Skip to main content
Webhooks are how you find out what happened to a payout. You should not have to poll the API for state at all. You register one HTTPS URL for your account, and every event is signed with a secret only you and we hold. See Webhook events for the full catalogue.

Register your endpoint

The response contains your signing secret, prefixed whsec_.
The secret is returned once, when you set the URL or rotate it. It is never retrievable afterwards — GET /v1/webhooks/config only tells you whether one exists. Store it in your secrets manager before you discard the response.
The URL must be HTTPS. Plain HTTP is rejected except for localhost and 127.0.0.1, allowed for local development. An invalid URL returns 400 WEBHOOK_URL_INVALID. Requires the webhooks:manage scope.

Per-payout override

Set notification_url on an individual payout and that payout’s events go there instead of your default URL. Your tenant secret signs both, so verification does not change. Useful for routing a specific payout’s events to a different consumer without standing up a second account. Everything else still goes to the default.

Verify every delivery

Each request carries these headers: The signature is an HMAC-SHA256 over <timestamp>.<raw_request_body>, keyed with your signing secret.
Verify against the raw request body, before any JSON parsing. Parsing and re-serialising changes whitespace and key order, and the signature will never match. In Express, capture it with express.json({ verify: (req, _res, buf) => { req.raw_body = buf.toString(); } }).
Reject anything that fails verification. An unverified request is not from us.
If no secret is configured for your account, deliveries are sent unsigned — the signature headers are simply absent. Treat a missing X-Spendin-Signature as a failure in production rather than as “nothing to check”, and confirm GET /v1/webhooks/config reports a secret before you go live.

Responding

Return any 2xx, quickly. Anything else — or no response within 10 seconds — counts as a failure. Do your real work asynchronously: acknowledge first, process after. A handler that runs a slow database write inline will eventually time out and cause duplicate deliveries.

Retries

Failed deliveries are retried up to 5 attempts total (the first attempt plus four retries), with exponential backoff starting at 10 seconds. Every attempt is logged, whether it succeeded or not. After the fifth the delivery is marked DEAD and not retried again.
There is no replay endpoint. A DEAD delivery is not resent, so an endpoint that is down for long enough will permanently miss events.Recover by reading state back: GET /v1/payouts with a created_at filter on your side, or GET /v1/webhooks/deliveries to see exactly what was missed. This is why the status timeline is the authoritative history and webhooks are the notification.

Idempotency on your side

The same event may be delivered more than once. Use event_id as the dedupe key — it is stable across every retry.
Do not deduplicate on created_at or on arrival order. Deliveries can arrive out of order; treat status in the payload as the truth and ignore an event that would move a payout backwards from where your record already is.

Debugging

This is the fastest way to tell “we never sent it” from “your endpoint rejected it”. failure_reason carries the HTTP status or the transport error — a timeout, a DNS failure, a TLS problem.

Rotating the secret

The new secret is returned once and takes effect immediately — there is no grace window during which the old one still verifies.
Deploy the new secret to your handler within the same change, or in-flight deliveries signed with the new secret will fail verification against your old one. If you need zero-gap rotation, have your handler accept either secret for a short period, then drop the old one.