Demo build. Every business shown is fictional, data is synthetic and partner integrations are simulated.What is real here

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-Id
  • Agora-Webhook-Timestamp
  • Agora-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 signature

php

sdk/php/src/Webhook.php

$event = \Agora\Webhook::verify($rawBody, getallheaders(), [$secret]);

Source: docs/portal/docs/webhooks.md + GET /v1/portal webhooks