payment.settled event to your server the moment it happens. Polling GET /v1/integration/payments?since=… keeps working and remains the documented catch-up path — webhooks are an upgrade, not a replacement. Keep the poller running at a relaxed cadence as your safety net.
Register an endpoint
https (localhost is allowed for development). Each tenant can have at most 3 active endpoints. GET /v1/integration/webhooks lists yours; DELETE /v1/integration/webhooks/:id disables one — delivery stops immediately.
The delivery
Each delivery is aPOST with three headers and a JSON body:
data is the same payment row shape your poller reads from /v1/integration/payments — one parser serves both paths. Fulfil deterministically on tierId + quantity (never by amount); buyerRef is a stable opaque buyer id, never an email.
watch.failed — when an agent tried and could not buy
A queued purchase (buy-at-the-drop or join-the-line) that fires and is refused
is terminal: it does not retry, and without a signal the buyer would never learn
what happened. So every terminal failure pushes watch.failed through the same
signed pipeline:
reason.code values: spend_cap_exceeded, mandate_invalid (a quantity
guardrail or scope rule), not_found (the saved card is no longer usable),
sale_ended, payment_refused, sca_required. Relay the message to the
buyer — it is written to be shown, and it tells them what to change (raise the
limit, buy fewer, re-add the card).
watch.failed is not a payment: nothing was charged and nothing must be
fulfilled. Branch on type — fulfil only payment.settled.Verify the signature
The signature is an HMAC-SHA256 over the raw request body, prefixed with the timestamp — the same shape as Stripe’s, so an existing verifier ports directly:Verify against the raw body bytes, before any JSON parsing or re-serialization — a re-stringified body will not match. Most frameworks need a raw-body route or content-type parser for the webhook path.
Test your endpoint before a real purchase
Rotate a leaked secret
If a signing secret is exposed (a log, a screenshot, a shared terminal), rotate it rather than deleting and re-registering — the endpoint id and any queued retries survive:whsec_… once. The old secret stops verifying immediately, so deploy the new one promptly; deliveries attempted in between fail signature verification on your side and are retried, arriving correctly once the new secret is live.
Delivery semantics
- At-least-once. A settle durably enqueues the event, then delivery is attempted immediately and retried with exponential backoff (30s doubling, capped at 1h, up to 10 attempts) on the sweep tick. Any non-2xx response, timeout, or connection failure counts as a failed attempt.
- Dedupe by
eventId. Retries and edge cases can deliver the same event twice.eventIdis the payment’sintentId— process eacheventIdonce (the same discipline your poller already uses). - Answer fast. Respond
2xxas soon as you’ve recorded the event; do your fulfilment work asynchronously. Slow handlers risk the 10-second delivery timeout and a redundant retry. - Disabled means stopped. Deleting an endpoint stops deliveries immediately, including queued retries.
Keep the poller as the fallback
Webhooks tell you now; the poller guarantees eventually. The recommended integration:- Webhook receiver verifies, records by
eventId, fulfils. - Poller runs every few minutes with
?since=<your newest createdAt>, fulfilling anything the webhook path missed — deduped by the sameintentIdkey.

