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.
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.
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.
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.
"""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())
/**
* 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.
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"
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
- Customers have no email, so Stripe sends no receipts
- Duplicate customers share an email and split billing
- PaymentMethods are created but never attached to a customer
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.
- The PaymentIntent object — Stripe API reference
- Create a PaymentIntent — Stripe API reference
- The charge object — Stripe API reference
- Search — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.