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_idis theWebhook-Idheadertimestampis theWebhook-Timestampheader (Unix seconds)bodyis 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.