Skip to main content
Retry a money-moving request without moving the money twice. For developers whose payout calls can time out or fail mid-request.
Agents: fetch this page as plain markdown at https://docs.usecleff.com/idempotency.md.
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.

Find the requests that 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 is accepted and protects nothing.

Retry a payout you created

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.
This pays Jane Doe $500.00 USD: amount_minor is 50000 because amounts are in minor units. 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.

Keep the retry identical

A retry replays only while it still describes the same disbursement. These fields are compared: beneficiary_id, destination_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. Two exceptions: validator_strategy is compared after its default is applied, and a retry that names no destination matches whichever one the payout pays. via is not compared: it records the channel a request came through, not the payout itself. IDs are compared case-insensitively.

Retry within 24 hours

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. Use each key for exactly one payout, and never 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.

Retry an approval or rejection

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, policy_version, agent_version, confidence) is not compared, so a validator may re-stamp 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.

Retry a bulk upload

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. A row that names its beneficiary by beneficiary_email is keyed the same way, but the email is looked up again on every retry, before the row can replay. If that beneficiary was archived or given a different email in between, the row fails with BENEFICIARY_EMAIL_NOT_FOUND. If it was archived and registered again, the email now names a different beneficiary and the row fails with IDEMPOTENCY_CONFLICT. Either way the payout the first upload created stands. Use beneficiary_id for rows that must replay whatever happens to the email. 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.

Repeat a request that takes no 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:

Deduplicate webhook deliveries

Idempotency runs the other way too: the same webhook event can reach you more than once. See Deduplicate and order events.

Follow the retry rules

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

Next steps

Errors

What each error code means and how to fix it.

Webhooks

Receive signed payout and beneficiary events instead of polling.