Skip to content

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.

Read-only key Python and Node.js Tests included
Two baristas smiling behind a counter
Photo by Vincent Leyva on Unsplash
The short answer

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.

Checkoutsubmitsno Idempotency-KeyStripe chargesthe card isdebitedResponse lostmobile networkdropsClient retriessame parametersCharged twicea refund and anapologyevery retry is a new charge
The charge succeeded the first time. Only the response was lost, and nothing in the retry can tell Stripe it is the same operation.

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.

GET /v1/eventsrequest.id andrequest.idempotency_keyNull request idStripe did it, not youKey presenta retry replays the resultNo key, customer createdduplicate recordsNo key, money movingduplicate charges
Stripe initiated events have a null request id, and on a billing account they outnumber everything else.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_idempotency_keys.py
"""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())
stripe-idempotency-keys.mjs
/**
 * 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.

test_stripe_idempotency_keys.py
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"
stripe-idempotency-keys.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.