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 '{ "...": "..." }'

What counts as the same request

The platform 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:

The four outcomes

State of the key Same request again Different request with the same key
Never used Executes normally
First attempt still running 409 problem+json, code: idempotency_key_in_progress — retry the identical request shortly 409 idempotency_key_reused
First attempt returned 2xx 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 non-2xx (any 4xx/5xx, or an exception) Executes again. A failed attempt does not lock the key Executes. The fingerprint is not checked after a failure, so a different body under a reused key is accepted

The last row is the one to design around: after a 422 or a 500, the key protects nothing at the platform layer. Money-moving routes also thread the key into their own workflow, which gives a second, route-specific guard, but do not rely on the header alone after a failure — read the resource's state before resending, and never reuse a key for a different action.

When replay protection applies

The replay layer engages only when the request:

  1. has a JSON content type (application/json, +json, or text/json),
  2. carries a Content-Length header, and
  3. that length is at most 1 MiB (1,048,576 bytes).

A chunked request (no Content-Length) is treated exactly like an oversized one: the header is still required and validated, but no entry is written, so a retry re-executes with no duplicate protection. Most HTTP clients send Content-Length for a buffered body; if yours streams request bodies, disable that for this API. Operations that take no body — for example DELETE …/banking/beneficiaries/{beneficiaryId} and POST …/crypto/wallets/{asset}/address — are typically sent without a JSON content type and so get no replay protection either: plan for a retry of those to execute again.

A successful response larger than 1 MiB is not stored; later retries of that key pass through and re-execute.

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