Webhooks & delivery
Receive BatchRouter batch results via signed webhooks — per-batch and org-default config, X-BatchRouter-Signature HMAC-SHA256 verification, and polling fallback.
BatchRouter delivers a signed webhook to your endpoint when a batch reaches a terminal state, so you don't have to poll. This guide shows how to register a webhook (per-batch or as an org-wide default), verify the X-BatchRouter-Signature HMAC, write an idempotent handler, and fall back to polling.
Configure a webhook
You can register a delivery endpoint in two ways. A per-batch webhook always overrides the org-level default for that batch.
Per-batch (on create)
Pass a webhook object with url and secret when you create the batch. The URL must be HTTPS; the secret is 8–256 characters and is used to sign every delivery.
curl -X POST https://api.batchrouter.com/v1/batches \
-H "Authorization: Bearer $BATCHROUTER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: batch-2026-06-18-001" \
-d '{
"quote_id": "qlock_abc123",
"items": [
{"customer_item_id":"item-1","operation":"responses","model":"gpt-5.4-mini","input":{"messages":[{"role":"user","content":"Summarize: BatchRouter routes batch-AI workloads across providers."}]}}
],
"webhook": {
"url": "https://example.com/hooks/batchrouter",
"secret": "a-long-random-shared-secret"
}
}'gpt-5.4-mini is a model id from the live catalog. List the models, regions, and pricing available to your org with GET /v1/catalog/models.
Org-wide default
Set a default endpoint once with PUT /v1/auth/account/delivery-webhook. Every batch you create without its own webhook block delivers here. Same constraints apply: HTTPS url, 8–256-char secret.
curl -X PUT https://api.batchrouter.com/v1/auth/account/delivery-webhook \
-H "Authorization: Bearer $BATCHROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/batchrouter",
"secret": "a-long-random-shared-secret"
}'Use a long, random secret (32+ bytes) and store it server-side. Rotate it by re-PUTting the org default or re-creating with a new per-batch secret. Resolution order per batch is: per-batch webhook → org default → poll.
Verify the signature
Every delivery is a POST with a JSON body and three BatchRouter headers:
| Header | Value |
|---|---|
X-BatchRouter-Signature | HMAC-SHA256 of {timestamp}.{raw body} — the timestamp, a period, then the raw request bytes — keyed with your webhook secret and base64url-encoded without padding |
X-BatchRouter-Timestamp | The Unix time (seconds) the delivery was signed |
X-BatchRouter-Event | The event type, e.g. batch.completed |
To verify:
- Reject deliveries whose
X-BatchRouter-Timestampis too far from your clock (5 minutes is a good tolerance). The timestamp is inside the signed payload, so an attacker can't replay an old delivery with a fresh timestamp. - Concatenate the timestamp, a
., and the raw request body bytes, compute the HMAC-SHA256 with your secret, and base64url-encode it (no=padding). - Compare against the header in constant time. Never parse or trust the JSON before the signature checks out.
Compare with a constant-time function (crypto.timingSafeEqual / hmac.compare_digest), and
sign the raw body — re-serializing parsed JSON can change the bytes and break the HMAC. The
signature is base64url, not hex: a hex digest of the body alone will never match.
import express from "express";
import crypto from "node:crypto";
const SECRET = process.env.BATCHROUTER_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;
const app = express();
// Capture the RAW body — required for an accurate HMAC.
app.use(express.raw({ type: "application/json" }));
app.post("/hooks/batchrouter", (req, res) => {
const signature = req.get("X-BatchRouter-Signature") ?? "";
const timestamp = req.get("X-BatchRouter-Timestamp") ?? "";
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!timestamp || !(age <= TOLERANCE_SECONDS)) {
return res.status(401).send("stale timestamp");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.`)
.update(req.body) // req.body is a Buffer of the raw bytes
.digest("base64url"); // base64url, no padding
const sigBuf = Buffer.from(signature);
const expBuf = Buffer.from(expected);
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event.type ("batch.completed", …) and event.batch (id, state, …)
res.sendStatus(200);
});Make your handler idempotent
BatchRouter retries failed deliveries (non-2xx responses or timeouts) with backoff, so the same event can arrive more than once. Your endpoint must be safe to call repeatedly.
-
Return 2xx fast. Acknowledge with
200once the payload is verified and persisted. Do heavy work asynchronously — a slow handler looks like a failure and triggers a retry. -
Dedupe by batch + state. Key processing on the event's
batch.idandbatch.state(and your own delivery record) so a redelivered event is a no-op. Treat the webhook as a signal to act, not the sole source of truth. -
Fetch authoritative state. On receipt, call
GET /v1/batches/{batchId}to confirm the currentbatch.statebefore acting on results.
You can inspect delivery attempts, retry state, and last-failure details for a batch with GET /v1/batches/{batchId}/webhooks — useful when an endpoint was down and you need to see whether a delivery was retried or dead-lettered.
Fall back to polling
Webhooks are optional. If you don't configure one, polling is the default path — and it's a good backstop even when webhooks are on (for example, if your endpoint had an outage).
-
Poll
GET /v1/batches/{batchId}untilbatch.stateis terminal:completed,failed,canceled, orexpired. -
On
completed, read results withGET /v1/batches/{batchId}/results(paginate with?limitand?cursor), or for large batches get a signed file viaGET /v1/batches/{batchId}/artifact-url.
Webhook delivery requires HTTPS. If you submit an http:// URL or one BatchRouter deems unsafe, the create/quote call returns 400 with the reason under error.details.preflight — fix the URL before the batch is accepted.