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

# Webhook subscriptions

> Register an endpoint and receive signed, idempotent, per-Payout-ordered events from Cleff.

A **webhook subscription** registers one HTTPS endpoint per environment to receive
domain events from Cleff covering the Payout in-flight lifecycle. Deliveries are:

* **Signed** with HMAC-SHA256 over `"<unix_ts>.<rawBody>"`, header `X-Cleff-Signature: t=<ts>,v1=<hex>`.
* **Idempotent**: every retry of a logical delivery carries the same `id`.
* **Per-Payout ordered** by `(occurred_at, sequence)`; see [Ordering and dedup](#ordering-and-dedup).

One endpoint per `(business, environment)`. A Business has one sandbox endpoint and one
production endpoint. (Fan-out to multiple endpoints per environment is on the roadmap;
the API contract is shaped to add it non-breaking.)

## Endpoints

| Method   | Path                        | Purpose                                                                     |
| -------- | --------------------------- | --------------------------------------------------------------------------- |
| `POST`   | `/v1/webhook-subscriptions` | Create or rotate the endpoint for the current environment                   |
| `GET`    | `/v1/webhook-subscriptions` | Retrieve the current endpoint                                               |
| `PATCH`  | `/v1/webhook-subscriptions` | Update URL, event allowlist, or `enabled` flag (does NOT rotate the secret) |
| `DELETE` | `/v1/webhook-subscriptions` | Delete the endpoint                                                         |

The environment (`sandbox` or `production`) comes from the credential; there is no
path-level selector.

## Signing secret

The signing secret is generated by Cleff (format `whsec_<env>_<random>`) and returned
**exactly once** on `POST`. Cleff stores it encrypted at rest but cannot reveal it back
to you after creation; if you lose it, `POST` again to rotate.

```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.disbursement_submitted",
      "payout.acknowledged",
      "payout.estimated_arrival_at",
      "payout.disbursed"
    ]
  }'
```

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

`POST` is idempotent on `(business, environment)`. Posting again rotates the secret
(hard swap: old secret stops verifying immediately; a Stripe-style overlap window is
on the roadmap).

## Verifying a delivery

```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; // replay window
  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest();
  const provided = Buffer.from(hex, "hex");
  return expected.length === provided.length && timingSafeEqual(expected, provided);
}
```

Reject any request where:

* the header is missing, malformed, or the timestamp is outside a **±300s replay window**;
* the recomputed HMAC does not equal the provided `v1` hex (use a **constant-time compare**);
* the body has been parsed by middleware that mutated bytes; verify against the
  raw request body.

This is the same signature scheme [Cleff uses to verify inbound provider webhooks](/api-reference/payouts):
one HMAC construction platform-wide.

## Payload envelope

Flat envelope; the event payload lives under `data`. The envelope shape is stable;
`data` is versioned independently via `api_version`.

```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": "po_…",
    "external_ref": "INVOICE-2026-0001",
    "beneficiary_id": "ben_…",
    "amount": "123.45",
    "currency": "USD",
    "rail_ref": "FAKE-ACH-2026-0001",
    "status": "disbursed"
  }
}
```

| Field         | Meaning                                                                                              |
| ------------- | ---------------------------------------------------------------------------------------------------- |
| `id`          | **Idempotency key.** Constant across all retries of one logical delivery. Dedup on this.             |
| `type`        | The event type. Subscribe with `enabled_events`.                                                     |
| `api_version` | Versions the `data` shape. New fields are additive; breaking changes bump this.                      |
| `occurred_at` | When the underlying state actually changed. Stable across retries.                                   |
| `created_at`  | When this delivery attempt was prepared. Differs across retries.                                     |
| `sequence`    | Platform-global monotonic integer stamped per delivery. Used only as a tiebreaker for `occurred_at`. |
| `data`        | The event-specific projection. See per-event docs.                                                   |

## Ordering and dedup

Strict wire ordering over a retrying HTTP queue is not achievable, and head-of-line
blocking would let one stuck delivery freeze every later event for the same Payout.
So the contract is:

> **Receivers order same-Payout events by `(occurred_at, sequence)` and dedup on `id`.**

* `occurred_at` is the domain-time epoch, stamped when the state changed, not when
  Cleff happened to enqueue the delivery. Stable across retries.
* `sequence` only breaks ties when two transitions stamp the same `occurred_at`.
* `id` is the idempotency key; storing the highest-`(occurred_at, sequence)` you've
  applied per Payout makes the integration safe against duplicate delivery and
  out-of-order arrival.

## Retry and dead-lettering

Failed deliveries retry with exponential backoff, up to **8 attempts**, stretching the
total window to several hours. Anything 2xx counts as delivered; anything else (including
network failure) retries. After the budget is exhausted, the delivery lands on Cleff's
internal dead-letter surface and is investigated by Ops; the subscription is **not**
auto-disabled in v1.

## Event types (v1)

All v1 events carry a shared core projection plus an event-specific tail:

```ts theme={null}
type Core = {
  payout_id: string; // Cleff Payout identifier
  external_ref: string | null; // Business-supplied reference (e.g. trade/invoice ID)
  beneficiary_id: string; // Cleff Beneficiary identifier
  amount: string; // Decimal string in major units, e.g. "123.45" (ISO 4217 exponent applied)
  currency: string; // ISO 4217 code, e.g. "USD"
  rail_ref: string | null; // Provider reference for the disbursement, once returned
};
```

| Type                            | Fires when                                                   | Tail fields                                 | Re-fire semantics                                                      |
| ------------------------------- | ------------------------------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------- |
| `payout.disbursement_submitted` | Cleff hands the disbursement to the upstream provider        | `status: "disbursement_submitted"`          | Once per Payout                                                        |
| `payout.acknowledged`           | First authenticated provider webhook arrives for the attempt | `acknowledged_at: string` (ISO-8601)        | Once per attempt                                                       |
| `payout.estimated_arrival_at`   | Provider supplies (or refines) an ETA for funds delivery     | `expected_delivery_date: string` (ISO-8601) | Re-fires only when the ETA value changes; same-ETA replays are dropped |
| `payout.disbursed`              | Provider confirmed the Payout has left Cleff                 | `status: "disbursed"`                       | Once per Payout                                                        |

`rail_ref` is the provider's opaque reference for the disbursement and is `null` until
the provider returns one, typically populated by `payout.disbursed`, sometimes earlier.

Cleff's relationship with the upstream provider is internal: subscribers never see the
provider name, the provider's payout ID, or the provider's raw lifecycle strings.
Lifecycle position is conveyed by `type`.
