Webhook配信

Max PrilutskiyCEO 兼 共同創業者Updated 先月 · 2 min read

プロビジョニングジョブを作成すると、202が返されます。そこにはエンジン ID とstatus: "in_progress"が含まれます。AI エージェントは現在、ソースをクロールしながら、ブランドボイス、用語集項目、ルールをバックグラウンドでそのエンジンに適用しています。完了までの時間は、クロールするリンクの数によって、すぐ終わることもあれば少し時間がかかることもあります。ライブ WebSocket 接続を開いたままにして処理の様子を追うこともできますが、エージェントがいつ完了したのか、何が構築されたのかを知るためだけに、接続を維持し続けるのは避けたいところです。

そこで使うのが webhook です。ジョブ作成時に callbackUrl を渡しておけば、Lingo はジョブが終了した瞬間に最終結果をその URL へ POST します。つまり、エンジンの準備が整ったタイミングで、何が作られたかの一覧付きで通知を受け取れます。 正常終了したジョブは provisioning.completed として届き、AI が作成したすべてのレコードの要約が含まれます。失敗したジョブは provisioning.failed として届き、その理由が含まれます。いずれの場合も、問い合わせに行かなくてもセットアップフロー側に通知されます。

このページでは、2 種類のペイロードとその扱い方を説明します。配信には署名が付き、再試行も行われます。この仕組みは ローカライゼーション と共通で、必要な箇所から参照できる Webhook署名の検証 ページにまとまっています。

このページの内容

配信の仕組み#

プロビジョニングジョブが終了するのは一度だけです。終端状態に達した瞬間、つまりすべてのソースのクロールと解析が完了したとき、または実行が中断されたとき、その結果は callbackUrl に 1 回の POST として配信されます。ローカライゼーショングループはターゲットロケールごとに 1 ジョブへ分かれ、それぞれが独自のコールバックを配信します。一方、プロビジョニングジョブは 1 つのジョブなので、配信も 1 回です。

送信先は、callbackUrlジョブを作成するときに で指定します。送られるペイロードの形式は 2 種類あり、type フィールドの値で provisioning.completedprovisioning.failed を見分けます。どちらにも、対応する jobIdengineId が含まれているため、1 つのハンドラーで type を見て振り分け、正しいレコードを更新できます。

HTTPS のみ

callbackUrl には HTTPS を使う必要があります。HTTP の URL はジョブ作成時に拒否されます。webhook には署名が付くため、平文通信で署名付きペイロードを送っても意味がないからです。

未知のイベントタイプも安全に扱う

現時点で送られるのは provisioning.completedprovisioning.failed です。ただし、この集合は今後増える前提で扱ってください。既知のタイプだけを分岐処理し、それ以外は無視しておけば、将来イベントタイプが追加されてもデプロイ済みのハンドラーが壊れません。

completed ペイロード#

ジョブが完了すると、ペイロードにsummaryが含まれます。これはジョブを読み取ったときに得られるものと同じインベントリで、ポーリングする代わりにプッシュで受け取れます。AI がそのエンジン上に作成したすべてのブランドボイス、用語集項目、ルールに加え、処理中に発生した項目ごとの失敗も一覧で確認できます。

json
{
  "type": "provisioning.completed",
  "jobId": "pjb_A1b2C3d4E5f6G7h8",
  "engineId": "eng_X1y2Z3a4B5c6D7e8",
  "summary": {
    "brandVoices": { "count": 3, "ids": ["bv_A1b2C3d4", "bv_B2c3D4e5", "bv_C3d4E5f6"] },
    "glossaryItems": { "count": 12, "ids": ["gi_A1b2C3d4", "..."] },
    "instructions": { "count": 5, "ids": ["ins_A1b2C3d4", "..."] },
    "errors": []
  }
}
フィールド説明
typeprovisioning.completed
jobId完了したプロビジョニングジョブ(pjb_ プレフィックス)
engineId設定されたエンジン(eng_ プレフィックス)
summaryAI がエンジン上に作成した内容。コンポーネントごとの件数と ID、および errors に入る項目単位の失敗を含みます

summary はジョブに含まれているものと同じオブジェクトです。各フィールドの意味、つまり各コンポーネントが何を表すか、項目がロケールにどう対応するか、errors に何が入るかは、AI が抽出するもの にまとめてあります。ここで押さえておきたいのは、completed ペイロードを受け取れば、エージェントが作成したすべての ID を取得できるということです。ジョブを再取得しなくても、ハンドラーで記録したりダッシュボードに表示したりできます。

errors 配列が空でなくても completed として届きます

項目単位の失敗でジョブ全体が失敗扱いになることはありません。1 つのソースをクロールできなかった場合や、1 つのレコードを作成できなかった場合、その内容は summary.errors に入り、残りは引き続きエンジンに適用されます。つまり、ペイロードは provisioning.completed のままで、provisioning.failed にはなりません。completed イベントが意味するのは、ジョブが最後まで実行されたということです。何を修正すべきかは errors を確認してください。provisioning.failed ペイロードが送られるのは、実行の結果として使えるエンジンがまったく得られなかった場合です。

