Skip to content

Diagnostic Stripe

PaymentIntents have a null customer, so payments are orphaned

Someone in support asks a simple question: how many times has this person bought from us? There is no answer. Their payments are in Stripe, four of them, all succeeded, all with the customer column empty. Same card, same email in the billing details, four separate strangers as far as Stripe is concerned — and as far as Radar is concerned too.

Read-only key Python and Node.js Tests included
A white and black sign
Photo by sarah b on Unsplash
The short answer

Page GET /v1/payment_intents?created[gte]={now-90d} and count how many have customer == null, as a number, as a share of the total, and as a sum of amount. That is the size of the cohort you cannot see a history for.

Then find the part that is provably costing you something. Page GET /v1/charges over the same window, group the customerless ones by payment_method_details.card.fingerprint, and flag any fingerprint appearing more than once. Those are repeat buyers you are treating as first-timers, which is exactly the signal Radar uses to approve people.

The problem in plain words

Nothing here is failing. The payments succeed, the money arrives, the accounting balances. What is missing is the connective tissue: with no cus_ on the intent, there is no payment history to open, no card to offer back at the next checkout, and nothing for setup_future_usage to attach a saved credential to even if you passed it.

The part that costs money is the risk model. Stripe weighs a known, previously-successful customer very differently from an anonymous one. A buyer on their fourth purchase should be an easy approval; presented as a stranger every time, they get scored as a stranger every time, and some proportion of them are declined or land in review for no reason other than the missing link.

Guest checkoutno customer lookedupIntent createdcustomer left nullPaymentsucceedscard discarded tooBuyer returnsstill a strangerRisk scoreno history to leanonevery visit starts from nothing
Nothing fails. The money arrives every time; the history that would have made the fourth payment an easy approval was never kept.

Why it happens

customer is optional and the guest path is the shortest path. A PaymentIntent with an amount, a currency and a payment method is a complete, valid request. Adding a Customer means a lookup, a create, an email to match on and a decision about what to do when two records collide. Skipping it ships a working checkout on the first afternoon.

Checkout will not create one unless told. A Checkout Session without customer and without customer_creation=always produces a payment and no Customer record. This is easy to miss because the Session itself collects an email, so the data appears to have been captured — it just lives on the Session rather than on anything reusable.

The loss is invisible from inside the application. Your own database has the user, the order and the email. Every screen you built looks right. The gap only exists on Stripe's side, in the systems you did not build: the payment history, the saved cards, the risk score.

By the time you want it, backfilling is guesswork. Linking old payments to people after the fact means matching on card fingerprint and billing email, which works for most rows and not all of them. The fingerprint is stable per card, so a customer who has changed cards splits into two, and a shared family card merges two people into one.

The fix, as a flow

The script groups customerless charges by card fingerprint, because a share is an argument about how much guest checkout you meant to have and a fingerprint counted twice is a named buyer you scored as a stranger.

GET /v1/payment_intentsplus charges grouped by fingerprintEvery intent attachedclearA few orphansfine if guests are deliberateMajority orphanedguest is the default pathFingerprint paid twicea repeat buyer lost
Six orphans in four thousand is noise until one of those cards appears twice, at which point the share stops being the story.

How to fix it

Measure the orphan share over ninety days

Page GET /v1/payment_intents?limit=100&created[gte]={now-90d}. Count customer == null and sum the amount for that slice. The share matters more than the count: 3% is a guest-checkout option working as intended, 60% is the default path.

Find the repeat buyers hiding in it

This is the number that ends the argument. Page GET /v1/charges over the same window, take the customerless successes, and group by payment_method_details.card.fingerprint. Every fingerprint with a count above one is a returning customer whose history Stripe was not allowed to keep.

Check whether the card could have been saved

Among the orphans, count how many also have setup_future_usage == null. That confirms the same code path is both failing to identify the buyer and failing to keep their card, which is one fix rather than two.

Attach a customer before the intent, not after

Look up or create the Customer from your own user record, then pass customer into POST /v1/payment_intents. In Checkout, pass an existing customer or set customer_creation=always. Deduplicate on your own user id rather than on the email, or you will trade this problem for a different one.

