# Idempotency keys explained

URL: https://www.paydude.io/resources/idempotency-keys-explained
Type: Guide
Published: 2026-09-24 · Updated: 2026-09-24
Summary: How idempotency keys prevent duplicate charges, why a retried POST is dangerous without them, and how to build the same guarantee yourself.

## Summary

An idempotency key lets you safely retry a write: the server recognises the repeat and returns the original result instead of acting twice. The dangerous case is a timeout, where you cannot tell whether the charge landed. A key must identify the operation, not the attempt, and must survive a crash — so derive it and persist it before the call. The Paydude API has no idempotency-key header; a retried write creates a second resource. Where the header is missing, the guard moves into your database: record the operation first, and use the metadata field to find a resource you are not sure you created.

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.

```http
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
```

## 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.

> **A random key per attempt is worse than none:** It is worse because it looks correct. The header is present, the code reads as though retries are handled, and a reviewer sees an idempotency key being set. The protection is entirely absent, and you find out the first time a request times out under load.

## 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.

```javascript
// 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;
```

> **Why metadata is load-bearing here:** Without your own reference on the resource, a lost response leaves you with no way to ask whether you created it — you can only list recent objects and guess from amounts and timestamps, which breaks the moment two customers buy the same thing in the same second. The metadata field is what makes the recovery query exact.

## What to retry, and when not to

**Retry policy by failure type**

| What happened | Retry? | Why |
| --- | --- | --- |
| Connection timeout, no response | Yes, through the guard | The write may or may not have landed — this is the case the guard exists for |
| 5xx from the API | Yes, through the guard | Server-side failure, possibly after the write committed |
| 429 rate limited | Yes, after backing off | Nothing was written; honour any retry hint before trying again |
| 400 validation error | No | The request is malformed; retrying sends the same malformed request |
| 401 unauthorised | No | Wrong key, or the right key against the wrong account. Retrying cannot fix either |
| 403 capability missing | No | Normal while an application is under review — surface it rather than looping |
| 2xx with a non-success status | No | The call worked; the payment declined. A different problem with a different fix |

> **A 2xx is not a successful payment:** A declined payment still returns 200 or 201. The HTTP status tells you the API processed your request; the status field in the body tells you what happened to the money. Treating 2xx as success is a distinct bug from anything idempotency addresses, and it is a common one.

## 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.

**Build against the sandbox first** An open sandbox, SDKs and an MCP server, on every tier. — [View pricing](https://www.paydude.io/pricing)

## Frequently asked questions

### 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.
