afterbatch
webhooks
Everything you need to successfully receive and verify afterbatch webhooks. Use this guide to understand how to setup webhooks for Anthropic, Gemini, or OpenAI batch APIs, exactly what we send, how we retry failures, and how to securely verify events in your codebase.
Quickstart
step 01
step 02
step 03
Delivery Headers
| Header | Required | Value | Description |
|---|---|---|---|
content-type | yes | application/json | Payload body MIME type. |
user-agent | yes | afterbatch-worker/0.0.0 | Delivery runtime user agent. |
x-afterbatch-signature | yes | sha256=<hex digest> | HMAC-SHA256 signature computed over x-afterbatch-timestamp + '.' + raw body. |
x-afterbatch-timestamp | yes | Unix epoch seconds | Timestamp captured when the delivery attempt starts; used in signature verification and optional freshness checks. |
x-afterbatch-delivery-id | yes | UUID | Stable delivery identifier reused across retries. |
x-afterbatch-event | yes | batch.state_changed | Mirrors payload event_type. |
x-afterbatch-correlation-id | yes | UUID | Request correlation identifier for tracing/logs. |
batch.state_changed payload your endpoint will receive.{
"event_version": 1,
"event_type": "batch.state_changed",
"event_id": "f54fbfe4-dd84-4ec6-a225-ab1772a9e9f6",
"occurred_at": "2026-02-19T18:21:33.214Z",
"watch_id": "c8c0f1af-a3cc-466e-963e-2ac912cbf097",
"project_id": "6dc9228f-3804-4fef-a402-fb8f0446f409",
"environment": "production",
"batch_id": "batch_013fQz7dNfJ7z7wjdY5N2BfQ",
"provider": "openai",
"current_state": "completed",
"previous_state": "in_progress",
"raw_status": "completed",
"request_counts": {
"total": 1200,
"succeeded": 1198,
"failed": 2
},
"delivery_mode": "include_completed_data",
"completion_data": {
"content_type": "application/x-ndjson",
"size_bytes": 128,
"body": "{\"custom_id\":\"req_1\",\"response\":{\"status_code\":200}}\n"
}
}/schemas/webhook-event-v1.json.{
"$id": "/schemas/webhook-event-v1.json",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"event_version": {
"type": "number",
"const": 1,
"description": "Event contract version."
},
"event_type": {
"type": "string",
"const": "batch.state_changed",
"description": "Webhook event type."
},
"event_id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"description": "Stable unique event identifier."
},
"occurred_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"description": "ISO-8601 timestamp of the underlying provider state transition."
},
"watch_id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"description": "Afterbatch watch identifier."
},
"project_id": {
"type": "string",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
"description": "Afterbatch project identifier."
},
"environment": {
"type": "string",
"minLength": 1,
"description": "Project environment where this watch is configured."
},
"batch_id": {
"type": "string",
"minLength": 1,
"description": "Provider batch identifier."
},
"provider": {
"type": "string",
"enum": [
"anthropic",
"openai",
"gemini"
],
"description": "Provider that owns the batch."
},
"current_state": {
"type": "string",
"enum": [
"pending",
"in_progress",
"completed",
"failed",
"canceled"
],
"description": "Current canonical batch state."
},
"previous_state": {
"anyOf": [
{
"type": "string",
"enum": [
"pending",
"in_progress",
"completed",
"failed",
"canceled"
]
},
{
"type": "null"
}
],
"description": "Previous canonical state. Null when state history is unavailable."
},
"raw_status": {
"type": "string",
"minLength": 1,
"description": "Provider-native status string observed at transition time."
},
"request_counts": {
"anyOf": [
{
"type": "object",
"properties": {
"total": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"description": "Total requests in the provider batch when available."
},
"succeeded": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"description": "Requests that finished successfully."
},
"failed": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"description": "Requests that reached a failed/canceled/expired outcome."
}
},
"required": [
"total",
"succeeded",
"failed"
],
"additionalProperties": false
},
{
"type": "null"
}
],
"description": "Provider-derived request counters when available, otherwise null."
},
"delivery_mode": {
"type": "string",
"enum": [
"notification_only",
"include_completed_data"
],
"description": "Webhook delivery mode configured on the endpoint."
},
"completion_data": {
"anyOf": [
{
"type": "object",
"properties": {
"content_type": {
"type": "string",
"minLength": 1,
"description": "Provider response content type."
},
"size_bytes": {
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991,
"description": "Raw byte size of provider completion output."
},
"body": {
"type": "string",
"description": "Provider completion output body as UTF-8 text."
}
},
"required": [
"content_type",
"size_bytes",
"body"
],
"additionalProperties": false
},
{
"type": "null"
}
],
"description": "Completed batch output when delivery mode is include_completed_data; otherwise null."
}
},
"required": [
"event_version",
"event_type",
"event_id",
"occurred_at",
"watch_id",
"project_id",
"environment",
"batch_id",
"provider",
"current_state",
"previous_state",
"raw_status",
"request_counts",
"delivery_mode",
"completion_data"
],
"additionalProperties": false,
"description": "afterbatch webhook event payload v1"
}Retries And Delivery
- At-least-once delivery
We guarantee delivery for every event at least once. If an endpoint times out, we'll try again with the same
event_id. Always deduplicate events based on this ID on your end. - Intelligent backoff
If your endpoint goes offline or responds with a server error, we will automatically retry. The backoff schedule expands geometrically: ever-increasing delays over a span of 5s -> 30s -> 2m -> 15m -> 1h -> 4h, up to a maximum of
7attempts. - Retryable vs Non-retryable errors
We automatically retry on network timeouts and specific HTTP statuses: 408, 429, and any code in the 500-599 ranges. Client errors (like 400 Bad Request or 401 Unauthorized) are treated as terminal drops right away, though you can always manually retry them from your dashboard.
AI Provider Webhooks
Many AI providers require you to manually poll their APIs to check if a batch request has completed. afterbatch abstracts this away, providing a unified webhook layer for your favorite models.
Does Anthropic support webhooks for batch requests? Natively, no. The Anthropic Message Batches API requires you to periodically poll the API for processing_status.
With afterbatch, you can easily setup Anthropic batch webhooks. We manage the polling behind the scenes and push a webhook event to your server the moment your Claude 3.5 Sonnet or Opus batches finish.
How to setup Gemini batch webhooks? Google's Gemini Batch Prediction API is powerful for processing datasets, but lacks direct webhook callbacks for job status updates.
Instead of building custom polling logic, use our platform to receive immediate webhook notifications when your Gemini 1.5 Pro or Flash batch predictions are complete.
Need webhooks for OpenAI batch API? Implementing reliable webhook listeners for OpenAI's Batch API can be complex when managing retries and dropped connections.
afterbatch provides reliable webhook delivery for your OpenAI jobs. Enjoy guaranteed at-least-once delivery and intelligent backoff without maintaining extra infrastructure.
Next.js Verification
afterbatch sends the same webhook payload and headers regardless of how strict your receiver verification is. Start with Simple verification by default, and use Advanced verification when you want extra replay hardening.
x-afterbatch-signature to authenticate each request.import { createHmac } from "node:crypto";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(request: Request) {
const signingSecret = process.env.AFTERBATCH_WEBHOOK_SECRET;
const signatureHeader = request.headers.get("x-afterbatch-signature") ?? "";
const timestamp = request.headers.get("x-afterbatch-timestamp");
if (!signingSecret || !timestamp || !signatureHeader.startsWith("sha256=")) {
return NextResponse.json({ error: "invalid signature setup" }, { status: 400 });
}
// Read raw body string before JSON parsing.
const rawBody = await request.text();
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = `sha256=${createHmac("sha256", signingSecret)
.update(signedPayload, "utf8")
.digest("hex")}`;
if (signatureHeader !== expectedSignature) {
return NextResponse.json({ error: "signature mismatch" }, { status: 401 });
}
let event: { event_id?: string };
try {
event = JSON.parse(rawBody) as { event_id?: string };
} catch {
return NextResponse.json({ error: "invalid json" }, { status: 400 });
}
// Deduplicate by event.event_id (at-least-once delivery).
console.log("event_id", event.event_id);
return NextResponse.json({ ok: true });
}import { createHmac, timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export async function POST(request: Request) {
const signingSecret = process.env.AFTERBATCH_WEBHOOK_SECRET;
const signatureHeader = request.headers.get("x-afterbatch-signature") ?? "";
const timestamp = request.headers.get("x-afterbatch-timestamp");
if (!signingSecret || !signatureHeader.startsWith("sha256=")) {
return NextResponse.json({ error: "invalid signature setup" }, { status: 400 });
}
// Read raw body string before JSON parsing.
const rawBody = await request.text();
// x-afterbatch-timestamp is a Unix timestamp in seconds.
if (!timestamp || Number.isNaN(Number(timestamp))) {
return NextResponse.json({ error: "invalid timestamp" }, { status: 400 });
}
const timestampMs = Number(timestamp) * 1000;
if (Math.abs(Date.now() - timestampMs) > 5 * 60 * 1000) {
return NextResponse.json({ error: "stale timestamp" }, { status: 400 });
}
const receivedHex = signatureHeader.slice("sha256=".length);
const signedPayload = `${timestamp}.${rawBody}`;
const expectedHex = createHmac("sha256", signingSecret)
.update(signedPayload, "utf8")
.digest("hex");
const expected = Buffer.from(expectedHex, "hex");
const received = Buffer.from(receivedHex, "hex");
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
return NextResponse.json({ error: "signature mismatch" }, { status: 401 });
}
// Optional replay guard: persist timestamp/event_id and reject duplicates.
let event: { event_id?: string };
try {
event = JSON.parse(rawBody) as { event_id?: string };
} catch {
return NextResponse.json({ error: "invalid json" }, { status: 400 });
}
console.log("event_id", event.event_id);
return NextResponse.json({ ok: true });
}