Skip to main content
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

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 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.
What comes back depends on whether Cleff has seen the key in the last 24 hours: 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 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 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:

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.

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 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 tells you how to untangle them.