BatchRouter Docs

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:

HeaderValue
X-BatchRouter-SignatureHMAC-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-TimestampThe Unix time (seconds) the delivery was signed
X-BatchRouter-EventThe event type, e.g. batch.completed

To verify:

  1. Reject deliveries whose X-BatchRouter-Timestamp is 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.
  2. Concatenate the timestamp, a ., and the raw request body bytes, compute the HMAC-SHA256 with your secret, and base64url-encode it (no = padding).
  3. 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.

  1. Return 2xx fast. Acknowledge with 200 once the payload is verified and persisted. Do heavy work asynchronously — a slow handler looks like a failure and triggers a retry.

  2. Dedupe by batch + state. Key processing on the event's batch.id and batch.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.

  3. Fetch authoritative state. On receipt, call GET /v1/batches/{batchId} to confirm the current batch.state 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

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).

  1. Poll GET /v1/batches/{batchId} until batch.state is terminal: completed, failed, canceled, or expired.

  2. On completed, read results with GET /v1/batches/{batchId}/results (paginate with ?limit and ?cursor), or for large batches get a signed file via GET /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.

Next steps

On this page