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

  1. Read the raw body as bytes.
  2. Verify Content-Digest, then the signature. If either fails: log it, answer 401, stop.
  3. Look up the envelope id. Seen it before? Answer 200, stop.
  4. Persist the event: id, type, occurredAt and the body.
  5. Return 2xx.
  6. 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 2xx is a promise you can keep. Once you have answered 2xx we 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:read scope.
  • Page on createdAt, ascending, and keep a high-water mark. occurredAt can 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 id and data are identical.
  • Filter with type, relatedObjectType, relatedObjectId or clientId when 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

  1. Stand up a publicly reachable HTTPS endpoint. During development a tunnel works, as long as it passes the Host header through unchanged. If it rewrites Host, every signature check fails. http will not be called at all.
  2. Send us the URL and the event types. We register it; a verification ping arrives within a minute or so.
  3. Confirm the ping. Return 2xx and the endpoint goes Active. If nothing arrives, check your TLS certificate and that the host resolves publicly.
  4. Trigger a real event. POST /v1/simulations/inbound-payments credits one of your sandbox accounts exactly as a real inbound Faster Payment would, and produces inbound-payment.received.v1.
  5. Check the whole chain, not just the status code: the digest matched, the signature verified against a fetched key, the id was stored, the payment fetched from the API.
  6. Test failure on purpose. Return 500 for a while and watch the retries arrive on the ladder; return 2xx again and watch the queue drain. Return 302 once to confirm for yourself that redirects are not followed.
  7. Test duplicates. Ask us to replay a delivery: the id is identical and Webhook-Attempt is 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

SymptomUsual cause
Every delivery fails signature verification, digest is fineSomething between us and your code rewrites the Host header or the path. @authority and @request-target are signed
Digest mismatchThe 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 allThe 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 whileThe 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 arriveCheck subscriptions, then reconcile with GET /v1/events. Ask us for a missed-deliveries replay
Duplicates in your databaseDe-duplicate on the envelope id, not on the delivery id or the attempt
Events arrive out of orderExpected. 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 keyid triggers one rate-limited refetch, not a hot loop.
  • created checked 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 2xx in well under 15 seconds; the real work is asynchronous.
  • Unknown event types and unknown fields ignored, not rejected.
  • Failures return 5xx so the retry ladder can do its job.
  • A reconciliation job polls GET /v1/events on 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.

Did this page help you?