Portreeve
how-to · 7 Sept 202612 min read

Stripe idempotency keys: what they dedupe and how to derive them

A Stripe idempotency key dedupes one request for 24 hours, not one order. Derive it from the attempt, handle the mismatch error, and stop double charges.

A Stripe idempotency key makes one HTTP request replayable for 24 hours. It does not make a payment safe, and the gap between those two things is where double charges live.

By the end of this you will have a checkout that survives a network retry without charging twice, a key derived from something that outlives a process restart, a handler for the mismatch error that does not make things worse, and the same guarantee on your own POST endpoints. You need Node 18 or later, the stripe package at v13 or newer, a Stripe test secret key, and any SQL database you can write one row to before you charge. Examples are Postgres and Express. Have curl open for step 2.

What Stripe actually dedupes

Stripe dedupes one HTTP request. The order behind it, the customer, the payment: the idempotency layer knows none of them.

The mechanism is spelled out in Stripe's idempotent requests reference: "Stripe's idempotency works by saving the resulting status code and body of the first request made for any given idempotency key, regardless of whether it succeeds or fails. Subsequent requests with the same key return the same result, including 500 errors." Keys are up to 255 characters and are removed once they are at least 24 hours old, after which the same key produces a genuinely new request.

Three things follow.

The stored thing is a response. If your first attempt got a 402 card decline, every retry on that key replays the 402 for a day, even after the customer fixes their card.

The comparison is on parameters. Same key with a different body is rejected rather than merged, because "the idempotency layer compares incoming parameters to those of the original request and errors if they're not the same to prevent accidental misuse."

Only POST matters. Stripe is blunt about it: "Don't send idempotency keys in GET and DELETE requests because it has no effect."

So the key has to name one attempt at one action, not the order or the cart it happens to belong to.

1. Mint the key before the first send and persist it

The most common broken pattern generates a UUID inline at call time. That is a fresh key on every retry, which is the same as having no key at all.

Mint it earlier, in the row that represents the attempt, and read it back when you retry.

import crypto from "node:crypto";

const { rows } = await db.query(
  `insert into payment_attempts (order_id, idempotency_key, amount_minor, state)
   values ($1, $2, $3, 'pending')
   returning idempotency_key`,
  [orderId, crypto.randomUUID(), amountMinor]
);
const idempotencyKey = rows[0].idempotency_key;

Now the key outlives the process. A job retried tomorrow morning, a Lambda that timed out, a deploy mid-request: they all reload the pending row and send the same key.

If you would rather derive than store, derive from the attempt and not the entity: ${orderId}:${attemptNumber}, where attemptNumber is a counter you increment when the customer starts a genuinely new payment. Stripe's own PaymentIntents guide suggests a key "typically based on the ID that you associate with the cart or customer session in your application," which is fine for the one create call per cart it is describing and wrong the moment you reuse that key for a second legitimate attempt on the same cart. A customer whose card was declined, who then pays with a different card on the same order within 24 hours, gets the cached decline back. Silently. You will spend an afternoon on that bug.

Keep identifying data out of the key while you are at it. The idempotent requests reference says to "avoid using sensitive data (for example, email addresses or personal identifiers) as idempotency keys," and a key derived from an email is also a key that collides across two orders from the same person.

2. Send it and verify the replay

In stripe-node the key is an option on the second argument, not a request parameter.

const intent = await stripe.paymentIntents.create(
  {
    amount: amountMinor,
    currency: "usd",
    customer: customerId,
    metadata: { order_id: orderId },
  },
  { idempotencyKey }
);

Verify it before you trust it. Send the same request twice by hand and look at the response headers:

curl -i https://api.stripe.com/v1/payment_intents \
  -u "$STRIPE_TEST_KEY:" \
  -H "Idempotency-Key: attempt_demo_1" \
  -d amount=1999 -d currency=usd | grep -i 'idempotent-replayed\|^HTTP'

Run it once and you get HTTP/2 200 with no replay header. Run it again and the header appears: Idempotent-Replayed: true. Same pi_ id in the body both times, one PaymentIntent in the dashboard. That header is the only way to tell a replay from a fresh execution, and it is worth logging in production.

3. Retry the same key only for the failures that deserve it

stripe-node handles the transport layer for you. As of v13 it "will automatically do one reattempt for failed requests that are safe to retry", maxNetworkRetries defaults to 1, and it reuses one key across its own attempts. Raise it if you want:

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { maxNetworkRetries: 2 });

That covers dropped sockets. It does not cover your worker dying between the send and the write, or a queue redelivering the job, or a customer double-clicking Pay. Those are your problem, and the rule for them is a question about intent.

Retry with the same key when you do not know whether the first request executed: timeouts, connection resets, 500s. Stripe's advanced error handling page is explicit that clients "should retry such requests with the same idempotency keys and the same parameters until they're able to receive a result from the server," and that a 500 should be treated as indeterminate rather than as a failure.

Use a new key when the customer is making a new attempt. A second click on Pay after a decline is a new authorization against the network. It deserves a new row in payment_attempts and a new key. Stripe's guidance on the 4xx family points the same way: "the safest strategy where 4xx errors are concerned is to always generate a new idempotency key," because rate limiting and parameter validation run before the idempotency layer and never get cached anyway.

The question is not what HTTP status you got back. It is whether the user asked for the thing again.

4. When you get the mismatch error, do not rotate the key

The error reads:

Keys for idempotent requests can only be used with the same parameters they were
first used with. Try using a key other than 'da52b99a-712a-436a-8fc5-e2e4b4e290a8'
if you meant to execute a different request.

It arrives as a 400 with its own error type. Stripe's errors reference defines idempotency_error as what you get when "an Idempotency-Key is re-used on a request that does not match the first request's API endpoint and parameters." The endpoint counts as much as the body, so the same key on create and then on confirm trips it too.

