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

# Idempotency

> Retry money-moving requests safely: what a key protects, how long it lasts, and what happens when a retry doesn't match.

Networks fail mid-request. When a call to create a payout times out, you can't
tell whether Cleff received it, and retrying one that did land would send the
money twice. An idempotency key makes the retry safe: the first request does the
work, and every repeat of it gets that same result back instead of doing the work
again.

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

## Which requests take a key

| Endpoint                                                                                                                                                                     | Key                                                      | Scope                          | Lifetime  |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------ | --------- |
| [`POST /v1/payouts`](/api-reference/payouts-create)                                                                                                                          | `Idempotency-Key` header or `idempotency_key` body field | Your Business, per environment | 24 hours  |
| [`POST /v1/payouts/bulk`](/api-reference/payouts-bulk-create)                                                                                                                | One per row, derived from `uploadId`                     | Your Business, per environment | 24 hours  |
| [`POST /v1/payouts/{payoutId}/decisions`](/api-reference/payouts-decisions-create), [`/approve`](/api-reference/payouts-approve), [`/reject`](/api-reference/payouts-reject) | `decision_id` (body field, required)                     | The payout                     | Permanent |

On `POST /v1/payouts`, send the key in the `Idempotency-Key` header, the
convention most payment APIs follow. The `idempotency_key` body field the API
launched with is still accepted:

