# Webhooksガイド — バッチ完了コールバック

> ポーリングの代わりにコールバックURLを登録してバッチ完了を通知してもらう方法 — Standard Webhooksの署名検証、イベントペイロードの構造、リトライ動作、bbox完了イベント、バッチの再処理時に自動的に再通知される仕組みについて説明します。

バッチが完了するまで`GET /batches/{batch_name}`をポーリングし続ける代わりに、コールバックURLを一度登録すれば、完了と同時にHTTP POSTが届きます。3つの署名ヘッダーと`id.timestamp.body`の署名対象構造は、[Standard Webhooks](https://www.standardwebhooks.com/)のオープン仕様に準拠しています — OpenAIのWebhookでも同じ方式が採用されています。**仕様との違いが1つあります:** ここでの`webhook_secret`はプレーンな16進数文字列であり、`whsec_`プレフィックス付きのbase64値ではありません。そのため、既製のStandard Webhooks/Svix検証ライブラリはbase64デコードしようとして失敗します。シークレットは返されたそのままの値、つまり生のHMACキーバイトとして、以下の検証コード（または同等の独自実装）で使用してください。プレフィックスを認識するライブラリは使わないでください。

## Webhookの登録

バッチのコールバックを登録（または更新）するには `PUT /api/v1/batches/{batch_name}/webhook` を使用します：

```bash
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つのシークレットが発行されるため、シークレットが漏洩しても影響を受けるのはそのコールバックのみです：

```json
{
  "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は、以下の両方の種類のイベントを混在して受信できます。

```json
{
  "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](/developers/guides/async-model) を参照してください。ペイロードは完了通知であり、結果そのものではありません。受信後は `GET /batches/{batch_name}/results` を呼び出して実際の抽出データを取得してください。

ドキュメントで [bboxアノテーションジョブ](/developers/guides/bbox) をトリガーすると、そのドキュメントのバッチに既に登録されているWebhookが再利用され、`batch.completed` とは別のイベントが配信されます：

```json
{
  "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` ベースの重複排除（[下記参照](#verifying-signatures)）が適用されます。

## 署名の検証

Standard Webhooks 仕様に従い、各配信には次の3つのヘッダーが含まれます。

| ヘッダー | 目的 |
| --- | --- |
| webhook-id | この配信試行の一意のID。重複排除に使用します。再試行された配信は同じIDを再利用します。 |
| webhook-timestamp | 配信が送信されたUnixタイムスタンプ。リプレイ攻撃を防ぐために使用します（タイムスタンプが古すぎる配信は拒否します）。 |
| webhook-signature | 署名そのもので、 v1,<base64 signature> の形式で表されます。 |

署名は、`webhook-id`、`webhook-timestamp`、生のリクエストボディをこの順序で `.` で連結したものに対して、Webhookの `webhook_secret` をキーとしてHMAC-SHA256を適用したものです。

```text
signed_content = "{webhook_id}.{webhook_timestamp}.{raw_request_body}"
signature      = base64(hmac_sha256(webhook_secret, signed_content))
```

ご自身の環境でこれを再計算し、`webhook-signature` ヘッダーの `v1,` 以降の値と（定数時間比較を使用して）比較してください。必ず*生の*リクエストボディのバイト列に対して検証を行ってください。パース済みJSONを再シリアライズしたものではありません。再シリアライズすると、空白やキーの順序が変わり、正規の配信でも署名が一致しなくなる可能性があります。

**cURL**

```bash
# 署名検証は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 \
  | base64
```

**Python**

```python
import 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())
```

**Javascript**

```javascript
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リファレンス](/developers/reference/webhooks) を参照） — そして、再アームは現在 `process` によって自動的に処理されます。古いアドバイスに従って作成された統合を使用している場合は、各ウェーブの前に再登録を安全に停止できます。それは何もしていなかったからです。

---

Source: https://imagetotable.ai/ja/developers/guides/webhooks
