Webhooks
Subscribe to event families, verify every delivery's signature and replay what you missed.
AGORA publishes every state change as an event (family.event, for example commerce.order.created or conversion.lead.qualified). A webhook subscription delivers the event families you choose to your HTTPS endpoint.
Subscribe
curl -s https://app.fondaq.ai/v1/webhooks/subscriptions -H "Authorization: Bearer $AGORA_API_KEY" \
-H "Agora-Version: 2026-10-25" -H "Idempotency-Key: wh-001" -H 'Content-Type: application/json' \
-d '{ "url": "https://example.test/agora/webhooks", "families": ["commerce", "conversion"] }'
In this demo build the server makes no outbound calls: use a sink://<name> URL and read deliveries from the in-process sink in the console. The signing secret is returned once. Rotate it with POST /v1/webhooks/subscriptions/{id}/rotate-secret; for 24 hours after a rotation each delivery carries two signatures, one per secret.
Verify every delivery
Each delivery carries Agora-Webhook-Id, Agora-Webhook-Timestamp and Agora-Signature: t=<unix>,v1=<hex>[,v1=<hex>], where v1 is the hex HMAC-SHA256 of "<t>.<raw body>" with your secret. Reject a delivery whose timestamp is more than 300 seconds old, compare signatures in constant time, and use the raw body exactly as received.
Retries and replay
A delivery that does not answer 2xx is retried with exponential back-off from 30 seconds, doubling up to one hour, for 24 hours, and then moves to a dead-letter queue. You can replay any delivery from the console (Developers > Webhooks). Deliveries can arrive more than once and out of order: use the event id to deduplicate and the event's timestamp to order.
Signature scheme
Agora-Webhook-IdAgora-Webhook-TimestampAgora-Signature: t=<unix>,v1=<hex>[,v1=<hex>]
v1 = hex(HMAC-SHA256(secret, `${t}.${rawBody}`)); reject timestamps older than 300 s; two v1 values for 24 h after a rotation.
Retries: exponential back-off from 30 s doubling to 1 h, for 24 h, then the dead-letter queue (replayable).
node (tested in CI)
sdk/samples/verify-webhook.mjs
#!/usr/bin/env node
// Signed-webhook verification sample (LP-14-02): copy this into your receiver. Node 18+, no dependencies.
//
// Every AGORA webhook delivery carries:
// Agora-Webhook-Id: the delivery id (use it to de-duplicate retries)
// Agora-Webhook-Timestamp: unix seconds
// Agora-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>] (two v1 values for 24 h after a secret rotation)
// v1 = hex(HMAC-SHA256(key = your endpoint secret (whsec_...), message = `${t}.${rawBody}`)).
// Verify against the RAW body bytes before parsing JSON, reject timestamps older than 300 s, compare in constant time.
//
// CLI use (CI runs this): node verify-webhook.mjs <secret> <timestamp> <signature-header> <body-file> -> exit 0 when valid
import { createHmac, timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
export const TOLERANCE_S = 300;
export function verifyAgoraWebhook(rawBody, headers, secret, nowS = Math.floor(Date.now() / 1000)) {
const get = (name) => { for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name) return Array.isArray(v) ? v[0] : v; return undefined; };
const sig = get("agora-signature");
if (!sig) return { ok: false, reason: "missing_signature" };
let t; const v1 = [];
for (const part of String(sig).split(",")) {
const [k, v] = [part.slice(0, part.indexOf("=")).trim(), part.slice(part.indexOf("=") + 1).trim()];
if (k === "t" && /^\d+$/.test(v)) t = Number(v);
if (k === "v1" && /^[0-9a-f]{64}$/i.test(v)) v1.push(v.toLowerCase());
}
if (t === undefined || !v1.length) return { ok: false, reason: "malformed_signature" };
if (Math.abs(nowS - t) > TOLERANCE_S) return { ok: false, reason: "timestamp_outside_window" };
const want = Buffer.from(createHmac("sha256", Buffer.from(secret, "utf8")).update(`${t}.${rawBody}`, "utf8").digest("hex"), "utf8");
const match = v1.some((h) => { const got = Buffer.from(h, "utf8"); return got.length === want.length && timingSafeEqual(got, want); });
if (!match) return { ok: false, reason: "signature_mismatch" };
return { ok: true, event: JSON.parse(rawBody), delivery_id: get("agora-webhook-id") };
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const [secret, ts, header, file] = process.argv.slice(2);
if (!secret || !ts || !header || !file) { console.error("usage: verify-webhook.mjs <secret> <timestamp> <signature-header> <body-file>"); process.exit(2); }
const body = readFileSync(file, "utf8");
const r = verifyAgoraWebhook(body, { "agora-signature": header, "agora-webhook-timestamp": ts }, secret, Number(ts));
console.log(JSON.stringify(r.ok ? { ok: true, event_type: r.event.event_type } : r));
process.exit(r.ok ? 0 : 1);
}
typescript
sdk/ts/src/webhooks.ts
import { verifyWebhook } from "@agora/sdk";
const r = verifyWebhook(rawBody, req.headers, [process.env.AGORA_WEBHOOK_SECRET!]);
if (!r.ok) return res.status(400).end();python
sdk/python/agora/webhooks.py
from agora.webhooks import verify_webhook
event = verify_webhook(raw_body, request.headers, [secret]) # raises on a bad signaturephp
sdk/php/src/Webhook.php
$event = \Agora\Webhook::verify($rawBody, getallheaders(), [$secret]);Source: docs/portal/docs/webhooks.md + GET /v1/portal webhooks