Diagnostic Stripe
payment-creating requests carry no idempotency key
A customer is charged twice. It happens perhaps once a week, always to someone on a phone, always during a moment when the network was bad, and never once on a developer machine. There is no bug in the checkout code. There is a missing HTTP header, and the events already recorded which requests were sent without it.
Page GET /v1/events filtered to the types that create money or customers, and read the request object on each. Flag events where request.id is set and request.idempotency_key is null: that is an API call your code made, without a key, that a retry would have executed twice.
The request.id guard is not optional. Stripe-initiated events — renewal invoices, customer.subscription.trial_will_end — have both fields null, so a check that only looks at the key reports every automated Billing event as a finding and buries the real ones.
The problem in plain words
The defining property of this failure is that it is unreproducible by construction. It needs a request that reaches Stripe, succeeds, and then fails to deliver its response — a timeout, a dropped mobile connection, a load balancer giving up. Locally the response always arrives, so the retry never happens, so the second charge never exists. Every attempt to reproduce it confirms the code is fine.
The retry also does not have to be yours. A user double-tapping a button, a proxy configured to retry idempotent-looking POSTs, a job runner that treats a timeout as a failure and re-queues: all of them produce a second identical request. Without a key, Stripe has no way to know it is the same operation, and it does exactly what it was asked to do a second time.
Why it happens
Nothing requires the header, so nothing reminds you. Every mutating Stripe request accepts Idempotency-Key and none of them demands it. Requests without one succeed normally and look identical in the logs to requests with one. The protection is opt-in and its absence is silent.
It lives in the options argument, not the parameters. In every official SDK the key is a second argument, separate from the request body: stripe.paymentIntents.create(params, { idempotencyKey }) in Node, idempotency_key= as a keyword in Python, ['idempotency_key' => $key] as the options array in PHP. Passing it inside the parameters hash is a very natural mistake, and it does not error — it is simply ignored.
A key regenerated per attempt is the same as no key at all. The common near-miss is generating a fresh UUID inside the retry loop. Each attempt then carries a different key, so Stripe sees a series of distinct operations. The key has to be derived from the business operation and stay fixed across every retry of it.
The events already hold the evidence, and the obvious query misreads it. Every event carries the request that caused it. Stripe's own automated actions produce events with a null request.id, and there are a lot of them on a Billing account. Filtering on the key alone drowns the real signal in renewal invoices.
The fix, as a flow
The script reads the request that caused each event, and separates your API calls from the ones Stripe made on your behalf, because only the first group could ever have carried a key.
How to fix it
Pull the events that represent money moving
GET /v1/events?types[]=payment_intent.created&types[]=charge.succeeded&types[]=customer.created&types[]=refund.created. These are the operations where a duplicate costs something real. The 30-day retention window is a large enough sample to be conclusive.
Separate your requests from Stripe's
request.id non-null means the event was caused by an API call from your integration. Null means Stripe did it on your behalf and there was never a key to send. Only the first group can be judged, and mixing them makes the report useless.
Report the ratio per event type, not a total
A single number hides where the exposure is. If customer.created is unkeyed but payment_intent.created is not, you have duplicate customer rows, which is annoying. The reverse means duplicate charges, which is a refund and an apology. Anything above zero on a money-moving type is a finding.
Add the key at the call site, derived from the operation
A v4 UUID generated once per logical operation, persisted next to the order record, and reused verbatim for every retry of that request. Not a customer ID, not a cart ID, not a date; those repeat, and a key reused with different parameters produces an idempotency_error rather than protection.
Re-run after a deploy and watch the ratio fall
Because the window is rolling, the old unkeyed events stay in the sample for 30 days after the fix. The number to watch is the ratio over the last day or two, which should reach zero on money-moving types and stay there.
How to check it worked
Re-run the script a day after the deploy. Every money-moving type should report every API-originated event as keyed.
python3 stripe_idempotency_keys.py --days 1
# keyed payment_intent.created 312 API request(s), all carrying a key
The full code
One paginated GET against /v1/events — a restricted key with read access to Events is enough. There are two pure functions because there are two decisions: what a single request object means, and what a whole type's tally means. The first is where the false positives live, so it is worth being able to test it on its own.
"""Report Stripe API requests made without an Idempotency-Key.
Read only. One paginated GET and no writes: give this a RESTRICTED key with
read access to Events. The repair is printed, never performed, because this
script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_idempotency_keys")
API = "https://api.stripe.com/v1"
# A duplicate of any of these costs real money or real support time.
MONEY_MOVING = ("payment_intent.created", "charge.succeeded", "refund.created")
WATCHED = MONEY_MOVING + ("customer.created",)
def classify(request):
"""What one event's `request` field says about the call that caused it.
Pure. Returns one of:
"stripe" no request id at all: Stripe did this on your behalf, and
there was never a key to send. Not a finding.
"unreported" a bare string request id, which is how very old API versions
rendered this field. The key is unknown, not absent.
"keyed" an API request that carried an Idempotency-Key.
"unkeyed" an API request that did not. This is the finding.
"""
if request is None:
return "stripe"
if isinstance(request, str):
return "unreported" if request else "stripe"
if not request.get("id"):
return "stripe"
return "keyed" if request.get("idempotency_key") else "unkeyed"
def verdict(event_type, api_requests, unkeyed):
"""Classify one event type's tally. Pure, so the thresholds can be tested."""
if not api_requests:
return ("stripe-only",
"no API-originated events in the window: nothing here is yours "
"to key")
if not unkeyed:
return ("keyed",
"%d API request(s), all carrying a key" % api_requests)
pct = 100.0 * unkeyed / api_requests
if event_type in MONEY_MOVING:
return ("exposed",
"%d of %d API request(s) sent no key (%.1f%%). A retried timeout "
"on any of these charges the customer twice."
% (unkeyed, api_requests, pct))
return ("unkeyed",
"%d of %d API request(s) sent no key (%.1f%%). Retries create "
"duplicate records rather than duplicate charges."
% (unkeyed, api_requests, pct))
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
r.raise_for_status()
return r.json()
def tally(session, since, limit):
"""Per-type counts of API-originated events and how many carried no key."""
counts = {t: {"api": 0, "unkeyed": 0, "unreported": 0} for t in WATCHED}
total = 0
params = {"limit": 100, "created[gte]": int(since)}
for i, t in enumerate(WATCHED):
params["types[%d]" % i] = t
while True:
page = get(session, "/events", **params)
data = page.get("data", [])
for ev in data:
total += 1
row = counts.get(ev.get("type"))
if row is None:
continue
state = classify(ev.get("request"))
if state == "stripe":
continue
if state == "unreported":
row["unreported"] += 1
continue
row["api"] += 1
if state == "unkeyed":
row["unkeyed"] += 1
if not data or not page.get("has_more") or total >= limit:
break
params["starting_after"] = data[-1]["id"]
return counts, total
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="how far back to sample, up to the 30-day retention limit")
ap.add_argument("--max-events", type=int, default=5000,
help="stop paginating after this many events")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
since = time.time() - args.days * 86400
counts, total = tally(s, since, args.max_events)
log.info("sampled %d event(s) over %d day(s)", total, args.days)
bad = 0
for t in WATCHED:
row = counts[t]
state, detail = verdict(t, row["api"], row["unkeyed"])
line = "%-11s %-24s %s" % (state, t, detail)
if state in ("keyed", "stripe-only"):
log.info(line)
else:
bad += 1
log.warning(line)
if row["unreported"]:
log.info(" %d event(s) rendered at an API version that does not "
"report the key; upgrade the endpoint pin to judge them",
row["unreported"])
if bad:
log.warning(" repair: send an Idempotency-Key header on every mutating "
"request, in the options argument rather than the params:")
log.warning(" node: stripe.paymentIntents.create(params, { idempotencyKey })")
log.warning(" python: stripe.PaymentIntent.create(..., idempotency_key=key)")
log.warning(" php: $stripe->paymentIntents->create($params, "
"['idempotency_key' => $key])")
log.warning(" the key is a v4 uuid per logical operation, persisted with "
"the order and reused unchanged for every retry of it")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe API requests made without an Idempotency-Key.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Events. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// A duplicate of any of these costs real money or real support time.
export const MONEY_MOVING = ['payment_intent.created', 'charge.succeeded',
'refund.created'];
const WATCHED = [...MONEY_MOVING, 'customer.created'];
/**
* What one event's `request` field says about the call that caused it. Pure.
* Returns 'stripe', 'unreported', 'keyed' or 'unkeyed'.
*/
export function classify(request) {
if (request === null || request === undefined) return 'stripe';
if (typeof request === 'string') return request ? 'unreported' : 'stripe';
if (!request.id) return 'stripe';
return request.idempotency_key ? 'keyed' : 'unkeyed';
}
/** Classify one event type's tally. Pure, so the thresholds can be tested. */
export function verdict(eventType, apiRequests, unkeyed) {
if (!apiRequests) {
return ['stripe-only',
'no API-originated events in the window: nothing here is yours to key'];
}
if (!unkeyed) return ['keyed', `${apiRequests} API request(s), all carrying a key`];
const pct = ((100 * unkeyed) / apiRequests).toFixed(1);
if (MONEY_MOVING.includes(eventType)) {
return ['exposed',
`${unkeyed} of ${apiRequests} API request(s) sent no key (${pct}%). ` +
'A retried timeout on any of these charges the customer twice.'];
}
return ['unkeyed',
`${unkeyed} of ${apiRequests} API request(s) sent no key (${pct}%). ` +
'Retries create duplicate records rather than duplicate charges.'];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
export async function tally(key, since, limit = 5000) {
const counts = new Map(WATCHED.map((t) => [t, { api: 0, unkeyed: 0, unreported: 0 }]));
let total = 0;
const params = { limit: 100, 'created[gte]': Math.floor(since) };
WATCHED.forEach((t, i) => { params[`types[${i}]`] = t; });
for (;;) {
const page = await get(key, '/events', params);
const data = page.data ?? [];
for (const ev of data) {
total += 1;
const row = counts.get(ev.type);
if (!row) continue;
const state = classify(ev.request);
if (state === 'stripe') continue;
if (state === 'unreported') { row.unreported += 1; continue; }
row.api += 1;
if (state === 'unkeyed') row.unkeyed += 1;
}
if (data.length === 0 || !page.has_more || total >= limit) break;
params.starting_after = data[data.length - 1].id;
}
return { counts, total };
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const days = Number(process.env.DAYS ?? 30);
const since = Date.now() / 1000 - days * 86400;
const { counts, total } = await tally(key, since);
console.log(`sampled ${total} event(s) over ${days} day(s)`);
let bad = 0;
for (const t of WATCHED) {
const row = counts.get(t);
const [state, detail] = verdict(t, row.api, row.unkeyed);
const line = `${state.padEnd(11)} ${t.padEnd(24)} ${detail}`;
if (state === 'keyed' || state === 'stripe-only') {
console.log(line);
} else {
bad += 1;
console.warn(line);
}
if (row.unreported) {
console.log(` ${row.unreported} event(s) rendered at an API version that ` +
'does not report the key; upgrade the endpoint pin to judge them');
}
}
if (bad) {
console.warn(' repair: send an Idempotency-Key header on every mutating ' +
'request, in the options argument rather than the params:');
console.warn(' node: stripe.paymentIntents.create(params, { idempotencyKey })');
console.warn(' python: stripe.PaymentIntent.create(..., idempotency_key=key)');
console.warn(" php: $stripe->paymentIntents->create($params, " +
"['idempotency_key' => $key])");
console.warn(' the key is a v4 uuid per logical operation, persisted with ' +
'the order and reused unchanged for every retry of it');
}
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The first test is the whole reason this check is worth writing carefully. Stripe-initiated events have a null request.id and a null request.idempotency_key, and on a Billing account they outnumber everything else. A check that reads only the key reports every renewal invoice as an unkeyed payment request, and the report gets ignored on its first run.
from stripe_idempotency_keys import classify, verdict
def test_stripe_initiated_events_are_not_findings():
# Both fields null. Stripe did this; there was never a key to send. Reading
# only idempotency_key here flags every renewal invoice on the account.
assert classify({"id": None, "idempotency_key": None}) == "stripe"
assert classify(None) == "stripe"
def test_an_api_request_without_a_key_is_the_finding():
assert classify({"id": "req_123", "idempotency_key": None}) == "unkeyed"
def test_an_api_request_with_a_key_is_clean():
assert classify({"id": "req_123", "idempotency_key": "8f14e45f"}) == "keyed"
def test_a_bare_string_request_is_unreported_not_unkeyed():
# Old API versions rendered `request` as a bare id string. The key is
# unknown there, and counting it as absent invents a problem.
assert classify("req_123") == "unreported"
def test_one_unkeyed_charge_is_already_exposed():
state, detail = verdict("payment_intent.created", 400, 1)
assert state == "exposed"
assert "twice" in detail
assert verdict("customer.created", 400, 1)[0] == "unkeyed"
assert verdict("payment_intent.created", 400, 0)[0] == "keyed"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, verdict } from './stripe-idempotency-keys.mjs';
test('stripe initiated events are not findings', () => {
assert.equal(classify({ id: null, idempotency_key: null }), 'stripe');
assert.equal(classify(null), 'stripe');
});
test('an api request without a key is the finding', () => {
assert.equal(classify({ id: 'req_123', idempotency_key: null }), 'unkeyed');
});
test('an api request with a key is clean', () => {
assert.equal(classify({ id: 'req_123', idempotency_key: '8f14e45f' }), 'keyed');
});
test('a bare string request is unreported not unkeyed', () => {
assert.equal(classify('req_123'), 'unreported');
});
test('one unkeyed charge is already exposed', () => {
const [state, detail] = verdict('payment_intent.created', 400, 1);
assert.equal(state, 'exposed');
assert.match(detail, /twice/);
assert.equal(verdict('customer.created', 400, 1)[0], 'unkeyed');
assert.equal(verdict('payment_intent.created', 400, 0)[0], 'keyed');
});
FAQ
Which requests need an idempotency key?
Every mutating one. GETs are already idempotent and Stripe ignores the header on them. In practice the ones that matter are the requests that create something: PaymentIntents, charges, refunds, customers, subscriptions. Those are the operations where doing it twice is visible to a customer.
Why does request.id have to be non-null in the check?
Because Stripe-initiated events have both request fields null. Renewal invoices, trial_will_end, automatic payout events: none of them came from an API call of yours, so none of them could have carried a key. Without the guard those dominate the report and the real findings disappear into them.
How long does Stripe remember an idempotency key?
Results are saved for roughly 24 hours, after which the key is pruned. A retry inside that window replays the saved status code and body. A request reusing the same key after pruning is treated as brand new, which is one reason keys derived from something long-lived, like a customer ID, cause duplicates rather than preventing them.
Can I use the order ID as the key?
Only if it is unique per attempt at that specific request, and usually it is not. An order that is retried after a failed payment, or that creates both a customer and a PaymentIntent, reuses the value across different operations and different parameters, which returns an idempotency_error instead of protection. Derive the key from the operation and persist it.
What does a 409 idempotency_key_in_use mean?
Two requests carrying the same key arrived close enough together that neither result was saved yet. It is retryable, and the retry must use the same key. It is a signal that the key is shared across concurrent operations rather than unique to one.
Related field notes
- One customer has several Customer records
- Two endpoints share one URL, so every event is handled twice
- Refunds fail or sit unresolved
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Idempotent requests — Stripe API reference
- The event object — Stripe API reference
- List all events — Stripe API reference
- Versioning — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.