Webhook events
BatchRouter delivers webhooks to tell your systems when work changes state — most importantly when a batch finishes — without you polling. Every webhook is an HTTPS POST with a JSON body and an HMAC-SHA256 signature you verify against a shared secret. This page documents the event envelope, the event types, how to verify signatures, how retries work, and how to inspect past deliveries.
For configuring where webhooks go (per-batch versus your org default), see the webhooks guide. For pushing result artifacts to your own S3-compatible bucket instead of receiving a callback, see delivery targets.
Where webhooks come from
Section titled “Where webhooks come from”A webhook fires to the URL you configure, with the secret you supply at the same time:
- Per-batch — pass a
webhookobject ({ "url", "secret" }) in thePOST /v1/batchesbody. This applies to that batch only. The secret must be 8–256 characters. - Org default — set a default URL and secret once with
PUT /v1/auth/account/delivery-webhook. Every batch that does not override it delivers here, and billing events (below) are delivered here too.
A per-batch webhook overrides the org default for that batch.
The event envelope
Section titled “The event envelope”Every webhook request carries the same set of headers and a JSON body. Headers are case-insensitive.
| Header | Description |
|---|---|
X-BatchRouter-Event | The event type, e.g. billing.balance_low. Lets you route by type without parsing the body first. |
X-BatchRouter-Timestamp | The timestamp signed alongside the body. Part of the signed string — do not ignore it. |
X-BatchRouter-Signature | base64url HMAC-SHA256 of {timestamp}.{body}, keyed with your webhook secret. |
Content-Type | application/json. |
The signed message is the timestamp, a literal ., and the raw, unmodified request body — concatenated as {timestamp}.{body}. You must compute the HMAC over the exact bytes you received, before any JSON parsing or re-serialization, or the signature will not match.
Acknowledge receipt by returning any 2xx status. Any non-2xx response (or a timeout) is treated as a failed delivery and scheduled for retry.
Event types
Section titled “Event types”Batch lifecycle
Section titled “Batch lifecycle”When a batch reaches a terminal state, BatchRouter delivers a signed event to the batch’s webhook (or your org default). The body carries the batch identifier and its final status so you can fetch results. Terminal statuses are completed, failed, cancelled, and expired; the live progression is pending → queued → routing → dispatched → processing → completing → completed.
On a successful completion event, fetch the output with GET /v1/batches/{batchId}/results (paginated) or pull a signed download with GET /v1/batches/{batchId}/artifact-url.
For the exact field list of the batch event body, see the interactive API reference and the raw spec.
Billing events
Section titled “Billing events”If you set an org delivery webhook and enable webhook delivery for billing alerts (alert_webhook_enabled via PUT /v1/billing/controls), spending and auto top-up events are delivered to the same URL, signed the same way. Each carries an id prefixed evt_, a created_at, the organization, and a typed billing object.
Spending alerts — X-BatchRouter-Event is one of:
| Event type | Fires when |
|---|---|
billing.balance_low | Available balance fell below your configured low-balance threshold. |
billing.limit_reached | Committed spend met a daily or monthly limit. |
billing.limit_threshold_reached | Spend crossed a configured percent-of-limit or absolute-spend threshold. |
Auto top-up outcomes — X-BatchRouter-Event is one of:
| Event type | Fires when |
|---|---|
billing.autotopup.succeeded | An automatic credit refill succeeded. |
billing.autotopup.failed | A refill attempt failed. |
billing.autotopup.disabled | Auto top-up was disabled after repeated failures. |
Monetary fields in billing events are decimal USD strings (for example "12.50"). See the API reference BillingAlertEvent and AutoTopupOutcomeEvent schemas for the full per-status shape.
Verifying the signature
Section titled “Verifying the signature”Recompute the HMAC over {timestamp}.{body} with your webhook secret and compare it to the X-BatchRouter-Signature header using a constant-time comparison. Always work from the raw request body — most frameworks parse JSON by default, which changes the bytes and breaks verification.
import crypto from 'node:crypto';
// rawBody must be the exact bytes/string received, NOT a re-serialized object.function verifyBatchRouterWebhook(rawBody, headers, secret) { const timestamp = headers['x-batchrouter-timestamp']; const received = headers['x-batchrouter-signature']; if (!timestamp || !received) return false;
const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('base64url');
const a = Buffer.from(received); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b);}
// Express example — capture the raw body for this route.import express from 'express';const app = express();
app.post( '/webhooks/batchrouter', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); if (!verifyBatchRouterWebhook(rawBody, req.headers, process.env.BATCHROUTER_WEBHOOK_SECRET)) { return res.status(400).send('invalid signature'); } const event = JSON.parse(rawBody); const type = req.headers['x-batchrouter-event']; // ... handle event, then fetch authoritative state via the API. res.sendStatus(200); },);import hmac, hashlib, base64
def verify_batchrouter_webhook(raw_body: bytes, headers, secret: str) -> bool: timestamp = headers.get("X-BatchRouter-Timestamp") received = headers.get("X-BatchRouter-Signature") if not timestamp or not received: return False
signed = f"{timestamp}.".encode("utf-8") + raw_body digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest() # base64url, no padding (matches X-BatchRouter-Signature). expected = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") return hmac.compare_digest(received, expected)
# Flask example — use the raw request body.from flask import Flask, request, abortapp = Flask(__name__)
@app.post("/webhooks/batchrouter")def batchrouter_webhook(): raw = request.get_data() # bytes, untouched if not verify_batchrouter_webhook(raw, request.headers, BATCHROUTER_WEBHOOK_SECRET): abort(400) import json event = json.loads(raw) event_type = request.headers.get("X-BatchRouter-Event") # ... handle event, then fetch authoritative state via the API. return "", 200# Webhooks are inbound to YOUR server — there is no outbound request to make.# To replay a captured delivery against your endpoint for testing,# resend the saved raw body and headers verbatim so the signature still matches:curl -X POST https://your-app.example.com/webhooks/batchrouter \ -H "Content-Type: application/json" \ -H "X-BatchRouter-Event: $SAVED_EVENT" \ -H "X-BatchRouter-Timestamp: $SAVED_TIMESTAMP" \ -H "X-BatchRouter-Signature: $SAVED_SIGNATURE" \ --data-binary @saved-body.jsonRetry and dead-lettering
Section titled “Retry and dead-lettering”Webhook delivery runs through a durable queue with its own dead-letter queue, so a transient failure on your side does not lose the event.
- A delivery that does not return
2xx(including timeouts and connection errors) is recorded as failed and retried automatically. Each delivery tracks anattempt_count, thelast_response_status,last_error, and thenext_attempt_at. - After repeated failures a delivery is dead-lettered (
status: dead_lettered) and stops retrying.
The platform records these transitions as delivery events — for example webhook.retry_scheduled and webhook.dead_lettered — which you can read back from the inspection endpoint below.
Because deliveries are retried, your handler should be idempotent: the same event may arrive more than once. Deduplicate on a stable identifier (the event id for billing events, or the batch_id plus status for lifecycle events) and make repeated processing a no-op.
Inspecting deliveries
Section titled “Inspecting deliveries”Use GET /v1/batches/{batchId}/webhooks to see the customer-visible delivery status, retry state, last failure, and persisted delivery events for a single batch — useful when a callback didn’t arrive and you need to know whether it was attempted.
curl https://api.batchrouter.com/v1/batches/bat_123/webhooks \ -H "Authorization: Bearer $BATCHROUTER_API_KEY"const res = await fetch('https://api.batchrouter.com/v1/batches/bat_123/webhooks', { headers: { Authorization: `Bearer ${process.env.BATCHROUTER_API_KEY}` },});const { data, events } = await res.json();import os, requests
res = requests.get( "https://api.batchrouter.com/v1/batches/bat_123/webhooks", headers={"Authorization": f"Bearer {os.environ['BATCHROUTER_API_KEY']}"},)body = res.json()deliveries, events = body["data"], body["events"]The response has two arrays:
data— one entry per delivery, each withid,batch_id, targeturl,status(pending,delivering,succeeded,failed, ordead_lettered),attempt_count,last_attempt_at,next_attempt_at,last_response_status, andlast_error.events— the persisted timeline of delivery events, each with itsid, theeventname (such aswebhook.retry_scheduledandwebhook.dead_lettered),status,webhook_id,batch_id,org_id, andcreated_at.
See the BatchWebhooksResponse schema in the API reference for the full field list.