Diagnostic Stripe
guest checkouts finish with customer null and can't be linked
Somebody writes in asking for a copy of a receipt from March. You search the Dashboard for their email address and find four separate payments, no Customer record, no purchase history, and nothing you can open the Billing Portal against. Every one of those four checkouts completed perfectly. None of them left behind anything that ties the four together.
Page GET /v1/checkout/sessions?status=complete&created[gte]=<now-90d> and count the sessions where mode == "payment" and customer is null. That is the default outcome, not a bug: customer_creation defaults to if_required, and Stripe only requires a Customer in subscription mode and in payment mode with post-purchase invoices enabled.
Group those guests by customer_details.email. Any address appearing more than once is a repeat buyer your account meets as a stranger every time. Fix it at creation with customer_creation=always, or by passing an existing customer=cus_...; on a Payment Link, set customer_creation on the link itself.
The problem in plain words
Guest checkout is usually a deliberate decision, and a good one: fewer fields, no account, a higher completion rate. The cost lands nowhere near the checkout. It lands afterwards, in every question that starts with "this customer".
You cannot show a purchase history, because there is no object to hang one on. You cannot open the Billing Portal, because a portal session is created against a Customer id and you do not have one. Lifetime value, refund history and repeat-purchase rate are all unqueryable from Stripe, because the only thing linking the payments is an email address sitting as a plain string on each Session. Support ends up doing by hand what the API was supposed to do, and does it with an amount, a date and a guess.
Why it happens
The default is named as if it described your business, and it describes Stripe's mechanics. if_required reads like "we will make one if it is needed". What it actually means is "if the Session cannot function without one" — a subscription, or a payment-mode session with post-purchase invoices. Needing a Customer to answer a support email is not part of that test, so the answer is always no.
Nothing about the checkout looks different. The Session completes, the payment succeeds, and Stripe emails a receipt to the address in customer_details.email. A receipt going out feels like proof that a customer exists somewhere. It is not: that field is a string, not a reference.
The email looks like a Customer and behaves like a label. It has no id, so no GET /v1/customers lookup finds it, no portal session can be created from it, and nothing prevents the same human from having three spellings of it across three purchases. It is a hint, not a join.
It is only really fixable going forward. You can create Customers from the addresses you have, but the Sessions that already completed keep customer: null, and the merge you would do is exactly the email-matching guess that made this a problem. Every week the flag stays on the default is another week of payments that will never link to anyone.
The fix, as a flow
The script reads a window of completed sessions once, counts how many share each email address, and only then classifies them, because a single session cannot tell you whether its buyer has been here before.
How to fix it
Page the completed sessions for a window you believe in
GET /v1/checkout/sessions?status=complete&created[gte]=<unix>&limit=100, paginated. Ninety days is long enough for repeat buyers to actually repeat, which is the number that makes the argument, and short enough that it describes the code running today.
Split by mode before you count anything
Subscription-mode sessions always have a Customer, because Stripe cannot run a subscription without one. Mixing them into the denominator makes the guest ratio look better on accounts that sell both, which are the accounts where this matters most.
Group the guests by customer_details.email
One address, several completed sessions, no Customer on any of them. That is the finding worth putting in front of someone: not "we create guest checkouts" but "142 people bought from us more than once this quarter and we recognised none of them".
Treat a guest with no email at all as its own case
A guest session you can still match on an address is recoverable by hand later. One with neither a Customer nor an email is not recoverable by any means at all, and a check that folds it in with the rest hides the only truly permanent loss on the report.
Set customer_creation at creation, or pass the customer you already know
customer_creation=always makes Stripe create the Customer. If the buyer is signed in and you already hold a cus_ id, pass customer instead — that links the payment to the record you have rather than making a second one beside it.
Fix Payment Links on the link, once
A link has no per-purchase code path to set anything in, so the setting lives on the link and every Session it creates inherits it. Links are usually the worst offenders on the report for exactly that reason: nobody has ever written a line of code near them.
Re-run monthly and watch the repeat count
The guest count falls slowly as old sessions age out of the window. The number that should move immediately is repeat guests, because after the change a returning buyer is matched to the Customer created on their first visit.
How to check it worked
Re-run after the change. New completed sessions should report linked, and the repeat-guest count should fall as the old window ages out.
python3 stripe_checkout_guests.py --days 30
# 214 session(s): 214 linked, 0 guest, 0 repeat-guest, 0 anonymous
The full code
One paginated GET against /v1/checkout/sessions and no writes. The classifier is pure and takes the number of sessions in the window sharing this one's email, because the difference between a genuine one-off guest and a repeat buyer you fail to recognise every time is the entire argument for changing the flag, and it is not visible on a single Session.
"""Report Stripe Checkout Sessions that completed without a Customer.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Checkout Sessions. 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_checkout_guests")
API = "https://api.stripe.com/v1"
def verdict(session, email_seen=1):
"""Classify one completed Checkout Session. Pure, so the rules can be tested
offline.
`email_seen` is how many sessions in the window share this session's
customer_details.email. A single Session cannot tell you whether its buyer
has been here before, and that is the fact the whole report turns on.
Returns (state, detail).
"""
if session.get("customer"):
return ("linked", "customer=%s" % (session["customer"],))
mode = session.get("mode")
if mode != "payment":
return ("unknown",
"mode %r completed with no Customer, which Stripe normally "
"requires here" % (mode,))
creation = session.get("customer_creation")
if creation == "always":
return ("unknown",
"customer_creation=always but no Customer is attached; check the "
"session really completed")
email = str((session.get("customer_details") or {}).get("email") or "").strip()
if not email:
return ("anonymous",
"no Customer and no customer_details.email: nothing at all to "
"match this payment to later")
if email_seen > 1:
return ("repeat-guest",
"%s completed %d sessions in this window and was a new stranger "
"every time" % (email, email_seen))
return ("guest",
"customer_creation=%r, so Stripe made no Customer; %s exists only as "
"a string on the Session" % (creation, email))
def email_of(session):
"""The address a guest session could later be matched on, normalised."""
return str((session.get("customer_details") or {}).get("email") or "").strip().lower()
def email_counts(sessions):
"""Count how many sessions share each address. Pure, and used before any
session is classified, because the count is an argument to the classifier."""
counts = {}
for s in sessions:
addr = email_of(s)
if addr:
counts[addr] = counts.get(addr, 0) + 1
return counts
def get(http, path, params=None):
r = http.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 completed_sessions(http, since, limit):
"""Yield completed Checkout Sessions created since `since`, newest first."""
seen = 0
params = {"limit": 100, "status": "complete", "created[gte]": int(since)}
while True:
page = get(http, "/checkout/sessions", params)
data = page.get("data", [])
for s in data:
yield s
seen += 1
if not data or not page.get("has_more") or seen >= limit:
break
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=90,
help="how far back to read completed sessions")
ap.add_argument("--max-sessions", type=int, default=5000,
help="stop paginating after this many sessions")
ap.add_argument("--show", type=int, default=10,
help="how many repeat guests to print")
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
http = requests.Session()
http.headers.update({"Authorization": "Bearer " + key})
since = time.time() - args.days * 86400
sessions = list(completed_sessions(http, since, args.max_sessions))
if not sessions:
log.info("no completed Checkout Sessions in the last %d days", args.days)
return 0
counts = email_counts(sessions)
tally = {"linked": 0, "guest": 0, "repeat-guest": 0, "anonymous": 0, "unknown": 0}
repeats = []
for s in sessions:
state, detail = verdict(s, counts.get(email_of(s), 1))
tally[state] = tally.get(state, 0) + 1
if state == "repeat-guest" and len(repeats) < args.show:
repeats.append((s.get("id", "?"), detail))
log.info("%d session(s): %d linked, %d guest, %d repeat-guest, %d anonymous",
len(sessions), tally["linked"], tally["guest"],
tally["repeat-guest"], tally["anonymous"])
for sid, detail in repeats:
log.warning("repeat-guest %s %s", sid, detail)
if tally["unknown"]:
log.warning("%d session(s) in an unexpected state; read them by hand",
tally["unknown"])
unlinked = tally["guest"] + tally["repeat-guest"] + tally["anonymous"]
if unlinked:
log.warning(" repair: POST %s/checkout/sessions -d customer_creation=always",
API)
log.warning(" or pass the id you already hold: -d customer=cus_XXX")
log.warning(" for a Payment Link, set it on the link: POST "
"%s/payment_links/plink_XXX -d customer_creation=always", API)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe Checkout Sessions that completed without a Customer.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Checkout Sessions. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Classify one completed Checkout Session. Pure, so the rules can be tested
* offline. `emailSeen` is how many sessions in the window share this session's
* customer_details.email.
*/
export function verdict(session, emailSeen = 1) {
if (session.customer) return ['linked', `customer=${session.customer}`];
const mode = session.mode;
if (mode !== 'payment') {
return ['unknown',
`mode ${JSON.stringify(mode)} completed with no Customer, which Stripe ` +
'normally requires here'];
}
const creation = session.customer_creation;
if (creation === 'always') {
return ['unknown',
'customer_creation=always but no Customer is attached; check the session ' +
'really completed'];
}
const email = String(session.customer_details?.email ?? '').trim();
if (!email) {
return ['anonymous',
'no Customer and no customer_details.email: nothing at all to match this ' +
'payment to later'];
}
if (emailSeen > 1) {
return ['repeat-guest',
`${email} completed ${emailSeen} sessions in this window and was a new ` +
'stranger every time'];
}
return ['guest',
`customer_creation=${JSON.stringify(creation)}, so Stripe made no Customer; ` +
`${email} exists only as a string on the Session`];
}
/** The address a guest session could later be matched on, normalised. */
export function emailOf(session) {
return String(session.customer_details?.email ?? '').trim().toLowerCase();
}
/** Count how many sessions share each address. Pure. */
export function emailCounts(sessions) {
const counts = new Map();
for (const s of sessions) {
const addr = emailOf(s);
if (addr) counts.set(addr, (counts.get(addr) ?? 0) + 1);
}
return counts;
}
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* completedSessions(key, since, limit = 5000) {
let seen = 0;
const params = { limit: 100, status: 'complete', 'created[gte]': Math.floor(since) };
for (;;) {
const page = await get(key, '/checkout/sessions', params);
const data = page.data ?? [];
for (const s of data) { yield s; seen += 1; }
if (data.length === 0 || !page.has_more || seen >= limit) break;
params.starting_after = data[data.length - 1].id;
}
}
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 days = Number(process.argv[2] ?? 90);
const sessions = [];
for await (const s of completedSessions(key, Date.now() / 1000 - days * 86400)) {
sessions.push(s);
}
if (sessions.length === 0) {
console.log(`no completed Checkout Sessions in the last ${days} days`);
return;
}
const counts = emailCounts(sessions);
const tally = { linked: 0, guest: 0, 'repeat-guest': 0, anonymous: 0, unknown: 0 };
const repeats = [];
for (const s of sessions) {
const [state, detail] = verdict(s, counts.get(emailOf(s)) ?? 1);
tally[state] = (tally[state] ?? 0) + 1;
if (state === 'repeat-guest' && repeats.length < 10) {
repeats.push([s.id ?? '?', detail]);
}
}
console.log(`${sessions.length} session(s): ${tally.linked} linked, ` +
`${tally.guest} guest, ${tally['repeat-guest']} repeat-guest, ` +
`${tally.anonymous} anonymous`);
for (const [id, detail] of repeats) console.warn(`repeat-guest ${id} ${detail}`);
if (tally.unknown) {
console.warn(`${tally.unknown} session(s) in an unexpected state; read them by hand`);
}
if (tally.guest + tally['repeat-guest'] + tally.anonymous) {
console.warn(` repair: POST ${API}/checkout/sessions -d customer_creation=always`);
console.warn(' or pass the id you already hold: -d customer=cus_XXX');
console.warn(' for a Payment Link, set it on the link: POST ' +
`${API}/payment_links/plink_XXX -d 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 case worth pinning is the repeat guest. A per-session check cannot see it — every one of those sessions is individually unremarkable — and it is the only state on the report that says the flag is costing you something today rather than in theory. The session with no email at all gets its own test for the opposite reason: it is rare, permanent, and easy to fold in with the recoverable ones by accident.
from stripe_checkout_guests import email_counts, verdict
def make(email=None, **kw):
s = {"mode": "payment", "customer_creation": "if_required"}
if email is not None:
s["customer_details"] = {"email": email}
s.update(kw)
return s
def test_a_session_with_a_customer_is_linked():
state, detail = verdict(make("a@example.com", customer="cus_9"))
assert state == "linked"
assert "cus_9" in detail
def test_the_default_flag_produces_a_guest():
state, detail = verdict(make("a@example.com"))
assert state == "guest"
assert "if_required" in detail
def test_the_same_address_twice_is_a_repeat_guest():
# The point of the note: no single session shows this, only the window does.
sessions = [make("buyer@example.com"), make("BUYER@example.com")]
counts = email_counts(sessions)
state, detail = verdict(sessions[0], counts["buyer@example.com"])
assert state == "repeat-guest"
assert "2" in detail
def test_a_guest_with_no_email_is_not_merely_a_guest():
assert verdict(make())[0] == "anonymous"
assert verdict(make(" "))[0] == "anonymous"
def test_subscription_mode_and_always_are_not_silently_guests():
assert verdict(make("a@example.com", mode="subscription"))[0] == "unknown"
assert verdict(make("a@example.com", customer_creation="always"))[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { emailCounts, verdict } from './stripe-checkout-guests.mjs';
const make = (email, extra = {}) => ({
mode: 'payment',
customer_creation: 'if_required',
...(email === undefined ? {} : { customer_details: { email } }),
...extra,
});
test('a session with a customer is linked', () => {
const [state, detail] = verdict(make('a@example.com', { customer: 'cus_9' }));
assert.equal(state, 'linked');
assert.match(detail, /cus_9/);
});
test('the default flag produces a guest', () => {
const [state, detail] = verdict(make('a@example.com'));
assert.equal(state, 'guest');
assert.match(detail, /if_required/);
});
test('the same address twice is a repeat guest', () => {
const sessions = [make('buyer@example.com'), make('BUYER@example.com')];
const counts = emailCounts(sessions);
const [state, detail] = verdict(sessions[0], counts.get('buyer@example.com'));
assert.equal(state, 'repeat-guest');
assert.match(detail, /2/);
});
test('a guest with no email is not merely a guest', () => {
assert.equal(verdict(make())[0], 'anonymous');
assert.equal(verdict(make(' '))[0], 'anonymous');
});
test('subscription mode and always are not silently guests', () => {
assert.equal(verdict(make('a@example.com', { mode: 'subscription' }))[0], 'unknown');
assert.equal(
verdict(make('a@example.com', { customer_creation: 'always' }))[0], 'unknown');
});
FAQ
Stripe emailed a receipt, so surely there is a customer somewhere?
No. The receipt goes to customer_details.email, which is a string recorded on the Checkout Session. It has no id, it is not an object, and nothing in /v1/customers will return it. A receipt proves an address was collected, not that a Customer was created.
What does customer_creation=if_required actually require?
Stripe's own mechanics, not your business needs. A Customer is required in subscription mode, and in payment mode when post-purchase invoices are enabled. Everything else completes with customer null, which is why the default silently produces guests on a plain one-off purchase.
Why can't I open the Billing Portal for these buyers?
A portal session is created against a Customer id. A guest checkout never produced one, so there is nothing to pass. Creating a Customer now does not retroactively attach the payments that already happened, which is why the flag matters more than the backfill.
Can I backfill Customers for the guest checkouts I already have?
You can create Customers from the addresses you collected, but the completed Sessions keep customer null and the join you would make is the same email match support is already doing by hand. Treat it as fixable going forward, and reconcile the historical window once, deliberately.
How do I set this on a Payment Link?
Set customer_creation on the link itself with POST /v1/payment_links/{plink_id} -d customer_creation=always. Every Checkout Session the link creates inherits it, which is the only place to fix it because a link has no per-purchase code path of its own.
Related field notes
- Checkout Sessions carry no ID that maps back to your order
- Duplicate customers share an email and split billing
- The Billing Portal has no configuration and will not open
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 Checkout Session object — Stripe API reference
- Create a Checkout Session — Stripe API reference
- Create a payment link — Stripe API reference
- The Customer object — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.