You POST a charge. The connection times out. Did the customer get charged?

You cannot tell. A timeout is indistinguishable from a successful charge whose response was lost on the way back. Retry and you may charge twice; do not retry and you may not charge at all. Every payments integration meets this problem, usually in production, usually as a support ticket about a double charge.

Idempotency keys are the standard fix. This article covers how they work, what makes a key correct, and — because it is the situation you are actually in here — how to get the same guarantee from an API that does not offer them.

What idempotent means

An operation is idempotent if performing it twice has the same effect as performing it once. Setting a value is idempotent; adding to it is not. GET is idempotent by definition. POST is the verb that creates things, and creating something twice is precisely the problem.

An idempotency key makes a POST behave like a set rather than an add. You attach a unique string to the request; the server stores the outcome against that key; a second request with the same key gets the stored outcome back without repeating the work.

POST /v1/charges
Idempotency-Key: order_8842-capture
{ "amount": 2500, "currency": "usd" }
-> 201 { "id": "ch_abc", "amount": 2500 }          # did the work

POST /v1/charges                                   # same key, retried
Idempotency-Key: order_8842-capture
{ "amount": 2500, "currency": "usd" }
-> 200 { "id": "ch_abc", "amount": 2500 }          # replayed, no second charge
How it works on an API that supports it

What makes a key correct

Most broken implementations fail on one of these two points, and both fail silently — the code works until the retry it was written for actually happens.

  1. The key identifies the operation, not the attempt. A UUID generated inside your retry loop is worthless: each attempt carries a different key, so every attempt is a new operation. Derive the key from something stable — the order id and the step, for instance.
  2. The key must survive a crash. If your process dies mid-request and the key only ever existed in memory, the retry after restart cannot reproduce it. Write it down before you make the call.

When the API has no idempotency keys

The Paydude API does not support them. There is no Idempotency-Key header, no vendor-prefixed variant, and sending one changes nothing — a retried POST /v1/subscriptions creates a second subscription and bills the customer twice.

That is a real constraint and it is better stated plainly than discovered. It also does not leave you without a guarantee: it moves the guard from a request header into your own database, which is somewhere you control and can test.

The pattern

  1. Derive a deterministic reference for the operation — the same string every time you attempt it.
  2. Write it to your database before the call, in a column with a unique constraint. The insert failing is how you learn the operation was already started.
  3. Send the reference along in the metadata object, which is available on payment intents, subscriptions and customers.
  4. On retry, look before you leap. If your record says the call may have landed, query the API filtering on that metadata reference rather than posting again.
// 1. Deterministic, derived from the operation — not from this attempt.
const reference = `order_${order.id}_capture`;

// 2. Claim it first. The unique constraint is the whole mechanism: if this
//    throws, some earlier attempt already got this far and may have succeeded.
try {
  await db.paymentAttempts.insert({ reference, status: "pending" });
} catch (err) {
  if (!isUniqueViolation(err)) throw err;

  // 4. Somebody already started this. Ask the API what exists rather than
  //    creating a second one.
  const existing = await api.get(`/v1/payment-intents`, {
    metadata: { reference },
  });
  if (existing.items.length > 0) return existing.items[0];
  // Nothing landed, so the previous attempt died before the call. Fall through
  // and retry — the claim row is already ours.
}

// 3. Carry the reference so the resource is findable if the response is lost.
const intent = await api.post("/v1/payment-intents", {
  amount: order.totalCents,
  currency: "usd",
  payment_method_types: ["card"],
  metadata: { reference },
});

await db.paymentAttempts.update({ reference }, {
  status: "created",
  intentId: intent.id,
});

return intent;
The guard, in outline

What to retry, and when not to

What happenedRetry?Why
Connection timeout, no responseYes, through the guardThe write may or may not have landed — this is the case the guard exists for
5xx from the APIYes, through the guardServer-side failure, possibly after the write committed
429 rate limitedYes, after backing offNothing was written; honour any retry hint before trying again
400 validation errorNoThe request is malformed; retrying sends the same malformed request
401 unauthorisedNoWrong key, or the right key against the wrong account. Retrying cannot fix either
403 capability missingNoNormal while an application is under review — surface it rather than looping
2xx with a non-success statusNoThe call worked; the payment declined. A different problem with a different fix
Retry policy by failure type

Testing the guard

An untested retry guard is a guess. The failure it protects against is rare, so it will not be exercised by accident — which means if it is wrong, you find out during the incident it was supposed to prevent.

  • Run the same operation twice in a row and assert exactly one resource exists at the end.
  • Kill the process between the claim insert and the API call, restart, and assert it recovers without creating a duplicate.
  • Make the API call succeed but drop the response, then retry, and assert the recovery query finds the resource by metadata.
  • Run two attempts concurrently and assert the unique constraint serialises them.

The third case is the one worth the effort to simulate, because it is the exact production failure that motivates all of this and the only one that requires the metadata reference to be correct.

GOOD QUESTIONS

Frequently asked

What is an idempotency key?+

A unique string you attach to a write request so the server can recognise a repeat. The first request with that key does the work and the server remembers the result; any later request carrying the same key returns that stored result instead of doing the work again. It turns retrying a POST from dangerous into safe.

Why do payments need idempotency keys specifically?+

Because the failure that matters most is invisible from the client. If a request times out you cannot tell whether the charge succeeded and the response was lost, or the charge never happened. Without a key, retrying risks charging twice and not retrying risks not charging at all. An idempotency key removes the dilemma: you retry, and the server tells you what already happened.

What makes a good idempotency key?+

It must be unique to the logical operation, not to the attempt, and it must be derivable again after a crash. A UUID generated per attempt is useless — the retry would carry a different key. Derive it from something stable, like the order id plus the operation, and persist it before you make the call.

Does the Paydude API support idempotency keys?+

No. There is no idempotency-key header, and sending one has no effect — a retried write creates a second resource. This is worth knowing up front rather than discovering in production, and the article above sets out the database-side guard that gives you the same protection.

Are GET and DELETE requests idempotent already?+

GET is, by definition — it has no side effects. DELETE is idempotent in outcome but not in reporting: deleting the same resource twice leaves the same end state, though the second call usually returns 404. The problematic verb is POST, which is why idempotency keys exist mostly for it.

How long should a server remember an idempotency key?+

Long enough to cover every retry that could plausibly still be in flight. Twenty-four hours is a common window and comfortably covers automated retry schedules. When you are implementing the guard yourself, the equivalent question is how long to keep your own operation records — and the answer is usually indefinitely, since they are cheap and double-charge disputes arrive late.