Diagnostic Stripe
customers have no address, so tax and SCA exemptions fail
An invoice will not finalize. The error is customer_tax_location_invalid, which sounds like a Stripe Tax configuration problem and is not: the registrations are fine, the rates are fine, and the customer simply has no address on file. Your own database has their shipping address. Stripe's copy of the Customer has address: null, and it has been that way since the integration was written.
Page GET /v1/customers and classify each address three ways, not two. null is the obvious case. An address object present but missing country, or missing postal_code, fails tax resolution in exactly the same way and does not look empty in the Dashboard.
Then weight it. Page GET /v1/subscriptions?status=active&expand[]=data.customer and count the subscribed customers whose address is incomplete — those are the ones with a finalization due every cycle. Confirm the damage with GET /v1/invoices/search?query=last_finalization_error_code:'customer_tax_location_invalid'.
The problem in plain words
The address is the field everybody assumes Stripe already has. It was collected at checkout, it is in the orders table, it is printed on the packing slip. It was just never written to the Customer object, because the code that creates the Customer runs before the address form and nothing ever goes back to fill it in.
What makes it expensive is that three unrelated systems quietly depend on it and each degrades differently. Stripe Tax does not degrade at all — it hard-fails at finalization, so the invoice simply does not go out. AVS has nothing to check, so address_postal_code_check comes back null on every card and one of the cheapest fraud signals you have is permanently off. And several SCA exemptions are calculated with the customer's location as an input, so European authorisation rates sag a few points in a way nobody attributes to a missing field.
Why it happens
The Customer is created before the address exists. The usual sequence is create the cus_ record at signup or at the start of checkout, then collect the address on a later step and store it locally. Stripe's copy stays null forever. The application is not broken by this because it reads the address from its own tables; only Stripe's own features are working blind.
Checkout does not collect it unless you ask. billing_address_collection defaults to auto, which means Stripe collects an address only when the payment method needs one. Card payments frequently do not, so a Checkout integration can run for years collecting nothing. The Payment Element behaves the same way: the billing fields have to be turned on.
A partial address is invisible. An address with line1 and city filled in looks complete on the Customer page. Tax needs the country; AVS needs the postal code. Any check that only counts address == null reports a clean account while a third of it is unusable, which is the failure mode of every version of this check written in a hurry.
Nothing fails at the moment of the mistake. Creating an addressless Customer is a 200. Charging one is a 200. Subscribing one is a 200. The first negative signal is an invoice that will not finalize, months later, on a customer who has already paid you several times.
The fix, as a flow
The script classifies each address four ways rather than two, because an address object with a street and a city but no country reads as filled in everywhere except the one place that resolves a tax location.
How to fix it
Classify every customer's address, not just the null ones
Three failure states, one healthy one. address absent; address present with no country; address present with no postal_code. Treat an address object whose every field is null as absent — Stripe returns that shape and it is not a partial address, it is an empty one.
Weight the count by who is subscribed
Ten thousand addressless customers from a free tier is a data-quality note. Forty addressless customers with active subscriptions is a finalization failure every month. GET /v1/subscriptions?status=active&limit=100&expand[]=data.customer gives you the second number in the same pass, without a per-customer request.
Confirm it against invoices that already failed
GET /v1/invoices/search?query=last_finalization_error_code:'customer_tax_location_invalid' turns the theory into a list. If that returns rows, this is not a risk assessment any more and the ticket should say so.
Check whether AVS has ever run
GET /v1/payment_methods?customer={id}&type=card and read card.checks.address_postal_code_check. null means the check was never performed, because nothing was submitted to compare against. A wall of nulls is the fraud signal you have been paying for and not receiving.
Close the collection hole before backfilling
Backfilling from your own users table fixes today's population and nothing else. Set billing_address_collection=required on Checkout Sessions, or configure the Payment Element to collect billing details, so tomorrow's customers arrive complete. Where a location is genuinely unavailable, customer.tax.ip_address gives Stripe Tax a fallback signal, though it is weaker than a real address.
How to check it worked
Re-run the script. Every customer with an active subscription should classify as complete, and the tax-failure search should return nothing.
python3 stripe_customer_address.py
# clear 1,204 customer(s), 0 with an incomplete address
The full code
Two paginated GETs and one search, all reads — a restricted key with read access to Customers, Subscriptions and Invoices is enough, and is what you should give it. Two pure functions do the thinking: one classifies a single address, because the partial cases are where every hand-written version of this check goes wrong, and one rolls the counts up into a verdict.
"""Report Stripe customers whose address cannot satisfy Tax, AVS or SCA.
Read only. Paginated GETs and one search, no writes: give this a RESTRICTED key
with read access to Customers, Subscriptions and Invoices. 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_customer_address")
API = "https://api.stripe.com/v1"
WIDESPREAD = 0.25 # share of incomplete addresses that means the collection path is wrong
def address_state(customer):
"""Classify one customer's address. Pure, so it can be tested without a network.
Returns one of "missing", "no_country", "no_postal_code" or "complete".
Stripe returns `address` either as null or as an object whose fields are
individually null. An object with nothing in it is an absent address, not a
partial one, and collapsing the two hides the customers who look filled in on
the Dashboard but resolve to no location at all.
"""
addr = customer.get("address")
if not isinstance(addr, dict):
return "missing"
if not any(v for v in addr.values()):
return "missing"
if not addr.get("country"):
return "no_country"
if not addr.get("postal_code"):
return "no_postal_code"
return "complete"
def verdict(total, incomplete, subscribed_incomplete, tax_failures):
"""Roll the counts up into one state. Pure.
Ordered deliberately: an invoice that has already refused to finalize outranks
any percentage, and a subscribed customer outranks a free-tier one, because
only the subscribed ones have a finalization due every cycle.
"""
if not total:
return ("unknown", "no customers read; check the key and the mode it belongs to")
if tax_failures:
return ("failing",
"%d invoice(s) already refused to finalize with "
"customer_tax_location_invalid. This is not a risk, it is unsent "
"revenue." % tax_failures)
if subscribed_incomplete:
return ("billing",
"%d subscribed customer(s) have an incomplete address. Each renewal "
"is a finalization that can fail." % subscribed_incomplete)
share = incomplete / float(total)
if share >= WIDESPREAD:
return ("widespread",
"%d of %d customer(s), %.0f%%, have an incomplete address. At that "
"share the collection path is wrong, not the data." % (
incomplete, total, share * 100))
if incomplete:
return ("residue",
"%d of %d customer(s) have an incomplete address. Backfill them and "
"close the collection hole." % (incomplete, total))
return ("clear", "%d customer(s), 0 with an incomplete address" % 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):
"""Yield every object from a paginated list endpoint, up to `limit`."""
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 tax_failure_count(session):
"""Count invoices that failed to finalize on the customer's location.
Search is a separate index and is not enabled on every account, so a failure
here is reported and treated as no evidence rather than as zero evidence.
"""
try:
page = get(session, "/invoices/search",
query="last_finalization_error_code:'customer_tax_location_invalid'",
limit=100)
except requests.HTTPError as exc:
log.info("invoice search unavailable (%s); skipping the confirmation step", exc)
return 0
return len(page.get("data", []))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-customers", type=int, default=5000,
help="stop paginating customers 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})
buckets = {"missing": 0, "no_country": 0, "no_postal_code": 0, "complete": 0}
examples = {}
total = 0
for cus in page_all(s, "/customers", args.max_customers):
state = address_state(cus)
buckets[state] += 1
total += 1
if state != "complete":
examples.setdefault(state, cus["id"])
subscribed_incomplete = 0
for sub in page_all(s, "/subscriptions", 1000, status="active",
**{"expand[]": "data.customer"}):
cus = sub.get("customer")
if isinstance(cus, dict) and address_state(cus) != "complete":
subscribed_incomplete += 1
incomplete = total - buckets["complete"]
state, detail = verdict(total, incomplete, subscribed_incomplete,
tax_failure_count(s))
line = "%-11s %s" % (state, detail)
if state in ("clear", "unknown"):
log.info(line)
return 0
log.warning(line)
log.warning(" %d absent, %d without a country, %d without a postal code",
buckets["missing"], buckets["no_country"], buckets["no_postal_code"])
for bucket, cus_id in sorted(examples.items()):
log.warning(" example %-14s %s", bucket, cus_id)
log.warning(" repair one customer:")
log.warning(" POST %s/customers/{id} -d \"address[line1]=...\" "
"-d \"address[city]=...\" -d \"address[postal_code]=...\" "
"-d \"address[country]=US\"", API)
log.warning(" stop creating more: set billing_address_collection=required on "
"Checkout Sessions, or collect billing details in the Payment Element")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe customers whose address cannot satisfy Tax, AVS or SCA.
*
* Read only. Paginated GETs and one search, no writes: give this a RESTRICTED
* key with read access to Customers, Subscriptions and Invoices. The repair is
* printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Share of incomplete addresses that means the collection path is wrong.
export const WIDESPREAD = 0.25;
/**
* Classify one customer's address. Pure, so it can be tested without a network.
* Returns 'missing', 'no_country', 'no_postal_code' or 'complete'.
*
* Stripe returns `address` either as null or as an object whose fields are
* individually null. An object with nothing in it is an absent address, not a
* partial one.
*/
export function addressState(customer) {
const addr = customer.address;
if (!addr || typeof addr !== 'object') return 'missing';
if (!Object.values(addr).some((v) => v)) return 'missing';
if (!addr.country) return 'no_country';
if (!addr.postal_code) return 'no_postal_code';
return 'complete';
}
/**
* Roll the counts up into one state. Pure.
* An invoice that has already refused to finalize outranks any percentage.
*/
export function verdict(total, incomplete, subscribedIncomplete, taxFailures) {
if (!total) {
return ['unknown', 'no customers read; check the key and the mode it belongs to'];
}
if (taxFailures) {
return ['failing',
`${taxFailures} invoice(s) already refused to finalize with ` +
'customer_tax_location_invalid. This is not a risk, it is unsent revenue.'];
}
if (subscribedIncomplete) {
return ['billing',
`${subscribedIncomplete} subscribed customer(s) have an incomplete address. ` +
'Each renewal is a finalization that can fail.'];
}
const share = incomplete / total;
if (share >= WIDESPREAD) {
return ['widespread',
`${incomplete} of ${total} customer(s), ${Math.round(share * 100)}%, have an ` +
'incomplete address. At that share the collection path is wrong, not the data.'];
}
if (incomplete) {
return ['residue',
`${incomplete} of ${total} customer(s) have an incomplete address. Backfill ` +
'them and close the collection hole.'];
}
return ['clear', `${total} customer(s), 0 with an incomplete address`];
}
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;
}
}
async function taxFailureCount(key) {
try {
const page = await get(key, '/invoices/search', {
query: "last_finalization_error_code:'customer_tax_location_invalid'",
limit: 100,
});
return (page.data ?? []).length;
} catch (err) {
console.log(`invoice search unavailable (${err.message}); skipping the confirmation step`);
return 0;
}
}
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 buckets = { missing: 0, no_country: 0, no_postal_code: 0, complete: 0 };
const examples = new Map();
let total = 0;
for await (const cus of pageAll(key, '/customers', 5000)) {
const state = addressState(cus);
buckets[state] += 1;
total += 1;
if (state !== 'complete' && !examples.has(state)) examples.set(state, cus.id);
}
let subscribedIncomplete = 0;
for await (const sub of pageAll(key, '/subscriptions', 1000,
{ status: 'active', 'expand[]': 'data.customer' })) {
const cus = sub.customer;
if (cus && typeof cus === 'object' && addressState(cus) !== 'complete') {
subscribedIncomplete += 1;
}
}
const incomplete = total - buckets.complete;
const [state, detail] = verdict(total, incomplete, subscribedIncomplete,
await taxFailureCount(key));
const line = `${state.padEnd(11)} ${detail}`;
if (state === 'clear' || state === 'unknown') { console.log(line); return; }
console.warn(line);
console.warn(` ${buckets.missing} absent, ${buckets.no_country} without a country, ` +
`${buckets.no_postal_code} without a postal code`);
for (const [bucket, id] of [...examples].sort()) {
console.warn(` example ${bucket.padEnd(14)} ${id}`);
}
console.warn(' repair one customer:');
console.warn(` POST ${API}/customers/{id} -d "address[line1]=..." ` +
'-d "address[city]=..." -d "address[postal_code]=..." -d "address[country]=US"');
console.warn(' stop creating more: set billing_address_collection=required on ' +
'Checkout Sessions, or collect billing details in the Payment Element');
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 are mostly about the shapes that look complete and are not: an address object whose every field is null, and one with a street and a city but no country. Both render as a filled-in address in the Dashboard, both fail tax resolution, and a check that counts only address == null will call the account clean.
from stripe_customer_address import address_state, verdict
def test_absent_address_is_missing():
assert address_state({"id": "cus_1"}) == "missing"
assert address_state({"address": None}) == "missing"
def test_address_object_with_every_field_null_is_missing_not_partial():
# Stripe returns this shape. It renders as an address and resolves to nothing.
empty = {"line1": None, "line2": None, "city": None,
"state": None, "postal_code": None, "country": None}
assert address_state({"address": empty}) == "missing"
def test_street_and_city_without_a_country_still_fails_tax():
addr = {"line1": "12 Rue de Rivoli", "city": "Paris", "postal_code": "75001"}
assert address_state({"address": addr}) == "no_country"
def test_country_without_a_postal_code_fails_avs():
assert address_state({"address": {"country": "US", "city": "Denver"}}) == "no_postal_code"
def test_a_complete_address_is_complete():
addr = {"line1": "1 Main St", "city": "Denver", "postal_code": "80202", "country": "US"}
assert address_state({"address": addr}) == "complete"
def test_a_failed_finalization_outranks_any_percentage():
state, detail = verdict(1000, 1, 0, 3)
assert state == "failing"
assert "3" in detail
def test_subscribed_customers_outrank_the_overall_share():
# 4 of 1000 is a rounding error until you notice all four are billed monthly.
state, _ = verdict(1000, 4, 4, 0)
assert state == "billing"
def test_a_quarter_incomplete_is_a_collection_problem():
assert verdict(1000, 249, 0, 0)[0] == "residue"
assert verdict(1000, 250, 0, 0)[0] == "widespread"
def test_no_customers_is_not_silently_clear():
assert verdict(0, 0, 0, 0)[0] == "unknown"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { addressState, verdict } from './stripe-customer-address.mjs';
test('absent address is missing', () => {
assert.equal(addressState({ id: 'cus_1' }), 'missing');
assert.equal(addressState({ address: null }), 'missing');
});
test('address object with every field null is missing not partial', () => {
const empty = {
line1: null, line2: null, city: null,
state: null, postal_code: null, country: null,
};
assert.equal(addressState({ address: empty }), 'missing');
});
test('street and city without a country still fails tax', () => {
const addr = { line1: '12 Rue de Rivoli', city: 'Paris', postal_code: '75001' };
assert.equal(addressState({ address: addr }), 'no_country');
});
test('country without a postal code fails avs', () => {
assert.equal(addressState({ address: { country: 'US', city: 'Denver' } }),
'no_postal_code');
});
test('a complete address is complete', () => {
const addr = { line1: '1 Main St', city: 'Denver', postal_code: '80202', country: 'US' };
assert.equal(addressState({ address: addr }), 'complete');
});
test('a failed finalization outranks any percentage', () => {
const [state, detail] = verdict(1000, 1, 0, 3);
assert.equal(state, 'failing');
assert.match(detail, /3/);
});
test('subscribed customers outrank the overall share', () => {
assert.equal(verdict(1000, 4, 4, 0)[0], 'billing');
});
test('a quarter incomplete is a collection problem', () => {
assert.equal(verdict(1000, 249, 0, 0)[0], 'residue');
assert.equal(verdict(1000, 250, 0, 0)[0], 'widespread');
});
test('no customers is not silently clear', () => {
assert.equal(verdict(0, 0, 0, 0)[0], 'unknown');
});
FAQ
What exactly does Stripe Tax need from the customer?
A location it can resolve, which in practice means at minimum address.country, and postal_code wherever the tax varies below the country level, as it does in the US and Canada. Without one it raises customer_tax_location_invalid at finalization rather than falling back to a default rate, so the invoice does not go out at all.
Some countries genuinely have no postal codes. Does that break the check?
It flags them, which is why the script reports no_postal_code as its own bucket rather than lumping it in with the rest. Hong Kong, the UAE and a handful of others have no postcode system; a customer there with a country set is fine for tax, and only the AVS postal check is unavailable. Read the buckets before you write the backfill.
Does an address on the PaymentMethod count?
Not for tax. billing_details.address lives on the PaymentMethod and is what AVS compares against; customer.address is what Stripe Tax resolves. They are separate fields and setting one does not populate the other, which is why an account can have good AVS coverage and still fail every finalization.
Will adding addresses actually improve authorisation rates?
It removes one reason for a decline rather than guaranteeing an approval. AVS results and the customer's location feed both the issuer's decision and several SCA exemption calculations, so the effect shows up as a few points across a large population, not as a fixed transaction that used to fail.
Can I detect this without a live secret key?
Yes. A restricted key with read access to Customers, Subscriptions and Invoices covers every call in this script, and it cannot move money if it leaks.
Related field notes
- Customers have no email, so Stripe sends no receipts
- Charges captured after AVS and CVC verification failed
- No tax registrations while invoicing many countries
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
- Error codes — Stripe Docs
- Strong Customer Authentication — Stripe Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.