Diagnostic Stripe
customers have no email, so Stripe sends no receipts
A dispute arrives with the reason “unrecognised”. The cardholder is a real, current customer, and they are not lying: they never got a receipt, so the only thing linking that line on their statement to your business is a descriptor they have never seen before. The Customer record has an email column and it is empty.
Page GET /v1/customers and count the ones where email is null or empty, as a number and as a share of the total. Then weight it: for each of those, GET /v1/subscriptions?customer={id}&status=active. An emailless customer with an active subscription is not a data-quality issue, it is an account that dunning can never reach.
Cover the guest path too. GET /v1/charges counting rows where both customer and receipt_email are null finds the one-off payments that produced no receipt at all. Cross-check disputed on the emailless cohort to see what it has already cost.
The problem in plain words
Stripe sends receipts and dunning notices to one of two places: customer.email, or the receipt_email on the individual payment. If neither is set, nothing is sent, and nothing reports that nothing was sent. There is no bounce, no error, no field on the charge saying the receipt went nowhere. The payment succeeded, which is the only thing anybody is watching.
The cost shows up twice, and neither time does it look like this. First as disputes: a customer who cannot connect a statement line to a purchase calls their bank, and you lose the amount plus the dispute fee on a charge that was entirely legitimate. Second as failed renewals that never recover, because the dunning sequence is emailing a null address while the subscription walks through its retry schedule and cancels.
Why it happens
The Customer is usually created before you have the email. Server-side flows create the cus_ record at signup or at the start of checkout, then collect contact details a step later and write them to your own users table. Stripe's copy stays null because nobody ever went back to update it, and your application works perfectly since it reads the email from its own database.
Nothing in the API objects to it. email is optional on the Customer. Creating one without it is a 200. Charging it is a 200. Subscribing it is a 200. The first negative signal is a dispute or a churned subscriber, months later and attributed to something else.
Guest checkout has a separate hole with the same shape. A one-off payment with no Customer at all can still send a receipt if you set receipt_email on the PaymentIntent. Integrations that collect an email purely for their own order confirmation frequently never pass it to Stripe, so the Stripe-side receipt — the one with the statement descriptor on it — is never sent.
Missing receipts and friendly fraud are the same story. The receipt is what makes a charge recognisable weeks later. Take it away and an ordinary customer looking at an unfamiliar descriptor has no way to identify the purchase except by asking their bank, which is what a dispute is.
The fix, as a flow
The script counts the blank emails, then asks which of those customers are being billed and which have already disputed a charge, because those two questions turn a percentage into a cost.
How to fix it
Count the gap, as a number and a share
GET /v1/customers?limit=100, paginated. Count email that is null or an empty string — both occur, and only checking for null misses the ones written as blanks by a form. The share matters more than the count: 40 missing out of 12,000 is cleanup, 40 out of 90 is the current behaviour of your signup.
Weight by whether the customer is being billed
For each emailless customer, check for an active subscription. Those are the accounts where the next failed payment starts a dunning sequence with nowhere to send it, and they should be at the top of the report regardless of how small the overall percentage is.
Check the guest payments separately
GET /v1/charges?limit=100 and count rows where customer and receipt_email are both null. Those are payments that produced no receipt from Stripe at all. A high count here with a clean customer list means the gap is in the guest checkout, not in signup.
Price it with the disputes you already have
Look at disputed on charges belonging to the emailless cohort. Turning "some customers have no email" into "eleven customers have no email and four of them have disputed a charge" is the difference between a backlog ticket and a fix that ships this week.
Backfill, then close the path that created it
Write the addresses you already hold: POST /v1/customers/{id} -d email=.... Pass email at creation from then on, set receipt_email on guest PaymentIntents, and confirm successful-payment receipts are actually switched on under the Dashboard's email settings, since a populated field sends nothing if the setting is off.
How to check it worked
Re-run after the backfill. The number to watch is the second one: no customer with an active subscription should be unreachable.
python3 stripe_customers_missing_email.py
# clear every customer in the window has an email
The full code
Two paginated GETs, no writes — a restricted key with read access to Customers, Subscriptions and Charges is enough. The classifier takes five counts and is pure, because the ordering between them is the whole point: a disputed charge on an emailless customer has to outrank a percentage, however alarming the percentage looks.
"""Report Stripe customers with no email, so no receipt or dunning notice is sent.
Read only. GETs only, no writes: give this a RESTRICTED key with read access to
Customers, Subscriptions 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 requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_customers_missing_email")
API = "https://api.stripe.com/v1"
WIDESPREAD_RATIO = 0.25 # a quarter of customers is a signup path, not a backlog
def verdict(missing, total, with_active_sub, receiptless_charges, disputed):
"""Classify the email gap. Pure, so the ordering can be tested offline.
missing customers with a null or empty email
total customers examined
with_active_sub of those, how many have an active subscription
receiptless_charges charges with neither a customer nor a receipt_email
disputed charges from emailless customers already disputed
Returns (state, detail). The order is deliberate: money already lost outranks
money about to be lost, which outranks a percentage.
"""
if disputed:
return ("disputed",
"%d charge(s) from customers with no email have been disputed. The "
"cardholder had no receipt to recognise the descriptor by."
% disputed)
if with_active_sub:
return ("unreachable",
"%d customer(s) with an active subscription have no email. When the "
"renewal fails, dunning has nowhere to send anything."
% with_active_sub)
if not total:
return ("clear", "no customers in the window")
ratio = missing / float(total)
if ratio >= WIDESPREAD_RATIO:
return ("widespread",
"%d of %d customers (%.0f%%) have no email. That is the signup path "
"behaving this way now, not an old backlog."
% (missing, total, ratio * 100))
if missing:
return ("gaps",
"%d of %d customers have no email and will receive no receipt"
% (missing, total))
if receiptless_charges:
return ("receiptless",
"every customer has an email, but %d charge(s) had neither a "
"customer nor a receipt_email: guest checkout sends no receipt"
% receiptless_charges)
return ("clear", "every customer in the window has an email")
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):
"""Yield objects from a paginated list endpoint until `limit` is reached."""
seen = 0
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:
return
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-customers", type=int, default=2000,
help="stop paginating customers after this many")
ap.add_argument("--max-charges", type=int, default=2000,
help="stop paginating charges after this many")
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})
total = missing = with_active_sub = 0
emailless = set()
sample = []
for cust in page_all(s, "/customers", args.max_customers, limit=100):
total += 1
# Both null and "" occur; a form that posts a blank field produces the
# second, and a check for None alone walks straight past it.
if (cust.get("email") or "").strip():
continue
missing += 1
cid = cust.get("id")
emailless.add(cid)
if len(sample) < 5:
sample.append(cid)
subs = get(s, "/subscriptions", customer=cid, status="active", limit=1)
if subs.get("data"):
with_active_sub += 1
receiptless = disputed = 0
for ch in page_all(s, "/charges", args.max_charges, limit=100):
if not ch.get("customer") and not ch.get("receipt_email"):
receiptless += 1
if ch.get("disputed") and ch.get("customer") in emailless:
disputed += 1
state, detail = verdict(missing, total, with_active_sub, receiptless, disputed)
line = "%-11s %s" % (state, detail)
if state == "clear":
log.info(line)
return 0
log.warning(line)
for cid in sample:
log.warning(" no email %s", cid)
log.warning(" backfill from your own user table:")
log.warning(" POST %s/customers/{id} -d email=user@example.com "
"-d name=\"Jenny Rosen\"", API)
if receiptless:
log.warning(" and for guest payments, set the address on the intent:")
log.warning(" POST %s/payment_intents -d receipt_email=user@example.com", API)
log.warning(" then confirm receipts are enabled at "
"https://dashboard.stripe.com/settings/emails")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe customers with no email, so no receipt or dunning notice is sent.
*
* Read only. GETs only, no writes: give this a RESTRICTED key with read access
* to Customers, Subscriptions and Charges. The repair is printed only.
*/
const API = 'https://api.stripe.com/v1';
export const WIDESPREAD_RATIO = 0.25; // a quarter is a signup path, not a backlog
/**
* Classify the email gap. Pure, so the ordering can be tested offline.
* The order is deliberate: money already lost outranks money about to be lost,
* which outranks a percentage. Returns [state, detail].
*/
export function verdict(missing, total, withActiveSub, receiptlessCharges, disputed) {
if (disputed) {
return ['disputed',
`${disputed} charge(s) from customers with no email have been disputed. ` +
'The cardholder had no receipt to recognise the descriptor by.'];
}
if (withActiveSub) {
return ['unreachable',
`${withActiveSub} customer(s) with an active subscription have no email. ` +
'When the renewal fails, dunning has nowhere to send anything.'];
}
if (!total) return ['clear', 'no customers in the window'];
const ratio = missing / total;
if (ratio >= WIDESPREAD_RATIO) {
return ['widespread',
`${missing} of ${total} customers (${(ratio * 100).toFixed(0)}%) have no ` +
'email. That is the signup path behaving this way now, not an old backlog.'];
}
if (missing) {
return ['gaps',
`${missing} of ${total} customers have no email and will receive no receipt`];
}
if (receiptlessCharges) {
return ['receiptless',
`every customer has an email, but ${receiptlessCharges} charge(s) had neither ` +
'a customer nor a receipt_email: guest checkout sends no receipt'];
}
return ['clear', 'every customer in the window has an email'];
}
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 pageAll(key, path, limit, params = {}) {
const out = [];
const q = { limit: 100, ...params };
for (;;) {
const page = await get(key, path, q);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more || out.length >= limit) return out;
q.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;
}
let total = 0;
let missing = 0;
let withActiveSub = 0;
const emailless = new Set();
const sample = [];
for (const cust of await pageAll(key, '/customers', 2000)) {
total += 1;
// Both null and '' occur; a form that posts a blank field produces the
// second, and a check for null alone walks straight past it.
if ((cust.email ?? '').trim()) continue;
missing += 1;
emailless.add(cust.id);
if (sample.length < 5) sample.push(cust.id);
const subs = await get(key, '/subscriptions', { customer: cust.id, status: 'active', limit: 1 });
if ((subs.data ?? []).length) withActiveSub += 1;
}
let receiptless = 0;
let disputed = 0;
for (const ch of await pageAll(key, '/charges', 2000)) {
if (!ch.customer && !ch.receipt_email) receiptless += 1;
if (ch.disputed && emailless.has(ch.customer)) disputed += 1;
}
const [state, detail] = verdict(missing, total, withActiveSub, receiptless, disputed);
const line = `${state.padEnd(11)} ${detail}`;
if (state === 'clear') { console.log(line); return; }
console.warn(line);
for (const cid of sample) console.warn(` no email ${cid}`);
console.warn(' backfill from your own user table:');
console.warn(` POST ${API}/customers/{id} -d email=user@example.com -d name="Jenny Rosen"`);
if (receiptless) {
console.warn(' and for guest payments, set the address on the intent:');
console.warn(` POST ${API}/payment_intents -d receipt_email=user@example.com`);
}
console.warn(' then confirm receipts are enabled at https://dashboard.stripe.com/settings/emails');
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 tests pin the ordering, because that is the only interesting decision in the function. A single disputed charge on an emailless customer has to beat a scary-looking percentage, an unreachable subscriber has to beat a tidy one, and the guest-checkout finding must never hide behind a customer list that happens to be clean.
from stripe_customers_missing_email import verdict
def test_a_full_customer_list_is_clear():
assert verdict(0, 500, 0, 0, 0)[0] == "clear"
def test_a_dispute_outranks_everything_else():
# One dispute, one missing email out of thousands. Still the top finding,
# because that money is already gone.
state, detail = verdict(1, 5000, 0, 0, 1)
assert state == "disputed"
assert "receipt" in detail
def test_an_active_subscriber_outranks_a_percentage():
state, detail = verdict(400, 500, 1, 0, 0)
assert state == "unreachable"
assert "dunning" in detail
def test_a_quarter_missing_is_the_signup_path():
state, detail = verdict(25, 100, 0, 0, 0)
assert state == "widespread"
assert "25%" in detail
def test_below_the_ratio_is_a_gap_not_a_path():
assert verdict(24, 100, 0, 0, 0)[0] == "gaps"
def test_guest_receipts_are_reported_on_a_clean_customer_list():
state, detail = verdict(0, 500, 0, 12, 0)
assert state == "receiptless"
assert "receipt_email" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-customers-missing-email.mjs';
test('a full customer list is clear', () => {
assert.equal(verdict(0, 500, 0, 0, 0)[0], 'clear');
});
test('a dispute outranks everything else', () => {
const [state, detail] = verdict(1, 5000, 0, 0, 1);
assert.equal(state, 'disputed');
assert.match(detail, /receipt/);
});
test('an active subscriber outranks a percentage', () => {
const [state, detail] = verdict(400, 500, 1, 0, 0);
assert.equal(state, 'unreachable');
assert.match(detail, /dunning/);
});
test('a quarter missing is the signup path', () => {
const [state, detail] = verdict(25, 100, 0, 0, 0);
assert.equal(state, 'widespread');
assert.match(detail, /25%/);
});
test('below the ratio is a gap not a path', () => {
assert.equal(verdict(24, 100, 0, 0, 0)[0], 'gaps');
});
test('guest receipts are reported on a clean customer list', () => {
const [state, detail] = verdict(0, 500, 0, 12, 0);
assert.equal(state, 'receiptless');
assert.match(detail, /receipt_email/);
});
FAQ
Where does Stripe send a receipt if the customer has no email?
Nowhere. It checks customer.email and the charge's receipt_email, and if both are empty it sends nothing and records nothing. There is no bounce and no error field, which is why this survives so long unnoticed.
Does a missing email really cause disputes?
It removes the thing that prevents them. The receipt is what lets a cardholder connect an unfamiliar statement descriptor to a purchase weeks later. Without it, the only way to find out what a charge was is to ask the bank, and asking the bank is a dispute.
Are receipts sent in test mode?
No. Stripe does not send email receipts for test-mode payments, so a flow that looks correct in test can be silently sending nothing in live. Verify against a live payment or the Dashboard's email settings rather than assuming.
What about one-off payments with no Customer at all?
Set receipt_email on the PaymentIntent. That is the guest-checkout equivalent of customer.email and it is the field integrations most often forget, because they already collected the address for their own order confirmation.
I set the email but customers still get no receipt.
Check the Dashboard email settings. Receipts for successful payments are a per-account toggle; with it off, a fully populated email field sends nothing. The field and the setting have to both be right.
Related field notes
- Duplicate customers share an email and split billing
- Dunning ran out of retries and no attempt is scheduled
- Disputes closed as lost were never actually contested
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 Customer object — Stripe API reference
- Update a customer — Stripe API reference
- Receipts — Stripe Docs
- The PaymentIntent 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.