Building a receiver
A webhook receiver is a small piece of software with a disproportionate number of ways to get it subtly wrong. This page is the shape that works, the recovery path when it does not, and how to test it before you go live.
The handler, in order
- Read the raw body as bytes.
- Verify
Content-Digest, then the signature. If either fails: log it, answer401, stop. - Look up the envelope
id. Seen it before? Answer200, stop. - Persist the event:
id,type,occurredAtand the body. - Return
2xx. - Process out of band: fetch the object from the API, then act.
Steps 1–5 should take milliseconds. Everything expensive belongs in step 6.
Why this order:
- Verify before parsing so unverified bytes never reach your JSON parser or your business code. The full recipe, with working Node and Python, is in Securing your webhook endpoint.
- De-duplicate before processing because at-least-once means duplicates are normal, not exceptional.
- Persist before responding so a
2xxis a promise you can keep. Once you have answered2xxwe consider the event delivered and will not send it again unless someone asks for a replay. - Respond before working. The timeout is 15 seconds and one delivery goes to your endpoint at a time, so a handler that takes ten seconds caps you at six events a minute and builds a backlog you will then have to drain.
Rules to build against
Idempotency. Key on the envelope id. The same id can arrive again after a timeout that actually succeeded, after a support replay, or during a type-version migration. Processing it twice must be harmless. A unique index on the event id is the simplest way to guarantee that.
No ordering. Do not assume the sent event arrives before the completed one, or that two events about the same payment arrive in the order they happened. Compare occurredAt, and when it matters, fetch the object from the API and use its current state.
Thin payloads. The body identifies the object; the API is the system of record. For anything that moves money or is shown to a customer, fetch: GET /v1/inbound-payments/{id}, GET /v1/outbound-payments/{id}, GET /v1/internal-payments/{id}.
Unknown types and unknown fields. Treat an unrecognised type as "acknowledge and ignore" and return 2xx. Configure your JSON parser to ignore unknown members; new optional fields are added to data without a version change.
Which client. If your auth group covers several member clients, one endpoint gets all of them. Route on the payload's clientId; the Webhook-Group-Id header identifies the group, not the client.
Timestamps. occurredAt is an ISO 8601 timestamp that renders its offset as +00:00 rather than Z, with fractional seconds only when non-zero. Parse it as an offset timestamp.
Failing. If you cannot process an event, return a 5xx and let the retry ladder bring it back. That is what it is for. Do not return 2xx and drop the event. Returning 2xx on a poison message and reconciling later from GET /v1/events is also fine; what is not fine is silently losing it.
Alerting. Alert on your own side when nothing has arrived in N minutes, and on your own rate of 401 and 5xx responses. A silently disabled endpoint looks exactly like a quiet day, and every delivery you reject counts as a failure on our side too.
Recovering events you missed
Every event is readable for 90 days from GET /v1/events, whether or not it was ever delivered. Use it after an outage, after a disabled endpoint, or as a nightly safety net.
GET /v1/events?createdFrom=2026-09-16T00:00:00Z&orderBy=createdAt&orderDirection=asc&pageSize=200
Authorization: DPoP <access_token>
DPoP: <fresh_dpop_proof>- Requires the
events:readscope. - Page on
createdAt, ascending, and keep a high-water mark.occurredAtcan go backwards between consecutive events, so a poller that advances on it will skip events. - Feed results through the same de-duplication and the same handler as deliveries: the
idanddataare identical. - Filter with
type,relatedObjectType,relatedObjectIdorclientIdwhen you only care about part of the feed.
A nightly reconciliation job over the last 24 hours costs very little and turns a silent receiver bug into a ticket instead of a discrepancy.
Testing in the sandbox
- Stand up a publicly reachable HTTPS endpoint. During development a tunnel works, as long as it passes the
Hostheader through unchanged. If it rewritesHost, every signature check fails.httpwill not be called at all. - Send us the URL and the event types. We register it; a verification ping arrives within a minute or so.
- Confirm the ping. Return
2xxand the endpoint goesActive. If nothing arrives, check your TLS certificate and that the host resolves publicly. - Trigger a real event.
POST /v1/simulations/inbound-paymentscredits one of your sandbox accounts exactly as a real inbound Faster Payment would, and producesinbound-payment.received.v1. - Check the whole chain, not just the status code: the digest matched, the signature verified against a fetched key, the
idwas stored, the payment fetched from the API. - Test failure on purpose. Return
500for a while and watch the retries arrive on the ladder; return2xxagain and watch the queue drain. Return302once to confirm for yourself that redirects are not followed. - Test duplicates. Ask us to replay a delivery: the
idis identical andWebhook-Attemptis higher. Your receiver should store nothing new.
Sandbox and production have different signing keys, so point each environment at its own /.well-known/jwks.json.
Troubleshooting
| Symptom | Usual cause |
|---|---|
| Every delivery fails signature verification, digest is fine | Something between us and your code rewrites the Host header or the path. @authority and @request-target are signed |
| Digest mismatch | The body was parsed and re-serialised, a middleware trimmed or re-encoded it, or the framework handed you a string instead of the raw bytes |
| "Unknown keyid" | Stale key-set cache after a rotation. Refetch once on an unknown keyid. Never pin one key |
| "created outside tolerance" | Clock drift on your server. Run NTP; the window is ±5 minutes |
| Nothing arrives at all | The endpoint is still Pending (the verification ping never got a 2xx), it is Disabled, or it is not subscribed to that type |
| Deliveries stopped after a while | The breaker is open after 10 consecutive failures (we are probing every 5 minutes), or the endpoint auto-disabled after five days of failure |
| A few events are missing but others arrive | Check subscriptions, then reconcile with GET /v1/events. Ask us for a missed-deliveries replay |
| Duplicates in your database | De-duplicate on the envelope id, not on the delivery id or the attempt |
| Events arrive out of order | Expected. Reconcile with occurredAt and the API |
When you contact support, send the Webhook-Delivery-Id and the time. We can see every attempt, your response code and the error we recorded, but never the payload we sent, so tell us the envelope id if the question is about content.
Go-live checklist
- Signature verified on every request, with the key set fetched from the production host and cached with its
ETag. - Unknown
keyidtriggers one rate-limited refetch, not a hot loop. -
createdchecked against a clock kept in sync by NTP. - Raw body preserved end to end; digest compared in constant time.
- De-duplication on the envelope
id, enforced by a unique index. - Handler answers
2xxin well under 15 seconds; the real work is asynchronous. - Unknown event types and unknown fields ignored, not rejected.
- Failures return
5xxso the retry ladder can do its job. - A reconciliation job polls
GET /v1/eventson a schedule. - Alerting on your own side for "no webhooks received in N minutes". An endpoint that is silently disabled looks exactly like a quiet day.
- Production URL registered with us, verified, and subscribed to the types you actually handle.
Updated about 2 months ago