failed ペイロード#

プロビジョニングジョブが失敗するのは、実行しても使えるものが何も得られなかった場合です。たとえば、すべてのソースのクロールに失敗し、エージェントが解析できるコンテンツが一切なかったケースです。その場合でも通知は届きます。ペイロードタイプは provisioning.failed で、要約の代わりに error 文字列が含まれます。

json
{
  "type": "provisioning.failed",
  "jobId": "pjb_A1b2C3d4E5f6G7h8",
  "engineId": "eng_X1y2Z3a4B5c6D7e8",
  "error": "All sources failed to crawl. No content available for analysis."
}
フィールド説明
typeprovisioning.failed
jobId失敗したプロビジョニングジョブ
engineId作成はされたものの、未設定のまま残ったエンジン
errorジョブを完了できなかった理由を示す、人が読める説明

ここで当然気になるのは、ジョブが失敗したら、エンジンも失われるのか? という点です。答えはノーです。このペイロード内の engineId は、202 で受け取ったものと同じエンジンです。呼び出しを行った瞬間に作成されており、失敗した実行で追加されるはずだった設定が入っていないだけで、エンジン自体はそのまま残ります。失うのは抽出結果だけで、エンジンそのものではありません。送信内容を調整して再試行することもできますし、ダッシュボードから手動でエンジンを設定することもできます。クロールでジョブが失敗した場合は、原因がソースにあることがほとんどです。ソースタイプ では、どんなソースを指定すべきかを説明しています。

Webhookの処理#

ここで最初に思い浮かぶ疑問はもっともです。ハンドラーが実際の処理、たとえばデータベースへの書き込み、通知、ダッシュボード更新まで行うなら、接続を開いたままにして webhook をタイムアウトさせてしまわないか?

そのとおりです。だからこそ、Lingo を待たせないでください。まず 200 を返し、その後で処理します。 先に受信確認を返し、実際の処理はレスポンス送信後に行ってください。配信契約の全体像、つまりなぜ先に確認応答するのか、そうしない場合どの再試行スケジュールになるのかは、署名と配信 ページにあります。以下のハンドラーは、プロビジョニングペイロードでの実装イメージを示しています。

javascript
app.post("/webhooks/provisioning", verifyWebhook, async (req, res) => {
  // Acknowledge first - the job ends once, so this fires once.
  res.status(200).send("ok");

  const { type, jobId, engineId } = req.body;

  if (type === "provisioning.completed") {
    const { summary } = req.body;
    await db.engines.update({
      where: { engineId },
      data: {
        status: "ready",
        brandVoiceCount: summary.brandVoices.count,
        glossaryCount: summary.glossaryItems.count,
        instructionCount: summary.instructions.count,
      },
    });
  }

  if (type === "provisioning.failed") {
    console.error(`Provisioning failed: ${jobId} (${engineId})`, req.body.error);
    await db.engines.update({
      where: { engineId },
      data: { status: "needs_configuration" },
    });
  }
});

このページで定義していない唯一の要素が verifyWebhook ミドルウェアです。すべての配信は Standard Webhooks 仕様に従って署名されます。使うのは 3 つのヘッダー、生のボディに対する HMAC、そしてコールバック付きで初めてジョブを送信したときに発行される whsec_ シークレットです。プロビジョニングと ローカライゼーション のコールバックは、どちらもこの方式をそのまま使うため、説明は Webhook署名の検証 にまとめています。ペイロードを信頼する前に、このミドルウェアを必ず組み込んでください。検証されていないボディは、認証されていないボディでもあります。

ボディを信頼する前に検証する

エンドポイントは公開 URL です。誰でもそこへ POST できます。どんなペイロードでも処理する前に、生のリクエストボディに対して署名を検証してください。エンジンを準備完了として扱う前に、あるいは作成されたとされる ID を保存する前に、です。方法、つまりヘッダー、HMAC、whsec_ シークレットについては、署名の検証 ページを参照してください。

配信が適さないケース#

webhook は便利なプッシュ通知であって、記録の正本ではありません。別の手段を使うべきケースが 2 つあり、どちらもすぐ次のリンクから確認できます。

結果配信時にエンドポイントがダウンしていても、プラットフォームは Lingo のすべての webhook と同じスケジュールで再試行します。しかも、結果がコールバックの中だけに閉じ込められるわけではありません。AI が作成したレコードはエンジンの実際の設定そのものであり、completed の要約は、実在するエンジン上ですでに行われた作業のレポートにすぎません。その唯一のコピーではないのです。つまり、しばらくダウンしていても失うのは通知だけで、エンジンではありません。再試行スケジュール自体は 署名と配信 ページにあります。

また、欲しいのがエンジン設定中のライブ進捗、つまり終了時にサーバーへ 1 回コールバックを受けることではなく、UI 上でクロールから設定までのステータスを見せることなら、使うべきなのは webhook ではなくプロビジョニングジョブの WebSocket です。接続時にスナップショットを返し、実行の進行に合わせて進捗イベントをストリームします。ジョブ完了後でも、いつでも接続できます。