Skip to content

Diagnostic Stripe

duplicate customers share an email and split billing

A customer writes in to say they were charged twice this month. Support finds their subscription, checks it, and it billed once. The second charge is on a different Customer record with the same email address, created the day they resubscribed, and it has its own card, its own subscription and its own renewal date.

Read-only key Python and Node.js Tests included
A cable network
Photo by Taylor Vick on Unsplash
The short answer

Page GET /v1/customers?limit=100, lowercase every email, group, and flag any address with more than one record. Stripe does not enforce uniqueness on customer email, by design, so any code path that creates a Customer without looking one up first mints a new cus_ every time.

Then find out which duplicates hold anything: GET /v1/payment_methods?customer=<id>&type=card and GET /v1/subscriptions?customer=<id>. Empty duplicates are untidy. Two records with live subscriptions on one email address are two bills.

The problem in plain words

The obvious cost is support time: three records, and the card is on one, the subscription on another, the invoices split across both. Every enquiry starts with a hunt, and any answer given from the wrong record is wrong in a way nobody notices for a month.

The expensive cost is billing. Two Customer records each with an active subscription renew independently. Cancelling one leaves the other charging, which is the shape of the "I cancelled and you kept billing me" complaint that ends in a dispute. Analytics inherits the same fault line: churn, lifetime value and active-customer counts all count the records rather than the people, so the numbers are wrong in a direction that flatters.

Re-signup atcheckoutno lookup firstNew cus_createdsame addressCard saved onitold record keepsits ownTwosubscriptionsrenewingindependentlyCancel onethe other keepscharging
Stripe does not enforce uniqueness on customer email, so every create that skips a lookup mints another record.

Why it happens

Stripe deliberately does not enforce uniqueness. The email on a Customer is a label, not a key. This is one of the oldest complaints in the ecosystem and the answer has always been the same: uniqueness is yours to enforce, because Stripe has no way to know whether two records with one address are one person or a shared family inbox.

Every path that creates a customer is a path that can duplicate one. A second checkout by a returning customer, a retried webhook that creates before it checks, a re-signup after a cancellation, a Checkout configuration that always creates a customer. Each is reasonable on its own and none of them look like a bug in review.

Lookup by email is stricter than people expect. The email filter on the customer list is an exact, case-sensitive match. A user who typed a capital on signup and lowercase next time has two records and a lookup that finds neither from the other, which is why the grouping here normalises before comparing.

The duplicate is created at the worst moment. It happens at checkout, when a card is being saved and a subscription started, so the new empty record does not stay empty. By the time anyone notices, both records hold something worth keeping and merging them is careful manual work rather than a delete.

The fix, as a flow

The script lowercases every address before grouping, then asks which of the duplicates actually hold a card or a subscription, which is what separates an untidy list from a person being billed twice.

GET /v1/customersgrouped, then cards andsubscriptionsOne recordunique, leave itDuplicates hold nothingtidy up laterTwo hold cardssupport answers the wrong oneTwo hold subscriptionstwo bills, one person
Case folding matters: Stripe's own email filter is exact, so the duplicate that differs by one capital hides from every lookup.

How to fix it

List every customer and normalise the address

GET /v1/customers?limit=100, paginated to the end. Lowercase and trim before grouping. Customers with no email are a different problem and belong in their own bucket rather than in one enormous group keyed on nothing.

Find out which duplicates hold value

For each record in a duplicate group, GET /v1/payment_methods?customer=<id>&type=card and GET /v1/subscriptions?customer=<id>. This is the difference between a report you can act on and a list of 400 email addresses. A group where only one record holds anything is a tidy-up; a group where two hold subscriptions is a billing incident.

Confirm a specific case before touching it

GET /v1/customers?email=<address> matches exactly and is case-sensitive, so run it against each casing you actually found. GET /v1/customers/search handles substring matching when you need it, at the cost of an index that lags writes by up to a minute.

Stop making new ones first

Look up before you create: GET /v1/customers?email=<address>&limit=1, and reuse the id if there is one. Store the cus_ id on your own user row and treat that as the single source of truth, so the lookup is a fallback rather than the mechanism. In Checkout, pass an existing customer instead of relying on customer creation.

Merge deliberately, subscriptions last

Attach the payment methods to the keeper, move or re-create the subscriptions, then delete the empty record. Deleting a customer cancels its subscriptions, so a delete performed in the wrong order is a cancellation you did not intend — which is exactly why this script prints the steps instead of running them.

