Webhooks & delivery
BatchRouter delivers a signed webhook to your endpoint when a batch reaches a terminal status, 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
Section titled “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)
Section titled “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-4o-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" } }'const res = await fetch("https://api.batchrouter.com/v1/batches", { method: "POST", headers: { Authorization: `Bearer ${process.env.BATCHROUTER_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "batch-2026-06-18-001", }, body: JSON.stringify({ quote_id: "qlock_abc123", items: [ { customer_item_id: "item-1", operation: "responses", model: "gpt-4o-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", }, }),});const { batch } = await res.json();import os, requests
res = requests.post( "https://api.batchrouter.com/v1/batches", headers={ "Authorization": f"Bearer {os.environ['BATCHROUTER_API_KEY']}", "Content-Type": "application/json", "Idempotency-Key": "batch-2026-06-18-001", }, json={ "quote_id": "qlock_abc123", "items": [ { "customer_item_id": "item-1", "operation": "responses", "model": "gpt-4o-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", }, },)batch = res.json()["batch"]Org-wide default
Section titled “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" }'await fetch("https://api.batchrouter.com/v1/auth/account/delivery-webhook", { method: "PUT", headers: { Authorization: `Bearer ${process.env.BATCHROUTER_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ url: "https://example.com/hooks/batchrouter", secret: "a-long-random-shared-secret", }),});import os, requests
requests.put( "https://api.batchrouter.com/v1/auth/account/delivery-webhook", headers={ "Authorization": f"Bearer {os.environ['BATCHROUTER_API_KEY']}", "Content-Type": "application/json", }, json={ "url": "https://example.com/hooks/batchrouter", "secret": "a-long-random-shared-secret", },)Verify the signature
Section titled “Verify the signature”Every webhook request carries an X-BatchRouter-Signature header: the HMAC-SHA256 of the raw request body, keyed with your webhook secret, hex-encoded. Compute the same HMAC over the bytes you received and compare in constant time. Never parse or trust the JSON before the signature checks out.
import express from "express";import crypto from "node:crypto";
const SECRET = process.env.BATCHROUTER_WEBHOOK_SECRET;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 expected = crypto .createHmac("sha256", SECRET) .update(req.body) // req.body is a Buffer of the raw bytes .digest("hex");
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.batch (id, status, …) res.sendStatus(200);});import hashlibimport hmacimport osfrom flask import Flask, request, abort
SECRET = os.environ["BATCHROUTER_WEBHOOK_SECRET"].encode()app = Flask(__name__)
@app.post("/hooks/batchrouter")def batchrouter_webhook(): raw = request.get_data() # raw bytes — do not use request.json here signature = request.headers.get("X-BatchRouter-Signature", "") expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected): abort(401)
event = request.get_json() # ... handle event["batch"] (id, status, …) return "", 200Make your handler idempotent
Section titled “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 + status. Key processing on the
batch.idandstatus(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 current status before 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
Section titled “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}untilstatusis terminal:completed,failed,cancelled, 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.