# Webhooks Guide — Batch Completion Callbacks

> Register a callback URL to get notified when a batch finishes instead of polling — Standard Webhooks signature verification, the event payload shape, retry behavior, the bbox completion event, and how reprocessing a batch gets notified again automatically.

Instead of polling `GET /batches/{batch_name}` until it's done, register a callback URL once and get an HTTP POST the moment a batch finishes. The three signature headers and the `id.timestamp.body` signed-content construction follow the [Standard Webhooks](https://www.standardwebhooks.com/) open specification — the same approach used by OpenAI's webhooks. **One difference from the spec's own convention:** `webhook_secret` here is a plain hex string, not a `whsec_`-prefixed base64 value — an off-the-shelf Standard Webhooks/Svix verifier library will try to base64-decode it and fail. Use the secret exactly as returned, as raw HMAC key bytes, with the verification code below (or your own equivalent) rather than a prefix-aware library.

## Registering a webhook

Register (or update) a batch's callback with `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"}'
```

The response includes a per-webhook `webhook_secret`, generated for you — one secret per registration, not a shared account-wide key, so a leaked secret only affects that one callback:

```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
}
```

Save `webhook_secret` — you'll need it to verify incoming deliveries. There's no separate "reveal secret" endpoint, but re-`PUT`-ing the same `callback_url` is a safe way to fetch it again if you lose it (it does not rotate the secret or reset anything).

## Event envelope

Every delivery is a JSON object wrapped in an envelope — never raw batch data — so the shape can grow to cover future event types without breaking existing integrations. Check `type` to tell events apart; a callback URL registered on a batch can receive both kinds below, mixed together.

```json
{
  "type": "batch.completed",
  "created_at": "2026-07-16T09:14:31Z",
  "data": {
    "batch_name": "260716-4K9P",
    "status": "succeeded",
    "document_count": 3
  }
}
```

`data.status` reflects the batch's collapsed outcome (`succeeded` or `failed` — see [Async Task Model](/developers/guides/async-model) for the full status vocabulary). The payload is a completion notice, not the results themselves — call `GET /batches/{batch_name}/results` after receiving it to fetch the actual extracted data.

Triggering a [bbox annotation job](/developers/guides/bbox) on a document reuses whatever webhook is already registered for that document's batch, and delivers a distinct event instead of another `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"
  }
}
```

Like `batch.completed`, delivery of this event is claimed atomically, so you won't receive two separate `document.bbox_completed` deliveries for one bbox job even if several row-groups reach a terminal status within moments of each other on different threads. The usual `webhook-id`-based dedup (see [below](#verifying-signatures)) still applies on top of that for the ordinary case of a failed delivery being retried.

## Verifying signatures

Every delivery carries three headers, per the Standard Webhooks spec:

| Header | Purpose |
| --- | --- |
| webhook-id | Unique ID for this delivery attempt. Use it to de-duplicate — a retried delivery reuses the same ID. |
| webhook-timestamp | Unix timestamp the delivery was sent, to guard against replay attacks (reject deliveries with a timestamp too far in the past). |
| webhook-signature | The signature itself, formatted as v1,<base64 signature> . |

The signature is an HMAC-SHA256 over the concatenation of `webhook-id`, `webhook-timestamp`, and the raw request body — in that order, joined with `.` — keyed with your webhook's `webhook_secret`:

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

Recompute this on your end and compare it (using a constant-time comparison) against the value after `v1,` in the `webhook-signature` header. Always verify against the *raw* request body bytes, not a re-serialized version of the parsed JSON — re-serializing can change whitespace/key order and produce a signature mismatch even for a genuine delivery.

**cURL**

```bash
# Signature verification isn't an HTTP call, so this recreates it with
# openssl against a delivery you've already saved to disk (headers.txt /
# body.json) — useful for confirming your understanding of the scheme by
# hand before wiring up real verification code in your webhook handler.
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);
```

If you're testing this by hand — saving a delivery's body to `body.json` yourself before running one of the snippets above — **watch for a trailing newline added by your editor or capture method**. The curl/openssl version above is accidentally immune to this (bash's `$(cat ...)` strips trailing newlines), but the Python and JavaScript versions read the file's exact bytes and will silently produce a signature that doesn't match if one extra `\n` snuck in — the classic symptom is "curl says it's valid but my Python verifier says it isn't," which is confusing precisely because the code itself has no bug. Capture the body via your web framework's raw-request-body accessor (Flask's `request.get_data()`, Express's raw-body middleware) in your actual webhook handler instead of copy-pasting/manually saving it, and this doesn't come up in production — it's only a hand-testing pitfall.

## Retries

Delivery is attempted once immediately when the batch completes. If that attempt fails (timeout, connection error, or a non-2xx response), it's retried with exponential backoff: **1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 24 hours** — up to 7 attempts total (the initial try plus 6 retries). If none of those succeed within 24 hours of the first attempt, the delivery is marked failed and no further retries happen. Make your endpoint respond with a 2xx status as soon as you've durably recorded the event — do slow processing (like actually fetching and parsing the batch's results) after responding, not before, so a slow downstream step doesn't itself cause a retry.

## Reprocessing a batch: one notification per wave

A given `batch.completed` notification fires once, the moment every document in the batch has reached a terminal status. If you later add more documents to that same `batch_name` and call `process` again — "a new wave" — **you'll be notified again once that wave finishes too**, automatically. `process_batch` re-arms the webhook itself the moment it finds new eligible documents in a batch whose previous wave already notified you; you don't need to re-`PUT` the webhook or do anything else between waves.

If two waves overlap — you add and process more documents while an earlier wave is still running, rather than after it finished — you'll get exactly **one** notification, covering every document from both waves, delivered once the last of them (from either wave) finishes. You only get two separate notifications when the batch actually went idle (every document reached a terminal status at least once) in between the two `process` calls.

Earlier versions of this doc described the once-only firing as a permanent limitation you had to work around by re-`PUT`-ing the webhook before each wave. That advice never actually worked — the registration endpoint was, and still is, deliberately hands-off about `fired_at` (see the [Webhooks reference](/developers/reference/webhooks)) — and re-arming is now handled automatically by `process` instead. If you're on an integration written against the old advice, you can safely stop re-registering before each wave; it was never doing anything.

---

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