Re-run it monthly

New duplicates mean a new code path. The count going up after a release is a much better signal than a support ticket six weeks later.

How to check it worked

Re-run after the lookup-before-create change ships. The count of email addresses with more than one record should stop growing, and the dangerous subset should be empty once the merges are done.

python3 stripe_duplicate_customers.py
# 1,204 customer(s), 0 address(es) with more than one record

The full code

One paginated GET over customers, plus two small GETs per duplicated record to find out which of them actually hold a card or a subscription. Nothing writes. Both pure functions are exported and tested: the normalisation, because case is the whole reason the duplicates hide from an exact-match lookup, and the classification, because three records with one real customer among them and three records with two of them billing want very different responses.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 21 Stripe fixes, free and open source.
stripe_duplicate_customers.py
"""Report Stripe Customers that share an email address.

Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
access to Customers, Subscriptions and PaymentMethods. The merge is printed,
never performed, because deleting a customer cancels its subscriptions and this
script holds a credential to a live payments account.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_duplicate_customers")

API = "https://api.stripe.com/v1"


def normalise(email):
    """Lowercase and trim an address for grouping. Pure.

    Stripe's own email filter is exact and case-sensitive, so a user who
    capitalised once and did not the next time has two records that no exact
    lookup will ever put beside each other. Grouping has to normalise even
    though the confirming API call cannot.
    """
    if not email:
        return None
    return str(email).strip().lower() or None


def verdict(records):
    """Classify one group of customers sharing an address. Pure.

    Each record is {"id": str, "has_card": bool, "has_subscription": bool},
    filled in by the caller. Returns (state, detail).
    """
    n = len(records)
    if n <= 1:
        return ("unique", "one customer for this address")

    subs = [r for r in records if r.get("has_subscription")]
    holders = [r for r in records
               if r.get("has_card") or r.get("has_subscription")]

    if len(subs) > 1:
        return ("split_billing",
                "%d records, %d with a subscription. They renew independently, "
                "so cancelling one leaves the other charging." % (n, len(subs)))
    if len(holders) > 1:
        return ("split_methods",
                "%d records, %d holding a card or a subscription. Support will "
                "answer from whichever one they find first." % (n, len(holders)))
    if holders:
        return ("shells",
                "%d records, one holding everything. The other %d are empty."
                % (n, n - 1))
    return ("empty",
            "%d records, none holding a card or a subscription. Untidy, not "
            "urgent." % n)


def get(session, path, params=None):
    r = session.get(API + path, params=params or {}, 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 group_by_email(session, limit):
    """Return {normalised email: [customer ids]} plus the number read."""
    groups = {}
    seen = 0
    params = {"limit": 100}
    while True:
        page = get(session, "/customers", params)
        data = page.get("data", [])
        for c in data:
            seen += 1
            key = normalise(c.get("email"))
            if key is None:
                continue  # no email is a different problem
            groups.setdefault(key, []).append(c["id"])
        if not data or not page.get("has_more") or seen >= limit:
            break
        params["starting_after"] = data[-1]["id"]
    return groups, seen


def enrich(session, customer_id):
    """One record for verdict(), costing two small GETs."""
    cards = get(session, "/payment_methods",
                {"customer": customer_id, "type": "card", "limit": 1})
    subs = get(session, "/subscriptions",
               {"customer": customer_id, "status": "all", "limit": 1})
    return {"id": customer_id,
            "has_card": bool(cards.get("data")),
            "has_subscription": bool(subs.get("data"))}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--max-customers", type=int, default=10000,
                    help="stop paginating after this many customers")
    ap.add_argument("--max-groups", type=int, default=50,
                    help="how many duplicate groups to enrich and report")
    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})

    groups, seen = group_by_email(s, args.max_customers)
    dupes = {e: ids for e, ids in groups.items() if len(ids) > 1}
    log.info("%d customer(s), %d address(es) with more than one record",
             seen, len(dupes))
    if not dupes:
        return 0

    # Worst first: the ones with the most records are the ones support is
    # already losing time to.
    ordered = sorted(dupes.items(), key=lambda kv: -len(kv[1]))[:args.max_groups]
    bad = 0
    for email, ids in ordered:
        records = [enrich(s, cid) for cid in ids]
        state, detail = verdict(records)
        log.warning("%-14s %s  %s", state, email, detail)
        log.warning("  records: %s", ", ".join(r["id"] for r in records))
        if state in ("split_billing", "split_methods"):
            bad += 1
            keeper = records[0]["id"]
            log.warning("  merge: POST %s/payment_methods/<pm>/attach "
                        "-d customer=%s, move the subscriptions, then "
                        "DELETE %s/customers/<dupe>", API, keeper, API)
            log.warning("  deleting a customer cancels its subscriptions, so "
                        "empty the record before you delete it")
    log.warning("  prevent: GET %s/customers?email=<address>&limit=1 before "
                "creating, and store the cus_ id on your own user row", API)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
stripe-duplicate-customers.mjs
/**
 * Report Stripe Customers that share an email address.
 *
 * Read only. Paginated GETs and no writes: give this a RESTRICTED key with read
 * access to Customers, Subscriptions and PaymentMethods. The merge is printed,
 * never performed, because deleting a customer cancels its subscriptions.
 */
const API = 'https://api.stripe.com/v1';

/**
 * Lowercase and trim an address for grouping. Pure.
 *
 * Stripe's own email filter is exact and case-sensitive, so grouping has to
 * normalise even though the confirming API call cannot.
 */
export function normalise(email) {
  if (!email) return null;
  return String(email).trim().toLowerCase() || null;
}

/**
 * Classify one group of customers sharing an address. Pure.
 * Each record is { id, has_card, has_subscription }, filled in by the caller.
 */
export function verdict(records) {
  const n = records.length;
  if (n <= 1) return ['unique', 'one customer for this address'];

  const subs = records.filter((r) => r.has_subscription);
  const holders = records.filter((r) => r.has_card || r.has_subscription);

  if (subs.length > 1) {
    return ['split_billing',
      `${n} records, ${subs.length} with a subscription. They renew ` +
      'independently, so cancelling one leaves the other charging.'];
  }
  if (holders.length > 1) {
    return ['split_methods',
      `${n} records, ${holders.length} holding a card or a subscription. ` +
      'Support will answer from whichever one they find first.'];
  }
  if (holders.length) {
    return ['shells',
      `${n} records, one holding everything. The other ${n - 1} are empty.`];
  }
  return ['empty',
    `${n} records, none holding a card or a subscription. Untidy, not urgent.`];
}

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 groupByEmail(key, limit = 10000) {
  const groups = new Map();
  let seen = 0;
  const params = { limit: 100 };
  for (;;) {
    const page = await get(key, '/customers', params);
    const data = page.data ?? [];
    for (const c of data) {
      seen += 1;
      const email = normalise(c.email);
      if (email === null) continue; // no email is a different problem
      groups.set(email, [...(groups.get(email) ?? []), c.id]);
    }
    if (data.length === 0 || !page.has_more || seen >= limit) break;
    params.starting_after = data[data.length - 1].id;
  }
  return { groups, seen };
}

async function enrich(key, customerId) {
  const cards = await get(key, '/payment_methods',
    { customer: customerId, type: 'card', limit: 1 });
  const subs = await get(key, '/subscriptions',
    { customer: customerId, status: 'all', limit: 1 });
  return {
    id: customerId,
    has_card: Boolean((cards.data ?? []).length),
    has_subscription: Boolean((subs.data ?? []).length),
  };
}

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 maxGroups = Number(process.argv[2] ?? 50);
  const { groups, seen } = await groupByEmail(key);
  const dupes = [...groups.entries()].filter(([, ids]) => ids.length > 1);

  console.log(`${seen} customer(s), ${dupes.length} address(es) with more than one record`);
  if (dupes.length === 0) return;

  // Worst first: the ones with the most records are the ones support is
  // already losing time to.
  dupes.sort((a, b) => b[1].length - a[1].length);
  let bad = 0;
  for (const [email, ids] of dupes.slice(0, maxGroups)) {
    const records = [];
    for (const id of ids) records.push(await enrich(key, id));
    const [state, detail] = verdict(records);
    console.warn(`${state.padEnd(14)} ${email}  ${detail}`);
    console.warn(`  records: ${records.map((r) => r.id).join(', ')}`);
    if (state === 'split_billing' || state === 'split_methods') {
      bad += 1;
      console.warn(`  merge: POST ${API}/payment_methods/<pm>/attach ` +
                   `-d customer=${records[0].id}, move the subscriptions, then ` +
                   `DELETE ${API}/customers/<dupe>`);
      console.warn('  deleting a customer cancels its subscriptions, so empty ' +
                   'the record before you delete it');
    }
  }
  console.warn(`  prevent: GET ${API}/customers?email=<address>&limit=1 before ` +
               'creating, and store the cus_ id on your own user row');
  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

Two things are worth pinning. Normalisation has to fold case, because a capital letter on signup is the most common way a duplicate hides from an exact-match lookup. And a group with two live subscriptions has to sort above a group with two saved cards, because one of those is a support annoyance and the other is billing the same person twice.

test_stripe_duplicate_customers.py
from stripe_duplicate_customers import normalise, verdict


def rec(cid, card=False, sub=False):
    return {"id": cid, "has_card": card, "has_subscription": sub}


def test_normalisation_folds_case_and_whitespace():
    assert normalise("  Ada@Example.COM ") == "ada@example.com"
    assert normalise("") is None
    assert normalise(None) is None


def test_a_single_record_is_not_a_duplicate():
    assert verdict([rec("cus_1", card=True)])[0] == "unique"


def test_two_live_subscriptions_is_the_billing_case():
    state, detail = verdict([rec("cus_1", sub=True), rec("cus_2", sub=True)])
    assert state == "split_billing"
    assert "cancelling one" in detail


def test_two_records_holding_cards_is_a_support_problem_not_a_billing_one():
    state, _ = verdict([rec("cus_1", card=True), rec("cus_2", card=True)])
    assert state == "split_methods"


def test_duplicates_holding_nothing_are_ranked_below_ones_that_do():
    assert verdict([rec("cus_1", card=True), rec("cus_2")])[0] == "shells"
    assert verdict([rec("cus_1"), rec("cus_2"), rec("cus_3")])[0] == "empty"
stripe-duplicate-customers.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { normalise, verdict } from './stripe-duplicate-customers.mjs';

const rec = (id, card = false, sub = false) =>
  ({ id, has_card: card, has_subscription: sub });

test('normalisation folds case and whitespace', () => {
  assert.equal(normalise('  Ada@Example.COM '), 'ada@example.com');
  assert.equal(normalise(''), null);
  assert.equal(normalise(null), null);
});

test('a single record is not a duplicate', () => {
  assert.equal(verdict([rec('cus_1', true)])[0], 'unique');
});

test('two live subscriptions is the billing case', () => {
  const [state, detail] = verdict([rec('cus_1', false, true), rec('cus_2', false, true)]);
  assert.equal(state, 'split_billing');
  assert.match(detail, /cancelling one/);
});

test('two records holding cards is a support problem not a billing one', () => {
  assert.equal(verdict([rec('cus_1', true), rec('cus_2', true)])[0], 'split_methods');
});

test('duplicates holding nothing are ranked below ones that do', () => {
  assert.equal(verdict([rec('cus_1', true), rec('cus_2')])[0], 'shells');
  assert.equal(verdict([rec('cus_1'), rec('cus_2'), rec('cus_3')])[0], 'empty');
});

FAQ

Why does Stripe allow two customers with the same email?

Because email is a label on the Customer object, not a key. Stripe cannot know whether two records on one address are one person or two people sharing an inbox, so uniqueness is left to you. It has been asked about for over a decade and the answer has not changed.

How do I look up a customer by email?

GET /v1/customers?email=<address> filters on an exact, case-sensitive match. GET /v1/customers/search with a query handles substring matching, at the cost of a search index that can lag a write by up to a minute. Neither will fold case for you, which is why duplicates that differ only in capitalisation stay invisible.

What is the safe order to merge duplicates in?

Attach the payment methods to the record you are keeping, move or re-create the subscriptions, confirm the loser holds nothing, and only then delete it. Deleting a customer cancels its subscriptions immediately, so a delete in the wrong order is an unintended cancellation.

Can I stop Checkout creating a new customer every time?

Yes. Pass an existing customer id to the Checkout Session rather than relying on customer creation. Look the customer up by email first, and store the cus_ id on your own user row so the next checkout does not need the lookup at all.

Is this worth fixing if the duplicates are empty?

It is worth preventing, not urgently worth merging. Empty duplicates only cost search time. The reason to act is that the code path creating them will eventually create one at checkout, where a card and a subscription land on the new record, and that one is a billing problem rather than an untidy list.

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.