Webhooks
Updated
A webhook is a URL, an event filter and a secret shown once. Every delivery is signed with the Standard Webhooks scheme, retried on a fixed schedule, and replayable with the same event id.
Register one
curl -s $FFA/webhooks -H "Authorization: Bearer $FFA_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/fax", "event_types": ["fax.delivered", "fax.failed", "fax.partially_delivered", "fax.outcome_unknown", "fax.received"]}' The answer carries the secret, whsec_..., once. Store it. POST /v1/webhooks/{id}/test sends a signed webhook.test event so you can check the receiver before a real one arrives.
Events
fax.queued, fax.started, fax.delivered, fax.partially_delivered, fax.failed, fax.outcome_unknown, fax.received, document.ready, document.rejected, balance.low, webhook.test. The sandbox fires the same ones as live.
The three headers
| Header | Value |
|---|---|
webhook-id | the event id, the same across retries and replays |
webhook-timestamp | unix seconds when this delivery was signed |
webhook-signature | v1,base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}")). During a secret rotation there are two, space separated |
Verify in Python
import base64, hmac, hashlib, time
def verify(secret: str, headers: dict, body: bytes, tolerance_s: int = 300) -> str:
h = {k.lower(): v for k, v in headers.items()}
msg_id, ts, sigs = h["webhook-id"], h["webhook-timestamp"], h["webhook-signature"]
if abs(time.time() - int(ts)) > tolerance_s:
raise ValueError("stale")
key = base64.b64decode(secret.removeprefix("whsec_"))
want = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
for sig in sigs.split():
if sig.startswith("v1,") and hmac.compare_digest(base64.b64decode(sig[3:]), want):
return msg_id
raise ValueError("no signature matched") Verify in TypeScript
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret: string, headers: Record<string, string>, body: Buffer, toleranceS = 300): string {
const h = Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]))
const id = h['webhook-id'], ts = h['webhook-timestamp'], sigs = h['webhook-signature']
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceS) throw new Error('stale')
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const want = createHmac('sha256', key).update(`${id}.${ts}.`).update(body).digest()
for (const sig of sigs.split(' ')) {
if (!sig.startsWith('v1,')) continue
const got = Buffer.from(sig.slice(3), 'base64')
if (got.length === want.length && timingSafeEqual(got, want)) return id
}
throw new Error('no signature matched')
} Verify over the raw body bytes, before any JSON parsing. Reject a timestamp older than 300 seconds. Dedupe your handler on webhook-id so a retry or a replay is a no-op.
How are deliveries retried?
A non-2xx answer schedules the next attempt from the row's own schedule: at once, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours. Every attempt is on GET /v1/webhook-deliveries with its status and the answer's first bytes, never the secret. POST /v1/webhook-deliveries/{id}/replay sends the event again with the same webhook-id.
What happens after an outage?
GET /v1/events?cursor=<cursor> serves the same events in sequence order, oldest first, with next_cursor on each page and an optional type filter. Walk it from the last cursor you stored and you have everything you missed, whether or not the webhook delivered.
How do I rotate a webhook secret?
POST /v1/webhooks/{id}/rotate-secret returns a new secret. The old one keeps signing for 24 hours, so both signatures are on each delivery and you can switch without a gap.