Backfill carefully and only where it is safe

Match historical orphans to your users on billing_details.email first and card fingerprint second, and leave the ambiguous ones alone. A wrong link is worse than a missing one: it puts one person's payment history on another person's account.

How to check it worked

Re-run the script after the checkout change. New intents should carry a customer, and the repeat-fingerprint count should stop growing.

python3 stripe_orphan_payments.py
# clear       2,318 payment intent(s), 0 with no customer attached

The full code

Two paginated GETs and no writes — a restricted key with read access to PaymentIntents and Charges is enough, and is what you should give it. The classifier is pure and puts the fingerprint evidence above the percentage, because a repeat buyer counted twice is proof of a specific loss while a share is only ever an argument about product design.

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_orphan_payments.py
"""Report Stripe payments with no Customer attached, and the repeat buyers in them.

Read only. Two paginated GETs, no writes: give this a RESTRICTED key with read
access to PaymentIntents and Charges. 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_orphan_payments")

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

DOMINANT = 0.5   # share of orphaned intents above which guest checkout is the default path


def verdict(total, orphans, repeat_fingerprints):
    """Classify the window. Pure, so the ordering can be tested without a network.

    `repeat_fingerprints` counts distinct cards that paid more than once with no
    Customer attached. It outranks the share on purpose: a share is an argument
    about how much guest checkout you meant to have, and a repeat fingerprint is a
    named buyer whose history Stripe was not allowed to keep.
    """
    if not total:
        return ("unknown", "no payment intents in the window; nothing to judge")
    share = orphans / float(total)
    if repeat_fingerprints:
        return ("repeat",
                "%d card(s) paid more than once with no customer attached. Those are "
                "returning buyers scored as strangers every time." % repeat_fingerprints)
    if share >= DOMINANT:
        return ("dominant",
                "%d of %d payment intent(s), %.0f%%, have no customer. Guest checkout "
                "is the default path, not an option." % (orphans, total, share * 100))
    if orphans:
        return ("guests",
                "%d of %d payment intent(s) have no customer. Expected if guest "
                "checkout is deliberate; costly if it is not." % (orphans, total))
    return ("clear", "%d payment intent(s), 0 with no customer attached" % total)


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 page_all(session, path, limit, **params):
    seen = 0
    params = dict(params, limit=100)
    while True:
        page = get(session, path, **params)
        data = page.get("data", [])
        for obj in data:
            yield obj
            seen += 1
        if not data or not page.get("has_more") or seen >= limit:
            break
        params["starting_after"] = data[-1]["id"]


def repeat_cards(session, since, limit):
    """Group customerless successful charges by card fingerprint.

    The fingerprint is stable for one card across payments, so a count above one
    is the same physical card paying twice as two different strangers. It is not
    stable across a reissue, which makes this an undercount rather than a guess.
    """
    counts = {}
    for ch in page_all(session, "/charges", limit, **{"created[gte]": since}):
        if ch.get("customer") or ch.get("status") != "succeeded":
            continue
        card = ((ch.get("payment_method_details") or {}).get("card") or {})
        fp = card.get("fingerprint")
        if fp:
            counts[fp] = counts.get(fp, 0) + 1
    return {fp: n for fp, n in counts.items() if n > 1}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=90,
                    help="how far back to read payment intents and charges")
    ap.add_argument("--max-objects", type=int, default=5000,
                    help="stop paginating each list after this many objects")
    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 = int(time.time()) - args.days * 86400

    total = orphans = orphan_amount = unsaveable = 0
    for pi in page_all(s, "/payment_intents", args.max_objects,
                       **{"created[gte]": since}):
        total += 1
        if pi.get("customer"):
            continue
        orphans += 1
        orphan_amount += pi.get("amount") or 0
        if not pi.get("setup_future_usage"):
            unsaveable += 1

    repeats = repeat_cards(s, since, args.max_objects)
    state, detail = verdict(total, orphans, len(repeats))

    line = "%-11s %s" % (state, detail)
    if state in ("clear", "unknown"):
        log.info(line)
        return 0

    log.warning(line)
    log.warning("  %d in the smallest currency unit is unattributed to anyone",
                orphan_amount)
    log.warning("  %d of the orphans also had no setup_future_usage, so the card "
                "was discarded too", unsaveable)
    for fp, n in sorted(repeats.items(), key=lambda kv: -kv[1])[:10]:
        log.warning("  fingerprint %s paid %d times as a stranger", fp, n)
    log.warning("  repair: look the customer up before creating the intent")
    log.warning("  POST %s/payment_intents -d customer=cus_XXX "
                "-d setup_future_usage=off_session", API)
    log.warning("  in Checkout: pass an existing customer, or customer_creation=always")
    return 1


if __name__ == "__main__":
    sys.exit(main())
stripe-orphan-payments.mjs
/**
 * Report Stripe payments with no Customer attached, and the repeat buyers in them.
 *
 * Read only. Two paginated GETs, no writes: give this a RESTRICTED key with read
 * access to PaymentIntents and Charges. The repair is printed, never performed.
 */
const API = 'https://api.stripe.com/v1';

// Share of orphaned intents above which guest checkout is the default path.
export const DOMINANT = 0.5;

/**
 * Classify the window. Pure, so the ordering can be tested without a network.
 *
 * `repeatFingerprints` outranks the share on purpose: a share is an argument
 * about how much guest checkout you meant to have, and a repeat fingerprint is a
 * named buyer whose history Stripe was not allowed to keep.
 */
export function verdict(total, orphans, repeatFingerprints) {
  if (!total) return ['unknown', 'no payment intents in the window; nothing to judge'];
  const share = orphans / total;
  if (repeatFingerprints) {
    return ['repeat',
      `${repeatFingerprints} card(s) paid more than once with no customer attached. ` +
      'Those are returning buyers scored as strangers every time.'];
  }
  if (share >= DOMINANT) {
    return ['dominant',
      `${orphans} of ${total} payment intent(s), ${Math.round(share * 100)}%, have ` +
      'no customer. Guest checkout is the default path, not an option.'];
  }
  if (orphans) {
    return ['guests',
      `${orphans} of ${total} payment intent(s) have no customer. Expected if guest ` +
      'checkout is deliberate; costly if it is not.'];
  }
  return ['clear', `${total} payment intent(s), 0 with no customer attached`];
}

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();
}

async function* pageAll(key, path, limit, params = {}) {
  let seen = 0;
  const p = { ...params, limit: 100 };
  for (;;) {
    const page = await get(key, path, p);
    const data = page.data ?? [];
    for (const obj of data) { yield obj; seen += 1; }
    if (data.length === 0 || !page.has_more || seen >= limit) break;
    p.starting_after = data[data.length - 1].id;
  }
}

/**
 * Group customerless successful charges by card fingerprint. The fingerprint is
 * stable for one card across payments and not across a reissue, which makes this
 * an undercount rather than a guess.
 */
export async function repeatCards(key, since, limit = 5000) {
  const counts = new Map();
  for await (const ch of pageAll(key, '/charges', limit, { 'created[gte]': since })) {
    if (ch.customer || ch.status !== 'succeeded') continue;
    const fp = ch.payment_method_details?.card?.fingerprint;
    if (fp) counts.set(fp, (counts.get(fp) ?? 0) + 1);
  }
  return new Map([...counts].filter(([, n]) => n > 1));
}

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 since = Math.floor(Date.now() / 1000) - 90 * 86400;

  let total = 0;
  let orphans = 0;
  let orphanAmount = 0;
  let unsaveable = 0;
  for await (const pi of pageAll(key, '/payment_intents', 5000, { 'created[gte]': since })) {
    total += 1;
    if (pi.customer) continue;
    orphans += 1;
    orphanAmount += pi.amount ?? 0;
    if (!pi.setup_future_usage) unsaveable += 1;
  }

  const repeats = await repeatCards(key, since);
  const [state, detail] = verdict(total, orphans, repeats.size);

  const line = `${state.padEnd(11)} ${detail}`;
  if (state === 'clear' || state === 'unknown') { console.log(line); return; }

  console.warn(line);
  console.warn(`  ${orphanAmount} in the smallest currency unit is unattributed to anyone`);
  console.warn(`  ${unsaveable} of the orphans also had no setup_future_usage, so the ` +
               'card was discarded too');
  for (const [fp, n] of [...repeats].sort((a, b) => b[1] - a[1]).slice(0, 10)) {
    console.warn(`  fingerprint ${fp} paid ${n} times as a stranger`);
  }
  console.warn('  repair: look the customer up before creating the intent');
  console.warn(`  POST ${API}/payment_intents -d customer=cus_XXX -d setup_future_usage=off_session`);
  console.warn('  in Checkout: pass an existing customer, or customer_creation=always');
  process.exitCode = 1;
}

// 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 test that matters is the small one: a handful of orphaned payments among thousands is unremarkable until one card appears twice in it, at which point the share stops being the story. The classifier has to rank the fingerprint evidence above the percentage even when the percentage is tiny.

test_stripe_orphan_payments.py
from stripe_orphan_payments import verdict


def test_a_repeat_fingerprint_outranks_a_tiny_share():
    # 6 of 4000 is noise. One of those cards paying twice is not.
    state, detail = verdict(4000, 6, 2)
    assert state == "repeat"
    assert "2" in detail


def test_majority_orphaned_means_guest_checkout_is_the_default():
    assert verdict(1000, 499, 0)[0] == "guests"
    state, _ = verdict(1000, 500, 0)
    assert state == "dominant"


def test_a_few_orphans_are_reported_without_alarm():
    state, detail = verdict(1000, 30, 0)
    assert state == "guests"
    assert "deliberate" in detail


def test_every_intent_attached_is_clear():
    assert verdict(1000, 0, 0)[0] == "clear"


def test_an_empty_window_is_not_silently_clear():
    assert verdict(0, 0, 0)[0] == "unknown"
stripe-orphan-payments.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-orphan-payments.mjs';

test('a repeat fingerprint outranks a tiny share', () => {
  const [state, detail] = verdict(4000, 6, 2);
  assert.equal(state, 'repeat');
  assert.match(detail, /2/);
});

test('majority orphaned means guest checkout is the default', () => {
  assert.equal(verdict(1000, 499, 0)[0], 'guests');
  assert.equal(verdict(1000, 500, 0)[0], 'dominant');
});

test('a few orphans are reported without alarm', () => {
  const [state, detail] = verdict(1000, 30, 0);
  assert.equal(state, 'guests');
  assert.match(detail, /deliberate/);
});

test('every intent attached is clear', () => {
  assert.equal(verdict(1000, 0, 0)[0], 'clear');
});

test('an empty window is not silently clear', () => {
  assert.equal(verdict(0, 0, 0)[0], 'unknown');
});

FAQ

Is guest checkout wrong?

No. It is a deliberate product decision and it lifts conversion for one-off purchases. The problem is guest checkout by accident: an integration that never passes a customer because nobody chose to, which quietly forfeits saved cards, payment history and the returning-buyer signal Radar uses.

How reliable is the card fingerprint for grouping?

It is stable for one card number across payments and across accounts, which makes it good evidence of a repeat purchase. It changes when the card is reissued and it is shared by anyone using the same physical card, so treat a match as strong evidence of a returning buyer and never as an identity.

Does attaching a Customer save the card automatically?

No, those are two parameters. customer links the payment to a person; setup_future_usage is what stores the credential for reuse. A PaymentIntent can have the first without the second, which is a separate and very common way to lose a card.

What about Checkout Sessions specifically?

A Session with no customer and no customer_creation setting completes the payment and creates nothing reusable, even though it collected an email. Pass an existing customer where you have one, or set customer_creation=always so the Session produces a record you can charge again.

Can I detect this with a restricted key?

Yes. Read access to PaymentIntents and Charges is all this script uses. Give it nothing more, and a leak costs you a list of payment amounts rather than the ability to move money.

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.