What is an idempotency key? How to retry payment API requests without charging twice
What an idempotency key is, why payment APIs need one, how the Idempotency-Key header replays a response, and how to retry without double-charging.
On this page
- Why do payment APIs need idempotency keys?
- How does an idempotency key work?
- The Idempotency-Key header on KukoPay
- Idempotency key vs out_trade_no: do you need both?
- How to write a safe retry loop
- Common idempotency key mistakes
- Idempotency on the receiving side
- How to test idempotency in the sandbox
- Summary
An idempotency key is a unique value a client sends with a request so the server can recognise a retry of the same request and return the original result instead of doing the work again. In payment APIs it is usually sent as an Idempotency-Key HTTP header, and it exists for one reason: when a request times out, you cannot tell whether the payment, order or refund was created — and a blind retry could create it twice.
This guide explains why payment APIs need idempotency keys, how the Idempotency-Key header behaves on KukoPay, how it differs from your own order number, and how to write a retry loop that never double-charges.
Why do payment APIs need idempotency keys?
In a distributed system, "no response" does not mean "not done". You send a request to create an order and the connection times out. Either:
- the request never arrived, and nothing happened; or
- the order was created, and the response was lost on the way back.
The client cannot tell these apart. Retrying without protection risks a second order or a second refund; not retrying risks a lost one. An idempotency key removes the dilemma: retry with the same key, and the server either runs the request for the first time or hands back the result it already produced.
How does an idempotency key work?
- Generate a key once per operation — a UUID v4 is the usual choice — and keep it with the operation in your own system.
- Send it with the request, and with every retry of that same request.
- The server stores the first response under the key, together with a fingerprint of the request.
- A retry with the same key gets the stored response back. Nothing runs twice.
A key is about one attempt to do one thing. A different operation — another order, another refund — gets a new key.
The Idempotency-Key header on KukoPay
Add the header to any write request:
curl -X POST "https://api.kukopay.com/v1/orders" \
-H "X-Api-Key: $KUKOPAY_API_KEY" \
-H "Idempotency-Key: 8f14e45f-ea2a-4c6f-9d3b-1b7c2a55e901" \
-H "Content-Type: application/json" \
-d '{
"out_trade_no": "ORDER_20261104_0001",
"amount": 2990,
"currency": "USD",
"subject": "Pro plan, 1 year"
}'| Rule | Detail |
|---|---|
| Value | Up to 255 printable ASCII characters; UUID v4 recommended |
| Scope | The calling key's app and environment. A key belongs to the endpoint it was first used on: reusing it on another endpoint is refused like a changed body |
| Retention | 24 hours, after which the value can be used again |
What a replay looks like
A retry with the same key and the same body returns the first response's status code and body, plus the header Idempotent-Replayed: true. The request_id is the first one too, so two lines in your logs point at the one real execution.
HTTP/1.1 201 Created
X-Request-Id: req_9f2c1a7b4e8d0356c1ab77de90f4b215
Idempotent-Replayed: trueSame key, different body
Reusing a key with a different request body — or on a different endpoint — is refused with 400 idempotency_error. It always means a bug in the caller — usually a retry that regenerated a timestamp, nonce or signature inside the body. If the body really has changed, it is a new request and needs a new key.
Two requests with the same key at once
While the first request is still being processed, a second one with the same key gets 409 idempotency_in_progress immediately rather than waiting. Retry a moment later with the same key and you receive the final result.
Which responses are stored
| First response | What happens on a retry |
|---|---|
2xx | Replayed for 24 hours |
4xx | Also replayed — the request already has a definite answer, and retrying will not change it |
5xx | Not stored; the key is released, so a retry with it actually runs again |
That last row matters: a server error leaves the outcome unknown, so the platform lets your retry find out instead of replaying a failure.
Idempotency key vs out_trade_no: do you need both?
KukoPay has two layers, and they solve different problems:
out_trade_no / out_refund_no | Idempotency-Key | |
|---|---|---|
| Where | Request body | Request header |
| Covers | Creating orders and refunds | Every write endpoint |
| Lasts | Permanently | 24 hours |
| Same value, different content | 409 conflict — check the original order | 400 idempotency_error |
out_trade_no is your business-level guarantee: the same order number with the same content always returns the same order, however late the retry. Idempotency-Key is the transport-level guarantee that covers every write and replays the exact response. Use both: your order number so an order can never exist twice, and a key so every retry is safe and returns the same request_id. Refunds work the same way with out_refund_no, covered in partial refunds explained.
How to write a safe retry loop
async function createOrder(payload) {
// One key per business operation, reused for every retry of it
const idempotencyKey = crypto.randomUUID()
const delays = [1000, 2000, 4000, 8000]
for (let attempt = 0; ; attempt++) {
const res = await fetch("https://api.kukopay.com/v1/orders", {
method: "POST",
headers: {
"X-Api-Key": process.env.KUKOPAY_API_KEY,
"Idempotency-Key": idempotencyKey,
"Content-Type": "application/json",
},
body: JSON.stringify(payload), // the same body on every attempt
})
const body = await res.json()
console.info("kukopay", res.status, body.request_id) // support asks for it
if (res.ok) return body.data
// 409 = same key still in progress; 5xx = outcome unknown. Both are safe to retry as-is.
const retriable = res.status === 409 || res.status >= 500
if (!retriable || attempt >= delays.length) throw new Error(body.message)
await new Promise((resolve) => setTimeout(resolve, delays[attempt]))
}
}Three rules keep it correct:
- Generate the key once per operation, outside the loop, and store it if the operation can outlive the process.
- Send the identical body on every attempt — build it once, never re-stamp it.
- Back off between attempts, and stop on errors a retry cannot fix. The
typeon the error says which:api_errorandapi_connection_errorare safe to retry,invalid_request_errorneeds a fix first. See errors.
Common idempotency key mistakes
- A new key on every retry. Then the key protects nothing; each attempt is a new request.
- One key for everything, such as the customer id. Different operations collide and return each other's responses.
- Changing the body on retry — a fresh timestamp or nonce — which turns a safe retry into
400 idempotency_error. - Treating a timeout as a failure and showing the buyer an error, when the order may well exist. Retry with the key, or look the order up by
out_trade_no.
Idempotency on the receiving side
Idempotency keys protect the requests you send. The webhooks you receive need the mirror image: a payment notification can arrive more than once, so record the event id under a unique constraint in the same transaction as the fulfilment. Duplicate payment webhooks shows that pattern, and return_url vs notify_url explains why the notification, not the redirect, decides that an order is paid.
How to test idempotency in the sandbox
With a kuko_test_ key, send the same order request twice with the same Idempotency-Key and check for Idempotent-Replayed: true and an identical request_id; then change one field and confirm the 400; then repeat the order with the same out_trade_no and no header and confirm the same order comes back. Test credit card numbers for payment testing lists these alongside the other sandbox scenarios.
Summary
- An idempotency key lets a server recognise a retry and return the original result instead of acting twice;
- on KukoPay, send
Idempotency-Keyon any write: responses are kept 24 hours and replayed withIdempotent-Replayed: true; - the same key with a different body is
400 idempotency_error; a key still in flight is409 idempotency_in_progress;5xxresults are not stored; - use it together with
out_trade_no/out_refund_no, which guard orders and refunds permanently; - one key per operation, the same body on every retry, and backoff between attempts.
Try it in the sandbox
Create a merchant account and request a test key. The sandbox needs no business verification.