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

Shell
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

Headers on every delivery
HeaderValue
webhook-idthe event id, the same across retries and replays
webhook-timestampunix seconds when this delivery was signed
webhook-signaturev1,base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}")). During a secret rotation there are two, space separated

Verify in Python

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

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.