The error message's advice is written for someone who deliberately reused a key. That is rarely who is reading it. In an integration that stores keys per attempt, this almost always means the body drifted between the send and the retry: tax recalculated, a shipping estimate refreshed, a currency conversion re-quoted, a metadata field carrying Date.now() or a fresh trace id. Stripe cannot tell an accidental one-cent difference from an intentional one, so it refuses.

Find what moved. Freeze the request body onto the attempt row alongside the key and send exactly those bytes on every retry. Rotating the key makes the error go away by turning a caught duplicate into a real second charge.

There is a second, quieter response: a 409 Conflict, which Stripe's status code reference describes as a request conflicting with another, "perhaps due to using the same idempotent key." That one means your first request is still in flight and nothing was cached, so back off and retry it rather than treating it as a failure.

5. Put the key on the create call, not the confirm

PaymentIntents already give you a lot of this. The PaymentIntents guide lists "No double charges" and "No idempotency key issues" among the advantages of the API, and that is true of confirmation: a PaymentIntent that has succeeded will not confirm into a second charge, because the object carries state.

The gap is create. Nothing stops two POST /v1/payment_intents calls from producing two PaymentIntents for one cart, each individually confirmable. Put the key there.

SetupIntents behave the same way, which matters if you are validating cards at signup. A card tester who can get you to mint a fresh SetupIntent on every submit gets a free validation oracle; the shape is covered in more detail in our writeup on card testing on Stripe.

Checkout Sessions are the flow people forget, because the redirect hides the duplicate. checkout.sessions.create without a key on a double-submitted form gives you two Sessions for one order, and both can be paid.

Refunds, transfers and payouts are where a duplicate costs you real money rather than an untidy dashboard. Key them.

6. Webhooks are not covered

Idempotency keys are a property of requests you send. Webhooks are requests Stripe sends you, and none of the above applies.

Stripe attempts delivery "for up to three days with an exponential back off in live mode" until it gets a 2xx, and is specific about how to dedupe: "Don't use created to determine event order or whether you've already processed an event. Track event IDs to identify duplicate deliveries instead."

A unique constraint does the whole job.

const seen = await db.query(
  `insert into stripe_events (id) values ($1)
   on conflict (id) do nothing returning id`,
  [event.id]
);
if (seen.rowCount === 0) return res.sendStatus(200);

Return the 200 first, then do the work asynchronously. Any webhook that retries until it gets a 2xx imposes the same requirement, including Portreeve's own review.resolved deliveries, so build the handler idempotent once and reuse it (handling verdicts covers the revoke side of that flow, as does our post on acting on a review verdict).

7. Accept Idempotency-Key on your own endpoints

If you are writing the API rather than calling it, the IETF Idempotency-Key draft describes the same design: the client sends the key, the server fingerprints the payload, and "if there is an attempt to reuse an idempotency key with a different request payload, the resource SHOULD reply with a HTTP 422 status code." The draft expired in April 2026 without becoming an RFC, so pick a status code and document it. Stripe returns 400 for the same condition.

Three states, one table.

app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));

async function idempotent(req, res, next) {
  const key = req.get("idempotency-key");
  if (!key) return next();

  // Hash the raw bytes. JSON.stringify of a parsed body reorders keys and
  // your fingerprint stops being stable across clients.
  const fingerprint = crypto.createHash("sha256").update(req.rawBody).digest("hex");

  const claimed = await db.query(
    `insert into idempotency (tenant_id, key, fingerprint, state)
     values ($1, $2, $3, 'in_flight')
     on conflict (tenant_id, key) do nothing
     returning key`,
    [req.tenantId, key, fingerprint]
  );
  if (claimed.rowCount === 1) return next();

  const { rows: [prior] } = await db.query(
    `select fingerprint, state, status_code, body from idempotency
     where tenant_id = $1 and key = $2`,
    [req.tenantId, key]
  );
  if (prior.fingerprint !== fingerprint) return res.status(422).json({ error: "idempotency_conflict" });
  if (prior.state === "in_flight") return res.status(409).json({ error: "request_in_progress" });
  return res.status(prior.status_code).json(prior.body);
}

Two details carry the correctness. The stored response must be written in the same transaction as the side effect it describes, or a crash in between leaves you replaying a success you never performed.

And the key is scoped by tenant, because an unscoped key is a way for one customer to read another's response body. nivertech put it plainly on the draft's Hacker News thread: "you cache/store the idempotency keys scoped by the currently authenticated user."

Production notes and the mistakes people make

Prune your key table at 24 hours to match Stripe. A longer window sounds safer and is not: the longer keys live, the more likely a legitimate repeat action collides with a stale one.

Log Idempotent-Replayed and alert on it. A rising replay rate means something upstream is retrying more than you think.

The five failures worth checking your code for right now:

  • A UUID generated at call time. Every retry is a new key and a new charge.
  • The bare order id or cart id as the key. The second legitimate attempt on that order returns the first one's response for a day, including its decline.
  • Rotating the key in response to idempotency_error. That converts a caught duplicate into a double charge. Find the drifting field instead.
  • Treating a 500 as a failure and starting over with a new key. Stripe treats those as indeterminate and may still have created the object.
  • Assuming keys cover webhooks. They do not; dedupe on event.id.

None of this is about fraud, but the plumbing overlaps: an endpoint that can be replayed for free is an endpoint an attacker will replay, and velocity counters that double-count retries produce false positives on your best customers.

If you want the abuse side handled too, Portreeve's free tier screens 1,000 events a month with no card: create an account and follow the quickstart to point one call at your checkout.

← Back to all posts