Agents: fetch this page as plain markdown at
https://docs.usecleff.com/idempotency.md.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:
400validation_failedonidempotency_key, and nothing is created, because Cleff can’t tell which retry you meant. - Neither:
400validation_failed; a key is required.
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.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.
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 bydecision_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,
429and5xxwith exponential backoff. For any other4xx, follow that code’s Fix on the Errors page rather than retrying blindly. - Treat
409IDEMPOTENCY_CONFLICTas 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.