* **One of the two:** that value is the key.
* **Both, with the same value:** that value is the key.
* **Both, with different values:** `400` [`validation_failed`](/errors#validation_failed)
  on `idempotency_key`, and nothing is created, because Cleff can't tell which
  retry you meant.
* **Neither:** `400` `validation_failed`; a key is required.

A key replays the same payout whichever way it arrives, so you can move from the
body field to the header without breaking retries already in flight.

Send the header value bare, as most APIs take it
(`Idempotency-Key: 6f1c2c1e-8a0b-4c55-9d3e-2b7f0e4a9c11`), or as the quoted
string the IETF draft specifies
(`Idempotency-Key: "6f1c2c1e-8a0b-4c55-9d3e-2b7f0e4a9c11"`). Both name the same
key.

Only `POST /v1/payouts` reads the header. Bulk rows and Decisions carry their
keys in the body, as described below, and every other endpoint ignores
`Idempotency-Key`: sending it there protects nothing.

## Creating a payout

Generate a fresh idempotency key for each payout you mean to send (a UUID v4 is
ideal) and store it with your own record **before** you call Cleff. If the call
fails or times out, resend the same request with the same key.

```bash theme={null}
curl -X POST https://api.usecleff.com/v1/payouts \
  -H "Authorization: Bearer $CLEFF_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2c1e-8a0b-4c55-9d3e-2b7f0e4a9c11" \
  -d '{
    "beneficiary_id": "…",
    "bank_account_id": "…",
    "amount_minor": 50000,
    "currency": "USD",
    "purpose_of_payment": "other"
  }'
```

What comes back depends on whether Cleff has seen the key in the last 24 hours:

| The key is…                     | Response                                                                         |
| ------------------------------- | -------------------------------------------------------------------------------- |
| New                             | `201` with a new payout                                                          |
| Already used, request unchanged | `200` with the original payout; nothing is created                               |
| Already used, request different | `409` [`IDEMPOTENCY_CONFLICT`](/errors#idempotency_conflict); nothing is created |

A replay returns the payout **as it is now**, not a copy of the first response:
its `state` may have moved on to `approved` or `disbursed` since. Tell a replay
from a create by the status code, `200` versus `201`.

### What counts as the same request

A retry replays only while it still describes the same disbursement. These fields
are compared:

`beneficiary_id`, `bank_account_id`, `amount_minor`, `currency`,
`purpose_of_payment`, `external_ref`, `replaces_payout_id`, `validator_strategy`

Change any of them under the same key and the request is refused, rather than
answered with a payout for different money. Optional fields count too: if the
first request left `external_ref` unset, the retry must leave it unset, and if
the first request set it, the retry must send the same value. The one exception
is `validator_strategy`, which is compared after its default is applied.

`via` is not compared: it records the channel a request came through, not the
payout itself. IDs are compared case-insensitively.

### The 24-hour window

The window runs for 24 hours from the payout's creation. After that the key
matches nothing, and the same request creates a **new** payout. So use each key
for exactly one payout, and don't hold a retry for more than a day.

The key stays on the payout permanently as `idempotency_key`, so you can
reconcile a payout against your own record long after the window closes.

## Approving or rejecting a payout

A Decision is keyed by `decision_id`, a [ULID](https://github.com/ulid/spec) you
generate: 26 characters of Crockford base32. A UUID is not accepted. Resending a
Decision with the same `decision_id` returns the original Decision with `200`
instead of recording a second one.

A `decision_id` is bound to its verdict for the life of the payout, so there is
no window. A retry replays while its `status` and `reasons` match the original.
Flipping `approved` to `rejected`, or changing the reasons, returns `409`
[`IDEMPOTENCY_CONFLICT`](/errors#idempotency_conflict) and records nothing.
Reasons are compared by content, so key order inside a reason doesn't matter.

Evaluation metadata (`evaluated_by`, `evaluated_at`, `policy_version`,
`agent_version`, `confidence`) is not compared, so a validator may re-stamp its
clock or its confidence on a retry. A different verdict is a different Decision:
send it with a new `decision_id`, and the payout's current state decides whether
it can apply.

## Bulk payouts

`POST /v1/payouts/bulk` runs every row through the same create path, so each row
carries its own key. By default a row's key is `{uploadId}:{index}`, where
`index` is the row's zero-based position; set a row's `idempotency_key` to
override it.

Each row result reports `created`, `replayed`, or `failed`. A row whose key
conflicts reports `failed` with the conflict message, and the rest of the file
is unaffected.

To retry a partially failed upload, resend the **same** `uploadId` with the rows
in the **same order**: rows that were created replay, and rows that failed are
attempted again. A new `uploadId` gives every row a new key, so rows that already
succeeded are created a second time. Reordering moves keys onto different rows,
which can conflict or create a row twice.

## Requests without a key

Other writes don't take a key. Most are safe to repeat and say so in their
reference: archiving a beneficiary, acknowledging compliance, and cancelling a
collection request all succeed as no-ops when repeated. A few have a visible
effect when repeated, so recover from them deliberately:

| Request                                     | A repeat…                                                                                                                                                                        | Recovery                                           |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `POST /v1/beneficiaries`                    | Returns `409` [`BENEFICIARY_EMAIL_ALREADY_EXISTS`](/errors#beneficiary_email_already_exists) when a beneficiary with that email exists, including one your first attempt created | List beneficiaries and match on `email`            |
| `POST /v1/beneficiaries/collection-invites` | Sends a new link and invalidates the previous one                                                                                                                                | None needed; the newest link is the live one       |
| `POST /v1/webhook-subscriptions`            | Rotates the signing secret; the previous secret stops verifying at once                                                                                                          | Use the secret from the latest successful response |

## Receiving webhooks

Idempotency runs the other way too. Cleff retries a delivery your endpoint doesn't acknowledge with `2xx`, up to
8 attempts, so the same event can arrive more than once. Every retry
of one delivery carries the same envelope `id`: record the ids you've processed
and skip repeats. See
[Ordering and dedup](/api-reference/webhook-subscriptions#ordering-and-dedup).

## Retrying well

* **Resend the same request with the same key.** Never regenerate a key on
  retry: a new key is a new payout.
* **Retry sequentially.** Send a retry only after the previous attempt has
  returned or timed out. Don't send the same key from two workers at once.
* **Back off.** Retry network errors, timeouts, `429` and `5xx` with
  exponential backoff. For any other `4xx`, follow that code's **Fix** on the
  [Errors](/errors) page rather than retrying blindly.
* **Treat `409` `IDEMPOTENCY_CONFLICT` as a caller bug**, not a transient error.
  Two different intentions shared one key; the
  [Fix](/errors#idempotency_conflict) tells you how to untangle them.
