Skip to main content
Receive Cleff’s events at your own HTTPS endpoint instead of polling. For developers who track payouts and Beneficiaries (the payees) as they change.
Agents: fetch this page as plain markdown at https://docs.usecleff.com/webhooks.md.

Subscribe an endpoint

Each environment has one webhook endpoint. The environment comes from the API key you call with, so a sandbox key manages the sandbox endpoint and a production key manages the production one. The URL must use HTTPS and resolve to a public address.
Store secret now. It’s the key you verify every delivery with, and Cleff returns it only in this response. If you lose it, rotate it. Cleff sends only the events listed in enabled_events, and nothing while enabled is false. Events you aren’t subscribed to aren’t saved for later, and neither are retries still pending when you disable the endpoint, delete it, or remove their event from enabled_events: turning it back on doesn’t resend them. Register the URL that handles the request itself, not one that redirects to it.

Verify the signature

Every delivery is a POST with a JSON body and an X-Cleff-Signature header:
t is the Unix time the delivery was signed. v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with your signing secret.
Reject the request with a non-2xx status when:
  • the header is missing or malformed;
  • t is more than 300 seconds from your clock, which stops an old delivery being replayed;
  • the HMAC you compute doesn’t match v1. Compare in constant time, as timingSafeEqual does.
Verify against the raw request body, byte for byte. If a framework parses the JSON and you re-serialize it, the bytes change and every signature fails.
Each attempt is signed when it’s sent, so a retry carries a fresh t and is signed with your current secret.

Respond, and let Cleff retry

Return any 2xx status within 10 seconds to acknowledge a delivery. Anything else counts as a failure and is retried: a 4xx or 5xx, a timeout, or a connection error. Store the event with its id as a unique key and return 2xx straight away, then do the work asynchronously. Slow processing inside the request turns into timeouts, and timeouts turn into duplicates. Cleff makes up to 8 attempts per delivery, backing off exponentially: The last attempt comes about an hour after the first. After it fails, Cleff stops retrying that delivery. The subscription stays enabled and later events are still sent. To catch up on payouts you missed, list the ones changed since your last event with GET /v1/payouts?updatedSince=<timestamp>, and read a Beneficiary with GET /v1/beneficiaries/{id}.

Deduplicate and order events

Deliveries can arrive more than once and out of order. Deduplicate on id and order events about the same payout or Beneficiary by (occurred_at, sequence). If you apply events in arrival order, a duplicate can be counted twice or a late event can move a payout back to an earlier state, and every request still succeeds.
Every delivery has the same envelope. The event’s content is under data:
The $500.00 USD payout to Jane Doe has left Cleff. amount is "500.00" because webhooks carry amounts in major units, while the API’s amount_minor for the same payout is 50000. See Read amounts in minor and major units. To handle deliveries safely:
  1. Skip an id you’ve already stored. Record each id when the event arrives, not when you finish processing it, so a repeat that arrives mid-processing is still caught.
  2. Find the subject. It’s data.payout_id for a payout.* event and data.beneficiary_id for a beneficiary.* event. The two families aren’t ordered against each other.
  3. Don’t let an older event overwrite a newer one. Keep the highest (occurred_at, sequence) you’ve applied for each subject, and don’t let an event that sorts before it change the subject’s status.
payout.delivered is the one exception. Its occurred_at is when the money arrived, which can be earlier than the payout.disbursed event for the same payout. Always apply it: a delivered payout stays delivered. If you receive two with different delivered_at values, the one with the higher sequence is the correction.

Rotate the signing secret

Call POST /v1/webhook-subscriptions again to rotate the secret. The response carries the new one, and the old one stops working at once. Repeating the call rotates it again. The call also replaces the endpoint’s url and enabled_events with the ones you send, and sets enabled to true. Read the current values with GET /v1/webhook-subscriptions first and send them back unchanged, or a rotation silently drops events you meant to keep.
There’s no overlap between the old and new secrets. Deploy the new secret to your endpoint as soon as you have it. Deliveries your endpoint rejects in the meantime are retried on the schedule above, signed with the new secret.
PATCH never rotates the secret, so you can change the URL or the event list without redeploying it.

Choose the events you receive

Subscribe to events by name in enabled_events. The event pages describe each event’s data. In sandbox, payouts always succeed and stop at payout.disbursed. You won’t see payout.delivered, payout.settled, payout.canceled or payout.returned until production, so subscribe to them before you go live.