Webhooks
バッチが完了するまでGET /batches/{batch_name}をポーリングし続ける代わりに、コールバックURLを一度登録すれば、完了と同時にHTTP POSTが届きます。3つの署名ヘッダーとid.timestamp.bodyの署名対象構造は、Standard Webhooksのオープン仕様に準拠しています — OpenAIのWebhookでも同じ方式が採用されています。仕様との違いが1つあります: ここでのwebhook_secretはプレーンな16進数文字列であり、whsec_プレフィックス付きのbase64値ではありません。そのため、既製のStandard Webhooks/Svix検証ライブラリはbase64デコードしようとして失敗します。シークレットは返されたそのままの値、つまり生のHMACキーバイトとして、以下の検証コード(または同等の独自実装)で使用してください。プレフィックスを認識するライブラリは使わないでください。
Webhookの登録
バッチのコールバックを登録(または更新)するには PUT /api/v1/batches/{batch_name}/webhook を使用します:
curl -X PUT https://imagetotable.ai/api/v1/batches/260716-4K9P/webhook \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"callback_url": "https://example.com/hooks/imagetotable"}'レスポンスにはWebhookごとに生成された webhook_secret が含まれます。アカウント全体で共有されるキーではなく、登録ごとに1つのシークレットが発行されるため、シークレットが漏洩しても影響を受けるのはそのコールバックのみです:
{
"batch_name": "260716-4K9P",
"callback_url": "https://example.com/hooks/imagetotable",
"webhook_secret": "9f2a3b7c1d8e4f5061728394a5b6c7d81a2b3c4d5e6f7089",
"created_at": "2026-07-16T09:12:03Z",
"fired_at": null
}webhook_secret は必ず保存してください。受信した配信を検証するために必要です。シークレットを再表示する専用のエンドポイントはありませんが、同じ callback_url で再 PUT すれば安全に再取得できます(シークレットのローテーションやリセットは行われません)。
イベントエンベロープ
すべての配信はエンベロープにラップされたJSONオブジェクトとして送られます。生のバッチデータが直接送られることはありません。これにより、既存の連携を壊すことなく、将来のイベントタイプに対応して構造を拡張できます。type を確認してイベントを区別してください。バッチに登録されたコールバックURLは、以下の両方の種類のイベントを混在して受信できます。
{
"type": "batch.completed",
"created_at": "2026-07-16T09:14:31Z",
"data": {
"batch_name": "260716-4K9P",
"status": "succeeded",
"document_count": 3
}
}data.status はバッチの最終結果(succeeded または failed)を反映します。ステータスの完全な一覧は Async Task Model を参照してください。ペイロードは完了通知であり、結果そのものではありません。受信後は GET /batches/{batch_name}/results を呼び出して実際の抽出データを取得してください。
ドキュメントで bboxアノテーションジョブ をトリガーすると、そのドキュメントのバッチに既に登録されているWebhookが再利用され、batch.completed とは別のイベントが配信されます:
{
"type": "document.bbox_completed",
"created_at": "2026-07-16T09:20:07Z",
"data": {
"document_id": "3f9c9e2a-1b7d-4e2b-9a3e-2f5b6c7d8e9f",
"group_batch_id": "bx_8a3c2e1f",
"status": "succeeded"
}
}batch.completed と同様に、このイベントの配信はアトミックに処理されるため、複数の行グループが異なるスレッドでほぼ同時に終了ステータスに達しても、1つのbboxジョブに対して2つの別々の document.bbox_completed 配信が行われることはありません。その上で、通常の配信失敗時の再試行ケースには、webhook-id ベースの重複排除(下記参照)が適用されます。
署名の検証
Standard Webhooks 仕様に従い、各配信には次の3つのヘッダーが含まれます。
| ヘッダー | 目的 |
|---|---|
webhook-id | この配信試行の一意のID。重複排除に使用します。再試行された配信は同じIDを再利用します。 |
webhook-timestamp | 配信が送信されたUnixタイムスタンプ。リプレイ攻撃を防ぐために使用します(タイムスタンプが古すぎる配信は拒否します)。 |
webhook-signature | 署名そのもので、v1,<base64 signature> の形式で表されます。 |
署名は、webhook-id、webhook-timestamp、生のリクエストボディをこの順序で . で連結したものに対して、Webhookの webhook_secret をキーとしてHMAC-SHA256を適用したものです。
signed_content = "{webhook_id}.{webhook_timestamp}.{raw_request_body}"
signature = base64(hmac_sha256(webhook_secret, signed_content))ご自身の環境でこれを再計算し、webhook-signature ヘッダーの v1, 以降の値と(定数時間比較を使用して)比較してください。必ず生のリクエストボディのバイト列に対して検証を行ってください。パース済みJSONを再シリアライズしたものではありません。再シリアライズすると、空白やキーの順序が変わり、正規の配信でも署名が一致しなくなる可能性があります。
# 署名検証はHTTP呼び出しではないため、このスクリプトは
# ディスクに保存した配信データ(headers.txt / body.json)を
# opensslで再現します。実際のWebhookハンドラに検証コードを
# 実装する前に、手動でスキームの理解を確認するのに便利です。
WEBHOOK_ID=$(grep -i '^webhook-id:' headers.txt | cut -d' ' -f2 | tr -d '\r')
WEBHOOK_TS=$(grep -i '^webhook-timestamp:' headers.txt | cut -d' ' -f2 | tr -d '\r')
SIGNED_CONTENT="${WEBHOOK_ID}.${WEBHOOK_TS}.$(cat body.json)"
echo -n "$SIGNED_CONTENT" \
| openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
| base64import base64
import hashlib
import hmac
import os
headers = dict(line.split(": ", 1) for line in open("headers.txt") if ": " in line)
webhook_id = headers["webhook-id"].strip()
webhook_timestamp = headers["webhook-timestamp"].strip()
body = open("body.json", "rb").read()
signed_content = f"{webhook_id}.{webhook_timestamp}.".encode() + body
signature = base64.b64encode(
hmac.new(os.environ["WEBHOOK_SECRET"].encode(), signed_content, hashlib.sha256).digest()
)
print(signature.decode())import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";
const headers = Object.fromEntries(
readFileSync("headers.txt", "utf8")
.split("\n")
.filter((line) => line.includes(": "))
.map((line) => line.split(": "))
);
const webhookId = headers["webhook-id"].trim();
const webhookTimestamp = headers["webhook-timestamp"].trim();
const body = readFileSync("body.json", "utf8");
const signedContent = `${webhookId}.${webhookTimestamp}.${body}`;
const signature = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(signedContent)
.digest("base64");
console.log(signature);手動でテストする場合(配信のボディを自分でbody.jsonに保存してから上記のスニペットを実行する場合)、エディタや取得方法によって末尾に改行が追加されていないか注意してください。curl/openssl版は偶然この問題の影響を受けません(bashの$(cat ...)は末尾の改行を削除します)が、Python版とJavaScript版はファイルの正確なバイトを読み取るため、余分な\nが混入すると、一致しない署名を静かに生成します。典型的な症状は「curlでは有効と言われるのにPythonの検証機では無効と言われる」というもので、コード自体にバグがないため混乱を招きます。実際のWebhookハンドラでは、Webフレームワークの生リクエストボディアクセサ(Flaskのrequest.get_data()、Expressのraw-bodyミドルウェアなど)を使ってボディを取得してください。コピペや手動保存は避ければ、本番環境ではこの問題は発生しません。これは手動テストの落とし穴にすぎません。
リトライ
バッチ完了時に、即座に1回目の配信が試行されます。その試行が失敗した場合(タイムアウト、接続エラー、または2xx以外のレスポンス)、指数バックオフでリトライされます:1分、5分、30分、2時間、6時間、24時間 — 合計7回の試行(初回+6回のリトライ)まで行われます。初回試行から24時間以内にいずれも成功しなかった場合、配信は失敗とマークされ、それ以降のリトライは行われません。イベントを確実に記録したら、すぐに2xxステータスで応答するようにエンドポイントを設定してください。遅い処理(バッチ結果の実際の取得や解析など)は応答後に行い、応答前には行わないでください。そうしないと、下流の遅いステップ自体がリトライの原因になります。
バッチの再処理:ウェーブごとに1つの通知
特定の batch.completed 通知は、バッチ内のすべてのドキュメントが最終ステータスに達した瞬間に1回だけ送信されます。後で同じ batch_name にさらにドキュメントを追加し、再度 process を呼び出すと — 「新しいウェーブ」 — そのウェーブが完了した時点でも自動的に通知されます。process_batch は、以前のウェーブですでに通知済みのバッチ内に新しい対象ドキュメントを見つけると、Webhook自体を自動的に再アームします。ウェーブ間でWebhookを再 PUT したり、その他の操作を行う必要はありません。
2つのウェーブが重なった場合 — 前のウェーブがまだ実行中に、さらにドキュメントを追加して処理した場合(前のウェーブが終了した後ではなく) — 両方のウェーブのすべてのドキュメントをカバーする 1つ の通知が、最後のドキュメント(どちらかのウェーブの)が完了した時点で配信されます。2つの別々の通知が送られるのは、2回の process 呼び出しの間にバッチが実際にアイドル状態になった場合(すべてのドキュメントが少なくとも1回は最終ステータスに達した場合)のみです。
このドキュメントの以前のバージョンでは、1回のみの通知を恒久的な制限として説明し、各ウェーブの前にWebhookを再 PUT して回避する必要があるとしていました。そのアドバイスは実際には機能しませんでした — 登録エンドポイントは、以前も今も、意図的に fired_at に関与しません(Webhookリファレンス を参照) — そして、再アームは現在 process によって自動的に処理されます。古いアドバイスに従って作成された統合を使用している場合は、各ウェーブの前に再登録を安全に停止できます。それは何もしていなかったからです。