# Webhooks

> Every delivery carries webhook-id, webhook-timestamp and an HMAC-SHA256 signature. Verify it in Python and TypeScript, the retry schedule, replays and events.

Page: https://faxforagents.com/docs/webhooks/
Last changed: 2026-10-28

Updated Oct 28, 2026

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

| 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

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.

## From the blog

- [Send a fax from email: how email to fax works](https://faxforagents.com/blog/send-fax-from-email/)
- [What a fax API is and how one works, from PDF to T.30](https://faxforagents.com/blog/what-is-a-fax-api/)
