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:

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:

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:

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:

  1. Read the resource back (the withdrawal or payment, the relevant list, the balance) and establish what the original attempt did.
  2. If it did what you wanted: stop — nothing further is needed.
  3. If the resource does not show the change you expected, raise it as a support case and quote the Idempotency-Key and any available X-Correlation-Id. Retrying while the entry is retained returns the same refusal; waiting for expiry is not a safe substitute for resolving the outcome.
  4. 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:

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