public webhook contract

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.

Schema
Download the machine-readable JSON Schema used by this page.

Path: /schemas/webhook-event-v1.json

Quickstart

step 01

Create a webhook endpoint in your Afterbatch dashboard and copy its signing secret.

step 02

Start with the simple default: verify x-afterbatch-signature in a raw-body handler using your signing secret.

step 03

Return 2xx after successful processing. Non-2xx responses are retried based on the backoff policy.

Delivery Headers

HeaderRequiredValueDescription
content-typeyesapplication/jsonPayload body MIME type.
user-agentyesafterbatch-worker/0.0.0Delivery runtime user agent.
x-afterbatch-signatureyessha256=<hex digest>HMAC-SHA256 signature computed over x-afterbatch-timestamp + '.' + raw body.
x-afterbatch-timestampyesUnix epoch secondsTimestamp captured when the delivery attempt starts; used in signature verification and optional freshness checks.
x-afterbatch-delivery-idyesUUIDStable delivery identifier reused across retries.
x-afterbatch-eventyesbatch.state_changedMirrors payload event_type.
x-afterbatch-correlation-idyesUUIDRequest correlation identifier for tracing/logs.
Example Payload
A realistic example of the 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"
  }
}
JSON Schema
Rendered from shared schema constants; file download stays versioned at /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 7 attempts.

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

Anthropic Batch Webhooks

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.

Gemini Batch Webhooks

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.

OpenAI Batch Webhooks

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.

Simple Verification (Default)
Uses the signing key plus 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 });
}
Advanced Verification (Optional)
Adds timestamp freshness checks and timing-safe byte comparison for stronger replay resistance.
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 });
}