> ## Documentation Index
> Fetch the complete documentation index at: https://docs.usecleff.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive signed events at your own endpoint, verify them, and handle retries, duplicates and ordering.

Receive Cleff's events at your own HTTPS endpoint instead of polling. For
developers who track payouts and Beneficiaries (the payees) as they change.

<Note>
  **Agents:** fetch this page as plain markdown at `https://docs.usecleff.com/webhooks.md`.
</Note>

## 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.

```bash theme={null}
curl -X POST https://api.usecleff.com/v1/webhook-subscriptions \
  -H "Authorization: Bearer ck_sandbox_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/cleff/webhooks",
    "enabled_events": [
      "payout.disbursed",
      "payout.delivered",
      "payout.failed",
      "payout.returned"
    ]
  }'
```

```json theme={null}
{
  "subscription": {
    "id": "8f3c…",
    "environment": "sandbox",
    "url": "https://api.example.com/cleff/webhooks",
    "enabled_events": ["payout.disbursed", "payout.delivered", "payout.failed", "payout.returned"],
    "enabled": true,
    "created_at": "2026-05-28T00:00:00.000Z"
  },
  "secret": "whsec_sandbox_F2x…"
}
```

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](#rotate-the-signing-secret).

| Request | What it does |
| - | - |
| [`POST /v1/webhook-subscriptions`](/api-reference/webhook-subscriptions-create) | Creates the endpoint, or rotates the secret if one exists |
| [`GET /v1/webhook-subscriptions`](/api-reference/webhook-subscriptions-get) | Returns the endpoint, without its secret |
| [`PATCH /v1/webhook-subscriptions`](/api-reference/webhook-subscriptions-update) | Changes `url`, `enabled_events` or `enabled`, and keeps the secret |
| [`DELETE /v1/webhook-subscriptions`](/api-reference/webhook-subscriptions-delete) | Removes the endpoint |

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:

```text theme={null}
X-Cleff-Signature: t=1780057233,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

`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.

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const match = /t=(\d+),v1=([0-9a-f]+)/.exec(header);
  if (!match) return false;
  const [, tsStr, hex] = match;
  const ts = Number(tsStr);
  if (Math.abs(Math.floor(Date.now() / 1000) - ts) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest();
  const provided = Buffer.from(hex, "hex");
  return expected.length === provided.length && timingSafeEqual(expected, provided);
}
```

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.

<Warning>
  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.
</Warning>

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:

| Attempt | Sent after the previous attempt fails |
| - | - |
| 1 | Immediately |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 4 minutes |
| 6 | 8 minutes |
| 7 | 16 minutes |
| 8 | 32 minutes |

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>`](/api-reference/payouts-list), and
read a Beneficiary with
[`GET /v1/beneficiaries/{id}`](/api-reference/beneficiaries-get).

## Deduplicate and order events

<Danger>
  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.
</Danger>

Every delivery has the same envelope. The event's content is under `data`:

```json theme={null}
{
  "id": "evt_…",
  "type": "payout.disbursed",
  "api_version": "2026-05-27",
  "occurred_at": "2026-05-26T12:20:32.000Z",
  "created_at": "2026-05-26T12:20:33.000Z",
  "sequence": 10472,
  "data": {
    "payout_id": "5f0c1f6e-8f4b-4b0e-9f6d-2a7c9d1e3b42",
    "external_ref": "INVOICE-2026-0001",
    "beneficiary_id": "c2a7e4d9-1b3f-4e8a-9d6c-7f5b0a2e8c14",
    "amount": "500.00",
    "currency": "USD",
    "rail_ref": "ACH-2026-0001",
    "status": "disbursed"
  }
}
```

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](/concepts/payouts#read-amounts-in-minor-and-major-units).

| Field | Meaning |
| - | - |
| `id` | The delivery's ID. It's the same on every retry of one delivery, so deduplicate on it |
| `type` | The event type, one of those [listed below](#choose-the-events-you-receive) |
| `api_version` | The version of the `data` shape. A breaking change to `data` changes it; a new field doesn't |
| `occurred_at` | When the change happened. The same on every retry |
| `created_at` | When this attempt was sent. Different on every retry |
| `sequence` | A tiebreaker for two events with the same `occurred_at` |
| `data` | The event's content, described on its [event page](#choose-the-events-you-receive) |

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.

<Warning>
  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.
</Warning>

`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`.

| Type | Sent when | Content |
| - | - | - |
| `payout.disbursement_submitted` | The payout is sent to the provider | [Payout events](/api-reference/webhook-events-payout) |
| `payout.acknowledged` | The provider confirms it received the payout | [Payout events](/api-reference/webhook-events-payout) |
| `payout.estimated_arrival_at` | An expected delivery date is given or updated | [Payout events](/api-reference/webhook-events-payout) |
| `payout.disbursed` | The money has left Cleff | [Payout events](/api-reference/webhook-events-payout) |
| `payout.delivered` | The money has arrived in the Beneficiary's account | [Payout events](/api-reference/webhook-events-payout) |
| `payout.settled` | The money can no longer be reversed | [Payout events](/api-reference/webhook-events-payout) |
| `payout.canceled` | The payout was canceled before any money moved | [Payout events](/api-reference/webhook-events-payout) |
| `payout.rejected` | An approval decision refused the payout | [Payout events](/api-reference/webhook-events-payout) |
| `payout.failed` | The money never left Cleff | [Payout events](/api-reference/webhook-events-payout) |
| `payout.returned` | The money left, then came back | [Payout events](/api-reference/webhook-events-payout) |
| `beneficiary.details_submitted` | A Beneficiary completes an identity collection link | [Beneficiary events](/api-reference/webhook-events-beneficiary) |
| `beneficiary.bank_account.registered` | A bank account is registered, by any means except import | [Beneficiary events](/api-reference/webhook-events-beneficiary) |
| `beneficiary.enrollment_rejected` | A Beneficiary's bank account is refused | [Beneficiary events](/api-reference/webhook-events-beneficiary) |

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.