← Blog

Stripe idempotency keys for creating customers: the retry the SDK covers, the double click it misses, the email we removed

Stripe idempotency keys for creating customers: what stripe-node already retries for you, the double click it does not catch, and the email we removed.

Clicking Upgrade twice, opening checkout in two tabs, or a request retried after a timeout can each create a second Stripe customer for the same user. Payment methods and invoices then split across two records, and the kit's "one customer per user" rule quietly breaks. Stripe idempotency keys for creating customers are the fix. The kit had one, and until this week the key contained the user's email address, which Stripe's own documentation says not to do. Here is what the SDK already protects you from, what it does not, and the key the kit uses now.

What stripe-node already does for you

The SDK sends an idempotency key on its own, but only for one request's retries. From stripe-node 22.2.0:

_defaultIdempotencyKey(method, settings, apiMode) {
  // If this is a POST and we allow multiple retries, ensure an idempotency key.
  const maxRetries = this._getMaxNetworkRetries(settings);
  const genKey = () => `stripe-node-retry-${this._stripe._platformFunctions.uuid4()}`;
  // ...
  if (method === 'POST' && maxRetries > 0) return genKey();
  return null;
}

To watch it work, I pointed a Stripe client at a local mock server and recorded the Idempotency-Key header on each request:

  • Default client (maxNetworkRetries is 2 unless you change it): the first attempt got a 500, and the SDK retried with the same stripe-node-retry-… key.
  • A second, separate customers.create call got a different generated key.
  • An explicit idempotencyKey was sent exactly as given.
  • maxNetworkRetries: 0: no key at all.

So a flaky network is already covered. A double click is not: two calls mean two generated keys, and Stripe sees two unrelated requests. Collapsing them takes a key derived from what the requests have in common, which here is the user.

A key that means "this user's customer"

export function customerIdempotencyKey(user: { id: string; email: string }): string {
  // Digest the exact email sent to Stripe: normalizing it here would let two
  // differently cased addresses share a key while the request params differ.
  const emailDigest = createHash("sha256").update(user.email).digest("hex").slice(0, 16);
  return `create-customer:${user.id}:${emailDigest}`;
}

export async function createStripeCustomer(user: { id: string; email: string }): Promise<string> {
  const customer = await stripe().customers.create(
    { email: user.email, metadata: { userId: user.id } },
    { idempotencyKey: customerIdempotencyKey(user) },
  );
  await saveStripeCustomerId(user.id, customer.id);
  return customer.id;
}

The second click sends the same key, so Stripe returns the first response instead of creating another customer. Each part of the key is there for a reason:

  • The user id makes it per user. It is an opaque internal id, not something personal.
  • Something that changes with the email. Stripe's docs warn that "the idempotency layer compares incoming parameters to those of the original request and errors if they're not the same". Within the key's lifetime, a user who changed their email would otherwise get an error instead of a customer.
  • A digest instead of the email itself. The same docs say: "Avoid using sensitive data (for example, email addresses or personal identifiers) as idempotency keys." The old key was create-customer:${user.id}:${user.email}, with the address in plain text in every request and in Stripe's logs. Now it looks like create-customer:usr_8f3kQ2:ebbacfe6629ad916.

I checked the new function directly. No part of the email appears in the key. The same user and email always give the same key. A changed email, a re-cased one or a different user each give a new key. The example above is 43 characters, far inside Stripe's 255-character limit even with a longer user id.

What the key does not cover

It expires. Stripe can prune keys "after they're at least 24 hours old", and "we generate a new request if a key is reused after the original is pruned." The key is protection for minutes, not memory. The kit's long-term memory is the database: saveStripeCustomerId writes the customer to billing_customers straight after creation, and checkout reads that table first, so a user who comes back next week reuses their customer without ever reaching customers.create. How that table came to exist is in Stripe Checkout and the customer portal in the App Router.

Truly simultaneous requests are a conflict, not a replay. Stripe saves a result only "after the execution of an endpoint begins". A request that collides with one still running is not saved and can be retried. stripe-node treats 409 conflicts as retryable, so the second click's request should come back with the first one's result.

Failures are replayed too. "Retries with the same key return the saved response, including 500 errors." If Stripe fails partway through creating the customer, the same key keeps returning that failure until it is pruned. That is rare, but it is the price of a deterministic key. If you ever see it, logging the key next to the error tells you why the retry did not help.

The database backstop. billing_customers has the user id as primary key and a unique stripe_customer_id, and the insert uses onConflictDoNothing. If two requests do both get through, only one row survives.

Things that bite

  • Do not reuse one key for different operations. A key built only from the user id, shared by customers.create and subscriptions.create, makes the second call fail Stripe's check against the first request. Prefix keys with the operation, as create-customer: does here.
  • Keys go on POSTs only. Stripe's docs say sending them on GET and DELETE "has no effect", because those are idempotent anyway.
  • Hash, do not normalize. Lowercasing the email before hashing it lets two requests with different email params share a key, which is exactly the parameter mismatch the digest exists to avoid.
  • Concurrency inside your app still matters. The key stops duplicate customers at Stripe, but two parallel checkout requests still both run your route. Make every step around the Stripe call safe to run twice, as the onConflictDoNothing insert is.

Shipwright, the Next.js and Claude starter kit this blog documents, ships customerIdempotencyKey and the billing_customers table, so a user gets one Stripe customer no matter how many times they click.