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

# Cleo Pay Webhooks: Events, Delivery, and Signature Verification

> How Cleo Pay webhooks work: the event payload envelope, all event types, HMAC-SHA256 signature verification via the Cleo-Signature header, and retry behavior.

Webhooks let Cleo Pay push real-time notifications to your server when important events occur — such as a payable being approved, a payment completing or failing, or a bank account becoming verified. Instead of polling the API, you receive an HTTP `POST` request at your configured endpoint the moment an event fires.

<Note>
  Webhook endpoints (URL, event subscriptions, and signing secrets) are managed through the **Cleo Pay dashboard**. Your signing secret is shown **once** — when the endpoint is created or the secret is rotated — so store it securely.
</Note>

***

## How webhooks work

When a subscribed event occurs, Cleo Pay sends an HTTP `POST` request with a JSON body to your endpoint. Every delivery is signed — see [Verifying deliveries](#verifying-deliveries). Your endpoint must respond with any `2xx` status code within 10 seconds to acknowledge receipt; the response body is ignored.

<Warning>
  If your endpoint does not return a `2xx` response — due to a timeout, connection error, or non-2xx status — Cleo Pay retries the delivery with exponential backoff. The same event may be delivered more than once. Process events idempotently by deduplicating on the event `id`.
</Warning>

***

## Payload structure

Every delivery uses the same JSON envelope:

```json title="Example delivery" theme={null}
{
  "id": "evt_x2f8kq0m3vjw9r41ptbn7c5d",
  "type": "payable.approved",
  "sequence": "10427",
  "createdAt": "2026-07-15T12:00:00.000Z",
  "businessId": "0d2c7a3e-9f41-4b8a-b6c2-5e8f13a9d7e0",
  "data": {
    "object": "payable",
    "id": "b1e2c3d4-5f6a-7b8c-9d0e-1f2a3b4c5d6e"
  }
}
```

| Field         | Type   | Description                                                                                                                                             |
| ------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`          | string | Unique event ID (`evt_…`). Stable across retries **and** manual redeliveries — deduplicate on this. Also sent as the `Cleo-Event-Id` header.            |
| `type`        | string | The event type, e.g. `payable.approved`.                                                                                                                |
| `sequence`    | string | Global, monotonically increasing ordering signal. Serialized as a string because it can exceed `Number.MAX_SAFE_INTEGER` — compare it as a big integer. |
| `createdAt`   | string | ISO 8601 instant the event was generated. Fixed at emission — not the time of the current delivery attempt.                                             |
| `businessId`  | string | ID of the business the event belongs to.                                                                                                                |
| `data.object` | string | Resource type: `payable`, `bank_account`, or `contact`.                                                                                                 |
| `data.id`     | string | ID of the resource the event refers to.                                                                                                                 |

<Note>
  Payloads are **thin references** — they never contain a resource snapshot. Fetch the resource's current state from the API using `data.id` (for example, `GET /v1/payables/{id}`). A delivery that arrives late or out of order still leads you to the latest state.
</Note>

Events are dispatched in emission order on a best-effort basis, but retries and concurrent delivery mean **arrival order is not guaranteed**. Order events by `sequence` (or `createdAt`), never by arrival.

***

## Event types

Subscribe to individual event types, or to the wildcard `*` to receive all of them.

### Payable events

| Event                            | Fires when                                                       |
| -------------------------------- | ---------------------------------------------------------------- |
| `payable.created`                | A payable is created.                                            |
| `payable.approved`               | A payable is approved.                                           |
| `payable.declined`               | A payable's approval is declined.                                |
| `payable.cancelled`              | A payable is cancelled.                                          |
| `payable.approval_reset`         | A payable's approval state is reset and it requires re-approval. |
| `payable.payment.scheduled`      | A payment for the payable is scheduled.                          |
| `payable.payment.partially_paid` | A payment settles that covers part of the payable's balance.     |
| `payable.paid`                   | The payable is fully paid.                                       |
| `payable.payment.failed`         | A payment attempt fails.                                         |
| `payable.payment.reversed`       | A completed payment is reversed or returned.                     |

### Bank account events

| Event                              | Fires when                                                              |
| ---------------------------------- | ----------------------------------------------------------------------- |
| `bank_account.created`             | A bank account is added.                                                |
| `bank_account.verified`            | A bank account passes verification.                                     |
| `bank_account.verification_failed` | Verification fails — for example, micro-deposit attempts are exhausted. |
| `bank_account.removed`             | A bank account is removed.                                              |

### Contact events

| Event                | Fires when                              |
| -------------------- | --------------------------------------- |
| `contact.created`    | A contact is created.                   |
| `contact.updated`    | A contact's details change.             |
| `contact.archived`   | A contact is archived.                  |
| `contact.unarchived` | A contact is restored from the archive. |

***

## Delivery headers

Every delivery includes these HTTP headers:

| Header            | Value                                                                                        |
| ----------------- | -------------------------------------------------------------------------------------------- |
| `Cleo-Signature`  | `t=<unix seconds>,v1=<hex HMAC-SHA256>` — see [Verifying deliveries](#verifying-deliveries). |
| `Cleo-Event-Id`   | The event ID (`evt_…`). Identical across retries and redeliveries of the same event.         |
| `Cleo-Webhook-Id` | Unique ID for this delivery. A manual redelivery of the same event gets a new value.         |
| `Content-Type`    | `application/json`                                                                           |
| `User-Agent`      | `Cleo-Webhooks/1.0`                                                                          |

***

## Verifying deliveries

Every delivery is signed with **HMAC-SHA256** so you can confirm it came from Cleo Pay and was not tampered with in transit. The `Cleo-Signature` header carries a Unix timestamp and a hex-encoded signature:

```text theme={null}
Cleo-Signature: t=1751328000,v1=fc4197d20c24733ce3bc083109ec596a6e7db714839c35fe02f6777f1fe6cb0c
```

The signature is the HMAC-SHA256 of the string `{t}.{raw request body}` — the timestamp, a literal dot, then the exact body bytes — keyed with your endpoint's signing secret (`whsec_…`).

<Steps>
  <Step title="Capture the raw request body">
    Verify against the exact bytes Cleo Pay sent. Do not JSON-parse and re-serialize the body first — any change in key order or whitespace invalidates the signature.
  </Step>

  <Step title="Parse the Cleo-Signature header">
    Extract `t` (Unix timestamp in seconds) and `v1` (hex-encoded signature).
  </Step>

  <Step title="Reject stale timestamps">
    If `t` is more than 5 minutes from the current time, reject the request. This bounds replay attacks. The timestamp is computed fresh for every delivery attempt, so legitimate retries always carry a current `t`.
  </Step>

  <Step title="Recompute and compare">
    Compute the HMAC-SHA256 of `{t}.{rawBody}` with your signing secret and compare it to `v1` using a constant-time comparison.
  </Step>
</Steps>

<CodeGroup>
  ```typescript verify-cleo-signature.ts theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  export function verifyCleoSignature(secret: string, rawBody: string, header: string): boolean {
    let t: number | undefined;
    let v1: string | undefined;

    for (const part of header.split(",")) {
      const eqIndex = part.indexOf("=");
      if (eqIndex === -1) continue;
      const key = part.slice(0, eqIndex).trim();
      const value = part.slice(eqIndex + 1).trim();
      if (key === "t") t = Number(value);
      else if (key === "v1") v1 = value;
    }

    if (t === undefined || !Number.isFinite(t)) return false;
    if (!v1 || v1.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(v1)) return false;

    const nowSeconds = Math.floor(Date.now() / 1000);
    if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;

    const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
    const actual = Buffer.from(v1, "hex");
    if (expected.length !== actual.length) return false;

    return timingSafeEqual(expected, actual);
  }
  ```

  ```typescript Express endpoint theme={null}
  import express from "express";
  import { verifyCleoSignature } from "./verify-cleo-signature";

  const app = express();

  app.post("/webhooks/cleo", express.raw({ type: "application/json" }), (req, res) => {
    const rawBody = req.body.toString("utf8");
    const header = req.header("Cleo-Signature") ?? "";

    if (!verifyCleoSignature(process.env.CLEO_WEBHOOK_SECRET!, rawBody, header)) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(rawBody);
    // Deduplicate on event.id, enqueue for processing, then acknowledge.
    res.status(200).send("ok");
  });
  ```
</CodeGroup>

### Test your implementation

Use this known-good vector to unit-test the HMAC computation (bypass the timestamp-freshness check for the fixed `t`):

| Input         | Value                                                              |
| ------------- | ------------------------------------------------------------------ |
| Secret        | `whsec_test_known_vector`                                          |
| Timestamp `t` | `1751328000`                                                       |
| Raw body      | `{"id":"evt_test123","type":"payable.approved"}`                   |
| Expected `v1` | `fc4197d20c24733ce3bc083109ec596a6e7db714839c35fe02f6777f1fe6cb0c` |

### Signing secrets

* Each endpoint has its own secret, prefixed with `whsec_` (32 bytes of entropy).
* The full secret is shown **once**, when the endpoint is created or its secret is rotated. Only the last 4 characters are visible afterwards.
* Rotation takes effect immediately, with no overlap window. Deliveries already in flight at rotation time were signed with the old secret and may fail your verification — redeliver them from the dashboard if needed.

<Warning>
  Treat your signing secret like a password. If it is ever exposed, rotate it from the dashboard immediately — anyone holding the secret can forge deliveries that pass verification.
</Warning>

***

## Retry behavior

A delivery succeeds only when your endpoint returns a `2xx` status within **10 seconds**. Anything else — a timeout, a connection failure, or a non-2xx response — is retried:

* Up to **10 attempts** per delivery.
* **Exponential backoff** starting at 60 seconds and doubling each attempt (1m, 2m, 4m, …), spanning roughly **8.5 hours** in total.
* The body and event `id` are identical on every attempt; the signature timestamp is computed fresh per attempt.

### Automatic pausing

An endpoint is automatically paused after **20 consecutive failed deliveries**, or after failing continuously for **24 hours** — whichever comes first. When that happens:

* The endpoint stops receiving deliveries and shows as **Paused** in the dashboard.
* Your business admins are notified by email.
* Once your endpoint is fixed, re-enable it from the dashboard and redeliver anything you missed from the delivery log.

A manual redelivery reuses the same event `id` and body — your dedupe logic treats it as the same event — but carries a new `Cleo-Webhook-Id` and a freshly computed signature.

***

## Endpoint requirements

* **HTTPS only** — `http://` URLs are rejected.
* The URL must resolve to a **publicly routable address**. Localhost, private-network, and internal addresses are rejected.
* Redirects are **not followed** — the endpoint must respond directly.
* Respond within **10 seconds** — acknowledge first, process asynchronously.

***

## Best practices

<CardGroup cols={2}>
  <Card title="Verify every request" icon="shield-check">
    Reject anything that fails signature verification before touching the payload. Your webhook URL is reachable by anyone on the internet — the signature is what makes a request trustworthy.
  </Card>

  <Card title="Deduplicate on the event ID" icon="fingerprint">
    Retries and redeliveries reuse the same event `id`. Store processed IDs and skip duplicates instead of processing them twice.
  </Card>

  <Card title="Respond fast, process async" icon="bolt">
    Return `2xx` immediately and hand the event to a queue or background job. Slow handlers hit the 10-second timeout and trigger retries.
  </Card>

  <Card title="Don't rely on delivery order" icon="arrow-down-wide-short">
    Events can arrive out of order. Order by `sequence`, and fetch the resource's current state from the API rather than reconstructing it from events.
  </Card>
</CardGroup>
