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:
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 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.
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 asidempotency_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 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, 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 with2xx, 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,
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.