Skip to content

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.

Published 5 min read

Hand-drawn illustration of three identical request cards carrying the same Idempotency-Key going into one server box that returns a single 201 response, a key, and a person with folded arms
On this page
  1. Why do payment APIs need idempotency keys?
  2. How does an idempotency key work?
  3. The Idempotency-Key header on KukoPay
  4. Idempotency key vs out_trade_no: do you need both?
  5. How to write a safe retry loop
  6. Common idempotency key mistakes
  7. Idempotency on the receiving side
  8. How to test idempotency in the sandbox
  9. 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?

  1. Generate a key once per operation — a UUID v4 is the usual choice — and keep it with the operation in your own system.
  2. Send it with the request, and with every retry of that same request.
  3. The server stores the first response under the key, together with a fingerprint of the request.
  4. 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"
  }'
RuleDetail
ValueUp to 255 printable ASCII characters; UUID v4 recommended
ScopeThe 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
Retention24 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: true

Same 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 responseWhat happens on a retry
2xxReplayed for 24 hours
4xxAlso replayed — the request already has a definite answer, and retrying will not change it
5xxNot 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_noIdempotency-Key
WhereRequest bodyRequest header
CoversCreating orders and refundsEvery write endpoint
LastsPermanently24 hours
Same value, different content409 conflict — check the original order400 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 type on the error says which: api_error and api_connection_error are safe to retry, invalid_request_error needs 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-Key on any write: responses are kept 24 hours and replayed with Idempotent-Replayed: true;
  • the same key with a different body is 400 idempotency_error; a key still in flight is 409 idempotency_in_progress; 5xx results 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.