Webhooks

Verifying Webhook Signatures

Verify that webhook deliveries are authentic and untampered

Every webhook delivery is signed with the endpoint's signing secret using HMAC-SHA256. Verify the signature before processing any event.

parseWebhookEvent (sync, node:crypto) and parseWebhookEventAsync (WebCrypto) throw WebhookVerificationError when the signature is invalid, the timestamp is stale (older than 5 minutes by default), or a required header is missing. Both return a typed WebhookEvent on success. Next.js app router uses the async function.

The signature scheme

The HMAC is computed over:

{webhook_id}.{timestamp}.{body}
  • webhook_id is the Webhook-Id header
  • timestamp is the Webhook-Timestamp header (Unix seconds)
  • body is the raw request body (not parsed JSON)

The result is base64-encoded and prefixed with v1,. The Webhook-Signature header may contain multiple space-separated signatures during key rotation. Accept any match.

For a verifier in another language, remove the whsec_ prefix from the signing secret and base64-decode the remainder to obtain the HMAC key. Compare signatures in constant time, and reject timestamps more than five minutes in the past or future. The SDK handles these checks.

Always read the raw body for signature verification. Parsing JSON first and re-stringifying changes whitespace and breaks the HMAC.

Next.js (app router)

// app/api/webhooks/agg/route.ts
import { parseWebhookEventAsync, WebhookVerificationError } from "@agg-build/sdk/server";

export async function POST(req: Request) {
  try {
    const event = await parseWebhookEventAsync(
      await req.text(),
      Object.fromEntries(req.headers),
      process.env.AGG_WEBHOOK_SECRET!,
    );

    switch (event.type) {
      case "trades.placed":
        await handleTrade(event.data);
        break;
      case "markets.resolved":
        await handleResolution(event.data);
        break;
    }
    return Response.json({ received: true });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return Response.json({ error: err.message }, { status: 401 });
    }
    throw err;
  }
}

Use parseWebhookEvent where node:crypto exists, and parseWebhookEventAsync for WebCrypto. Next.js pages-router handlers need export const config = { api: { bodyParser: false } }. In Express, mount express.raw({ type: "application/json" }) on the webhook route before any JSON middleware. Both must pass the original body bytes to the verifier.

The handler above illustrates signature verification. For production processing, add durable deduplication as described below before running your event handlers.

Idempotency

Deduplicate on Webhook-Id, which stays the same across retries. Concurrent deliveries can overlap, so a separate check, process, and mark sequence is not sufficient.

One option is a durable inbox in your own database. Create a unique key on the delivery ID:

CREATE TABLE agg_webhook_inbox (
  id text PRIMARY KEY,
  payload jsonb NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

After verification, insert the event atomically. Here db is your server's database client with parameterized queries. Return 2xx only after the insert commits; database errors must return a failure so sufwan-local-test can retry.

async function handle(req: Request) {
  const event = await parseWebhookEventAsync(
    await req.text(),
    Object.fromEntries(req.headers),
    process.env.AGG_WEBHOOK_SECRET!,
  );
  const webhookId = req.headers.get("webhook-id")!;

  await db.query(
    `INSERT INTO agg_webhook_inbox (id, payload) VALUES ($1, $2)
     ON CONFLICT (id) DO NOTHING`,
    [webhookId, JSON.stringify(event)],
  );

  return Response.json({ received: true });
}

Your worker processes unprocessed rows and commits its database changes and processed_at together in one transaction. Use the delivery ID as an idempotency key for external side effects too. Keep processed IDs for your replay window, not just the automatic retry period; the replay endpoint can resend older deliveries.

Events can arrive out of order. Use the event's resource IDs to read current API state before updating a balance or order display. See delivery behavior.

On this page