Your app has two Stripe customers for the same user, and neither of them notices
September 5, 2026
Stripe will let you create as many Customer objects as you want for the same email address. It doesn’t deduplicate, doesn’t warn you, doesn’t even surface it anywhere prominent in the dashboard. That’s reasonable behavior on Stripe’s side — plenty of real businesses legitimately want multiple customer records tied to one email, like a parent company with several subsidiaries. But if your app only expects one customer per user and quietly ends up with two, you get a specific, ugly failure mode: a real, paying subscription sitting on a Stripe customer object your app has completely stopped looking at.
The three ways this actually happens
1. A retried request during signup
Your signup flow calls customers.create, the request times out or the connection drops before the response comes back, and your code — reasonably, defensively — retries it. Except the first request usually did go through; Stripe processed it, just didn’t manage to tell you in time. The retry creates a second customer with the same email. Your database saves whichever customer ID came back last, and the first one becomes an orphan that still technically exists and can still be charged, refunded, or subscribed — just not by anything your app is watching.
2. Guest checkout, then account creation
A visitor checks out as a guest before creating an account — Stripe Checkout happily creates a customer object for them on the spot. Minutes later they sign up properly, and your onboarding code creates a second customer object because it has no idea the first one exists; nothing about the signup form knows to go looking for a guest checkout that happened under the same email ten minutes earlier. The subscription they just paid for lives on customer A. Their new account links to customer B. Their account page shows no active plan.
3. A webhook that races your own onboarding logic
Some integrations create the Stripe customer from a webhook (say, on user creation in your auth provider) and from a direct API call somewhere else in the codebase — a support tool, an admin backfill script, a mobile app hitting a different endpoint than the web app. Whichever path runs second has no way of knowing the first one already ran, because there’s no natural place that checks first. Two code paths, two customer objects, one user.
Why your own logs won’t show you this
Nothing here throws an error. Stripe’s API returns a perfectly valid 200 with a perfectly valid customer object every time — it has no way of knowing you didn’t want a new one. Your webhook handler processes customer.subscription.created correctly, for the correct (second) customer. Your database write succeeds. Every individual step in the chain does exactly what it was told to do. The bug only exists in the gap between two customer objects that both look completely legitimate on their own, and nothing in a typical stack is watching for that gap, because it isn’t an error condition from any single system’s point of view.
What it costs when a real customer hits it
This is one of the few billing bugs that a customer discovers themselves, immediately, instead of you catching it in a report weeks later. They pay, get redirected back to your app, and see a paywall or an “upgrade to Pro” prompt on a plan they just bought. The instinct is to assume they got scammed, not that there’s a data-modeling bug three layers down — and the support ticket that follows reads exactly like that. Multiply this by however many people sign up through a guest checkout flow or hit a flaky network during onboarding, and it stops being a one-off and starts being a small, steady leak of trust that never shows up as a single dramatic incident, just a slow trickle of angry first-day emails.
The fix
- Use an idempotency key on every
customers.createcall, derived from something stable in your own system — your internal user ID, not a timestamp or a random UUID generated per attempt. Stripe will return the original customer object on a retry with the same key instead of creating a new one. This alone closes failure mode #1 completely. - Before creating a customer, search for an existing one by email (or better, by a metadata field you control, since emails can change). Do this at every entry point that can create a customer — checkout, onboarding, admin tools, mobile — not just the main signup flow. This is what closes failure modes #2 and #3.
- Enforce a one-to-one mapping in your own database: a unique constraint on the column that stores the Stripe customer ID per user, not just an index. A unique constraint turns “a second customer got created” into a loud database error at write time instead of a silent second row.
- Run a periodic reconciliation that doesn’t trust any single code path: pull every customer object from Stripe, group by email, and flag any group with more than one result. This is the only check that catches duplicates that already happened, from causes you haven’t written a specific guard for yet.
The first three prevent new duplicates. The fourth is the one that finds the customers who are already stuck wrong today, sitting quietly in your Stripe account with an active subscription your app has never once checked.
What we built this for
I built Venwai after running into a version of this exact problem. It connects to Stripe with a read-only key, polls a single endpoint you expose on your own app once a day, and diffs “who Stripe says is paying” against “who your app says has access” — including cases like this one, where the mismatch traces back to a duplicate customer object rather than a missed webhook. The moment the two disagree, it alerts you in Slack, Discord, Telegram, or email, instead of waiting for the customer to find it first.
Free while it’s in beta, no card required: venwai.com.