非同期ジョブが完了しても、Lingo.dev では延々とポーリングする必要はありません。代わりに、登録済みの callbackUrl として設定した HTTPS エンドポイントに POST で通知します。便利な仕組みですが、同時にリスクもあります。公開 URL はインターネットから届くあらゆるリクエストを受け付けるため、URL を知られれば、誰でも偽造した「ジョブ完了」イベントをあなたのハンドラーに POST できてしまいます。
だから、すべてのコールバックで守るべきルールは同じです。信頼する前に、必ず検証する。 それぞれの配信には、あなたと Lingo.dev だけが知るシークレットから計算されたシグネチャが付いています。受信側で再計算し、一定時間比較で照合すれば、偽造ペイロードがビジネスロジックに到達することはありません。このページでは、その検証の仕組みだけをまとめています。localization と provisioning のコールバックは、どちらも同じ仕組みをそのまま使います。各ページではペイロードの形式を扱い、検証についてはこのページを参照します。
このページでわかること
3つのヘッダー#
Lingo.dev は Standard Webhooks 仕様に準拠しています。これは複数のプロバイダーが採用しているオープンな方式なので、ベンダー独自の特殊仕様ではなく、公開された仕様に基づいて検証できます。各配信には、次の 3 つのヘッダーが含まれます。
| ヘッダー | 説明 |
|---|---|
webhook-id | その配信を一意に識別する ID です。 |
webhook-timestamp | 配信が送信された時刻を表す Unix タイムスタンプ(秒)です。 |
webhook-signature | シグネチャ本体です: v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
署名対象は、この 3 つの要素をピリオドでつないだ文字列です。順番は、webhook-id、次に webhook-timestamp、最後に 生のリクエストボディ。この順序どおりである必要があります。この文字列を組み立て、シークレットで HMAC-SHA256 を計算し、その結果を base64 エンコードしたものが比較対象の値になります。
webhook-signature ヘッダーには、方式バージョン(v1,...)付きのシグネチャがスペース区切りで複数入る場合があります。検証側は、どれか 1 つでも 一致すればその配信を受け入れます。単一の値だけを見るのではなく、一覧として順に確認するのが、このヘッダーを安全にパースするやり方です。そのため、以下のサンプルでも含まれているすべてのシグネチャをループで確認しています。
署名シークレット#
シークレットは、callbackUrl を指定して初めてジョブを送信したときに、組織ごとに生成されます。形式は、先頭に whsec_ が付き、その後ろに base64 エンコードされた鍵バイト列が続きます。
whsec_Mf9aQ7n...base64...key...bytes実際の鍵バイト列を取り出すには、whsec_ プレフィックスを削除し、残りを base64 デコードします。HMAC キーとして使うのは、そのデコード後の値であって、プレフィックス付き文字列そのものではありません。見た目は合っているのに実装が一致しない、最もよくある原因は、whsec_... の文字列をそのまま署名に使ってしまうことです。必ず先にデコードしてください。
このシークレットは API キーと同じように扱ってください
署名シークレットは、本物のコールバックと偽造されたコールバックを見分けるためのものです。サーバー側だけで管理し、ソース管理にもクライアントバンドルにも含めないでください。これを持っている人は、あなたのハンドラーが受け入れるペイロードに署名できてしまいます。Lingo.dev の組織スコープ認証情報の扱いについては、API Keys を参照してください。
シグネチャを検証する#
検証は、ハンドラーの前段に一度だけ組み込む関数で行えます。その関数がやることは 3 つだけです。生のボディから期待されるシグネチャを再計算すること、一定時間比較で受信した値と照合すること、そして一致しないものをあなたのコードが動く前に拒否することです。同じ関数で、Lingo.dev が送るすべての非同期イベントを保護できます。ローカライゼーション完了、provisioning 完了、あらゆるイベントタイプ、あらゆるプロダクト面で共通です。
import crypto from "node:crypto";
function verifyWebhook(payload, headers, secret) {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
// Reject timestamps outside a tolerance window (replay prevention)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
throw new Error("Webhook timestamp too old");
}
// Recompute the expected signature over id.timestamp.body
const content = `${msgId}.${timestamp}.${payload}`;
const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
const expected = crypto
.createHmac("sha256", secretBytes)
.update(content)
.digest("base64");
// A delivery may carry several signatures; accept if any matches
for (const sig of signatures.split(" ")) {
const [version, value] = sig.split(",", 2);
if (version === "v1" && crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(value)
)) {
return JSON.parse(payload);
}
}
throw new Error("Invalid webhook signature");
}比較には、crypto.timingSafeEqual や hmac.compare_digest のような一定時間比較関数を使ってください。== は使わないでください。通常の文字列比較は、2 バイトの差分が見つかった時点で処理を打ち切るため、そのタイミング差からシグネチャが 1 バイトずつ漏れる可能性があります。一定時間比較はこのサイドチャネルを防ぐため、上の 2 つのサンプルでも使われています。
なぜ生のボディが重要なのか#
どちらの関数も、payload に対して署名している点に注目してください。つまり、JSON としてパースされる前の、受信したそのままのボディです。これは、他が正しくても統合でつまずきやすいポイントであり、問題になりやすい箇所だからこそ、ここではっきり書いておく価値があります。
シグネチャは、Lingo.dev が送信したまったく同じバイト列に対して計算されます。ボディを一度オブジェクトにパースしてから再シリアライズすると、空白、キー順、数値の表現が変わることがあります。そうなると、再計算した HMAC は元のバイト列に対するシグネチャと一致しません。ペイロードの意味が同じでも、バイト列が同じとは限らないのです。
パース済みオブジェクトではなく、生のボディで検証する
フレームワークがパースする前に、生のリクエストボディを取得し、そのバイト列を検証関数に渡してください。Express では webhook ルートで express.raw({ type: "application/json" }) を使います。FastAPI では await request.body() を読み取ります。パースするのは、シグネチャ検証を通過してからです。まず検証、次にパースです。
リプレイ攻撃を防ぐ#
攻撃者が取得した有効な署名付きペイロードは、そのまま再送できます。最初の配信時でも、1 時間後に送り直したコピーでも、シグネチャ自体は変わらないからです。その有効期間に上限を設けるのが webhook-timestamp ヘッダーです。このヘッダーには配信時刻が入っているため、検証側は自分で決めた許容時間より古いものを拒否できます。上のサンプルでは 5 分を使っています。
タイムスタンプを確認すれば、古いリプレイを防げます。取得されたコピーが許容時間を過ぎてから再送されても、新鮮性チェックで弾かれ、ハンドラーには届きません。
まずはすばやく応答し、処理は後で行う#
配信を検証できたら、すぐに 200 を返し、その後で本来の処理を行ってください。たとえば、データベースへの書き込み、下流サービスの呼び出し、キャッシュ無効化などです。
app.post(
"/webhooks/lingo",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = verifyWebhook(req.body.toString(), req.headers, process.env.LINGO_WEBHOOK_SECRET);
} catch {
return res.status(401).send("invalid signature");
}
// Acknowledge first, process after - never block the response on slow work
res.status(200).send("ok");
void handleEvent(event);
}
);理由は作法ではなく、仕組みの問題です。ハンドラーが遅いと HTTP 接続を開いたまま保持し続けます。タイムアウトするほど長引けば、その配信は失敗扱いとなって再試行されます。つまり、レスポンス経路の中で重い処理をすると、1 つのイベントが複数回の処理に増えてしまいます。まずはすばやく受領を返し、処理はキューやバックグラウンドタスクに渡してください。そうすれば、1 つのイベントは 1 つのイベントのままです。handleEvent の中で分岐するペイロード形式は各プロダクトのページにあります。localization callbacks と provisioning callbacks を参照してください。
再試行とバックオフ#
エンドポイントが一時的に落ちることはあります。デプロイ中、タイムアウト、Bad Gateway。そういうときでも、Lingo.dev はイベントを捨てません。
エンドポイントが non-2xx ステータスを返した場合、または到達不能な場合、配信は 30 秒から始まる指数バックオフで再試行され、最大 5 回 まで行われます。5 回目の試行後、その配信は失敗としてマークされ、Lingo.dev は再試行を停止します。ただし、結果そのものが失われるわけではありません。結果はジョブレコードから引き続き取得できるため、一定時間のダウンタイムで失うのはコールバックだけで、結果自体ではありません。このジョブレコードが最後の保険です。通常系は webhook で処理しつつ、保存済みジョブはいつでも頼れる正本として扱ってください。翻訳ジョブなら、直接ポーリングできます。
