Idempotency
Why it matters
A network timeout doesn't tell you whether your request succeeded before the connection dropped.
If that request was "withdraw £500," retrying it blind can pay a customer twice. The
Idempotency-Key header exists so a retry is provably safe — within the limits stated on this page.
The header is required, not optional
Every POST, PUT, PATCH and DELETE on /api/v1/partner and /api/v2/public requires an
Idempotency-Key. A request without one — or with two, or with a value outside
8–256 visible ASCII characters (! to ~, so no spaces) — is refused with
400 application/problem+json, code: idempotency_key_invalid, before the operation runs. The check happens after authentication
and rate limiting, so an invalid key on a bad credential shows up as 401, not 400.
curl -X POST https://api.kwiikpay.io/api/v1/partner/customers/{customerId}/banking/withdrawals \
-H "X-Api-Key: kp_live_..." \
-H "Idempotency-Key: withdrawal-2026-08-09-a1b2c3" \
-H "Content-Type: application/json" \
-d '{ "...": "..." }'
Shared replay and transactional commands
The shared replay rules below apply to eligible JSON mutations. Two public operations instead own transactional idempotency and bypass this replay layer:
POST /customers/{customerId}/end-customers/{endCustomerId}/internal-transfersrequires the same non-empty UUID inIdempotency-Keyandclient_reference. Matching parameters recover the completed transfer; changed parameters return409 internal_transfer_client_reference_reused.POST /customers/{customerId}/end-customers/{endCustomerId}/consolidaterecovers a completed consolidation using its original key; changed parameters return409 consolidation_key_reused_with_different_parameters.
These operations use durable transaction records, not the shared 24-hour byte-response cache.
The shared layer's 1 MiB request rejection, response-retention limit and idempotency_* 409
codes do not apply to them. The common header validation still applies. Follow
Sub-customers & parent access for their eligibility, recovery and
prior-day limit decisions; do not treat cache expiry as permission to repeat a transfer.
What counts as the same request
The shared replay layer stores an entry per tenant + key for 24 hours, and fingerprints the request as calling API key + HTTP method + path + body bytes. All four must match for a retry to be recognised. Consequences worth knowing:
- Reusing one key on two different routes, or
POSTthenPATCH, is a conflict, not a retry. - Sending the same key from a second API key of the same tenant — load-balancing across keys, or a retry that straddles a key rotation — is a conflict even with a byte-identical body.
- Any byte difference in the body (whitespace, field order) is a different request.
- After 24 hours the replay entry expires and a request with that key can reach the operation again. Individual money workflows may apply additional durable checks; the shared replay layer does not provide a permanent guarantee. Recover an uncertain outcome by reading state first.
The outcomes
The stored-entry outcomes below apply once completion or failure is recorded and while the entry
is retained. If that bookkeeping fails, an entry can remain in progress; a persistent
idempotency_key_in_progress needs support-led recovery. A 2xx result describes the HTTP
response; it does not establish that a payment settled. Check the resource's status.
| State of the key | Same request again | Different request with the same key |
|---|---|---|
| Never used | Executes normally | — |
| First attempt still running | 409 idempotency_key_in_progress — retry the identical request shortly |
409 idempotency_key_reused |
| First attempt returned 2xx, and its response was stored | The stored response is replayed byte-for-byte (same status, same body, including reveal-once values such as a webhook signing secret); the operation is not executed again | 409 idempotency_key_reused — mint a new key |
| First attempt returned 2xx, but its response could not be retained (larger than 1 MiB) | 409 idempotency_response_unavailable — the completed operation is not re-executed; recover by reading, below |
409 idempotency_key_reused |
| First attempt returned non-2xx (any 4xx/5xx, or an exception) | Executes again. A failed attempt does not lock the key | 409 idempotency_key_reused — the fingerprint is still checked, so a changed body is refused rather than run under a used key |
Two rows carry the design weight:
- After a failure (
422,500, …) the key stays retryable — but only for the identical bytes. Editing a field and resending under the same key is refused (idempotency_key_reused), so a corrected retry needs a new key. - A completed request whose response was too large to retain fails closed while its entry is retained. On operations using shared replay the platform will not re-run that mutation during this window, even though it cannot show you the original response. (A small number of platform-admin endpoints suppress response storage and implement their own retry semantics; none of them appear in these guides.)
Recovering from idempotency_response_unavailable
This 409 means your first request returned an HTTP 2xx response that was too large to keep for replay. It does not prove that a payment settled. Recovery is read-first, never blind-resend:
- Read the resource back (the withdrawal or payment, the relevant list, the balance) and establish what the original attempt did.
- If it did what you wanted: stop — nothing further is needed.
- If the resource does not show the change you expected, raise it as a support case and quote the
Idempotency-Keyand any availableX-Correlation-Id. Retrying while the entry is retained returns the same refusal; waiting for expiry is not a safe substitute for resolving the outcome. - Only when you intend a genuinely new action, send it under a fresh
Idempotency-Key.
Responses within the 1 MiB retention bound (§ below) are retained for replay; this outcome only affects responses above it.
When replay protection applies
Except for the transactional commands above, the replay layer engages when the request carries
a JSON content type (application/json, +json, or text/json). The header itself is required
on every mutating route regardless. Eligible JSON requests get the same replay protection
whether or not they carry a Content-Length:
- Bodies up to 1 MiB (1,048,576 bytes) are fingerprinted and fully protected — the outcomes above all apply.
- A JSON body above that bound is refused with
413 application/problem+json,code: idempotency_request_too_large, before the operation runs and before any key state is written. Nothing executed, so there is nothing to recover; split the payload or use a documented bulk operation. - A chunked request (no
Content-Length) is read through a bounded buffer and gets the same protection as any other JSON request. Streaming a body does not disable idempotency. - Requests without a JSON content type do not enter the replay layer — the gate is the request's
content type, not its HTTP verb. Some of those routes thread the key into their own durable
workflow — for example
POST …/crypto/wallets/{asset}/addressreturns the same wallet/address operation when the same key is repeated — while others simply re-execute; read the resource's state before resending. Sent without a JSON content type,DELETE …/banking/beneficiaries/{beneficiaryId}is an example of the re-executing kind.
A 2xx response larger than 1 MiB is not retained; an identical retry while the completed entry
exists is refused with 409 idempotency_response_unavailable rather than re-executing.
Choosing a key
Derive the key from something inherent to the action on your side — an internal transaction id, or a UUID minted once per user click and persisted before the first attempt. Never generate it per attempt (a fresh key on every retry defeats the point), and never reuse one across actions. A UUID is the simplest reliable choice.
Not what you might expect
- Read operations (
GET) neither require nor honour the header. - A shared-replay idempotency
409isapplication/problem+json, not the envelope. Transactional commands use their route-specific JSON errors. The causes carry differentcodes; see Errors. - Responses replayed from the store are served as
application/jsonwith the original status. - A retained entry protects the exact request; it is not a general "this action happened once" guarantee. If a retry is refused, resolve it by reading state or with support — not by minting a new key for the same action, which starts a genuinely new request.