Diagnostic Stripe
no tax registrations while invoicing many countries
Stripe Tax is enabled. automatic_tax.status reads complete on every invoice. The tax line is zero on all of them, including the ones going to Germany and the UK, and the reason given is not_collecting. Nothing is failing. Stripe calculated the tax correctly, and the correct answer given the registrations on file is nothing.
Compare two lists. GET /v1/tax/registrations?status=active&limit=100 gives the countries where Stripe is allowed to collect for you. GET /v1/invoices?status=paid&created[gte]=<a year ago> gives, through customer_address.country, the countries you have actually been billing.
Every country in the second list and not the first is untaxed revenue accruing towards a threshold you are not tracking. Also check status=expired: a registration with expires_at in the past stops collection on that date and reports the same reassuring complete afterwards.
The problem in plain words
This is the tax failure that survives review, because every signal an engineer would look at is green. Stripe Tax is switched on. The API says the calculation completed. There is no error code, no stuck invoice, no unusual status. The one field that says what happened, taxability_reason on the line, says not_collecting, and that reads as a normal condition rather than a warning.
The exposure builds in the wrong direction too. The problem is not that a customer was undercharged; it is that you owe the tax anyway once the threshold is crossed, out of margin you already recognised as revenue. A year of German sales with no VAT is not a year of missing line items, it is a liability plus interest that arrives as a surprise, and the invoices cannot be reissued retroactively without going back to every customer.
Why it happens
A registration is what authorises collection, not the Tax toggle. Enabling Stripe Tax turns on calculation. Calculation consults your registrations, finds none for the buyer's jurisdiction, and returns zero. That zero is a successful calculation, which is why the status field cannot help you and the amount has to be read instead.
Thresholds accrue silently and in the background. Economic nexus rules mean the obligation starts when you cross a revenue or transaction level in a jurisdiction, whether or not anybody registered. Stripe's monitoring emails only fire above a revenue floor and only in live mode, so a business under that floor gets no warning at all while it approaches one.
Registrations expire, and expiry is not an error. A registration can carry an expires_at. Past that date it stops authorising collection, and every invoice afterwards behaves exactly like one for a country you never registered in. Nothing changes in the API responses except the amount.
The billed-country list lives in the invoices, not in your head. Teams reliably know their top three markets and reliably miss the long tail: two customers in Norway, one in Australia, a handful in Canada. Those are the rows that show up in a diligence review, and the only honest way to enumerate them is to read the paid invoices.
The fix, as a flow
The script compares the countries you are registered in against the countries your paid invoices went to, because a correct calculation of zero looks exactly like a working tax setup.
How to fix it
Collect the countries you are registered in
GET /v1/tax/registrations?status=active&limit=100 and take country from each. In the US, registration is per state: read country_options.us.state as well, because a single US registration authorises collection in one state and not in the other forty-nine.
Collect the countries you have actually billed
GET /v1/invoices?status=paid&limit=100&created[gte]= a year back, paginated, tallying distinct customer_address.country along with the count and the total per country. Paid invoices, not all invoices: drafts and voids describe intentions rather than revenue.
Subtract, and rank what is left by revenue
Every billed country absent from the registration set is a finding. Rank by revenue rather than by invoice count, because the jurisdiction that matters first is the one closest to its threshold, and thresholds are almost always monetary.
Check for expired registrations separately
GET /v1/tax/registrations?status=expired. A country you used to be registered in is a different and more urgent finding than one you never registered in: it means collection stopped on a specific date, which is the date you can hand to an accountant.
Register, then record the registration in Stripe
The registration itself happens with the tax authority. Recording it in Stripe with POST /v1/tax/registrations and a country plus the country options for that regime is what makes calculation start returning a number. Both steps are required and only the second one is visible from the API, which is why this check only ever sees the second.
Turn threshold monitoring on rather than reading this monthly
Stripe tracks nexus thresholds in the Dashboard under Tax, and can email you as you approach them. Set the notification preferences once. This script tells you where you stand today; the monitoring tells you before the next one arrives.
How to check it worked
Re-run the script after the registrations are recorded. Every country you bill should be either registered or explicitly below its threshold and known about.
python3 stripe_tax_registrations.py
# covered DE registered, 214 paid invoice(s) in the last year
The full code
Three GETs and no writes — a restricted key with read access to Tax and Invoices is enough, and is what you should give it. The classifier is pure and takes one country plus the registered and expired sets and the revenue billed there, because an expired registration and one that never existed produce identical invoices and need different phone calls.
"""Report countries you invoice into with no active Stripe Tax registration.
Read only. Three GETs, no writes: give this a RESTRICTED key with read access to
Tax 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 time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_tax_registrations")
API = "https://api.stripe.com/v1"
# Roughly where Stripe's own threshold monitoring starts emailing, in minor units
# of the invoice currency. Currencies are not converted: this is a triage rank,
# not an accounting figure, and it is deliberately a constant you can move.
WATCH_MINOR = 1000000
def verdict(country, registered, expired, revenue_minor, invoice_count):
"""Classify one billed country. Pure, so the rules can be tested offline.
`registered` and `expired` are sets of country codes from
/v1/tax/registrations. Returns (state, detail).
"""
where = "%d paid invoice(s), %d minor unit(s) billed" % (invoice_count,
revenue_minor)
if country in registered:
return ("covered", "registered, %s" % where)
if country in expired:
return ("lapsed",
"a registration existed and has expired, so collection stopped on "
"a known date. %s since." % where)
if revenue_minor >= WATCH_MINOR:
return ("exposed",
"no registration and %s. This is the size at which a threshold is "
"the likely explanation for the letter." % where)
return ("unregistered", "no registration, %s" % where)
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")
if r.status_code == 403:
raise SystemExit("403 from Stripe: the restricted key lacks read access to "
+ path)
r.raise_for_status()
return r.json()
def paginate(session, path, **params):
params = dict(params, limit=params.get("limit", 100))
while True:
page = get(session, path, **params)
data = page.get("data", [])
for row in data:
yield row
if not data or not page.get("has_more"):
return
params["starting_after"] = data[-1]["id"]
def registration_countries(session, status):
"""Country codes with a registration in the given status.
US registrations are per state, so a US row is recorded as US-CA rather than
US: one state registration does not authorise collection in the other 49.
"""
out = set()
for reg in paginate(session, "/tax/registrations", status=status):
country = (reg.get("country") or "").upper()
if not country:
continue
state = ((reg.get("country_options") or {}).get("us") or {}).get("state")
out.add("%s-%s" % (country, state.upper()) if country == "US" and state
else country)
return out
def billed_countries(session, since):
"""Tally paid invoices by the customer's country. Returns {code: (count, minor)}."""
tally = {}
for inv in paginate(session, "/invoices", status="paid",
**{"created[gte]": since}):
addr = inv.get("customer_address") or {}
country = (addr.get("country") or "").upper()
if not country:
continue
key = country
if country == "US" and addr.get("state"):
key = "US-%s" % addr["state"].upper()
count, amount = tally.get(key, (0, 0))
tally[key] = (count + 1, amount + (inv.get("amount_paid") or 0))
return tally
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=365,
help="how far back to read paid invoices")
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})
registered = registration_countries(s, "active")
expired = registration_countries(s, "expired")
since = int(time.time()) - args.days * 86400
tally = billed_countries(s, since)
if not tally:
log.info("no paid invoices with a customer country in the last %d days",
args.days)
return 0
findings = 0
for country, (count, amount) in sorted(tally.items(), key=lambda kv: -kv[1][1]):
state, detail = verdict(country, registered, expired, amount, count)
line = "%-12s %-6s %s" % (state, country, detail)
if state == "covered":
log.info(line)
continue
findings += 1
log.warning(line)
if findings:
log.warning("register with each authority, then record it so calculation "
"starts returning a number rather than a correct zero")
log.warning(" GET %s/tax/registrations?status=active "
"(the list this check compares against)", API)
log.warning(" Dashboard: Tax > Locations shows threshold progress per "
"jurisdiction, which this API cannot")
log.info("%d billed country/state(s), %d without an active registration",
len(tally), findings)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report countries you invoice into with no active Stripe Tax registration.
*
* Read only. Three GETs, no writes: give this a RESTRICTED key with read access
* to Tax and Invoices. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// Roughly where Stripe's own threshold monitoring starts emailing, in minor units
// of the invoice currency. Currencies are not converted: this is a triage rank,
// not an accounting figure.
export const WATCH_MINOR = 1000000;
/**
* Classify one billed country. Pure, so the rules can be tested offline.
* `registered` and `expired` are Sets of country codes. Returns [state, detail].
*/
export function verdict(country, registered, expired, revenueMinor, invoiceCount) {
const where = `${invoiceCount} paid invoice(s), ${revenueMinor} minor unit(s) billed`;
if (registered.has(country)) return ['covered', `registered, ${where}`];
if (expired.has(country)) {
return ['lapsed',
'a registration existed and has expired, so collection stopped on a known ' +
`date. ${where} since.`];
}
if (revenueMinor >= WATCH_MINOR) {
return ['exposed',
`no registration and ${where}. This is the size at which a threshold is the ` +
'likely explanation for the letter.'];
}
return ['unregistered', `no registration, ${where}`];
}
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.status === 403) {
throw new Error(`403 from Stripe: the restricted key lacks read access to ${path}`);
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
async function* paginate(key, path, params = {}) {
const p = { limit: 100, ...params };
for (;;) {
const page = await get(key, path, p);
const data = page.data ?? [];
for (const row of data) yield row;
if (data.length === 0 || !page.has_more) return;
p.starting_after = data[data.length - 1].id;
}
}
/**
* Country codes with a registration in the given status. US registrations are per
* state, so a US row is recorded as US-CA: one state does not cover the other 49.
*/
export async function registrationCountries(key, status) {
const out = new Set();
for await (const reg of paginate(key, '/tax/registrations', { status })) {
const country = (reg.country ?? '').toUpperCase();
if (!country) continue;
const state = reg.country_options?.us?.state;
out.add(country === 'US' && state ? `US-${state.toUpperCase()}` : country);
}
return out;
}
/** Tally paid invoices by the customer's country. Returns a Map of code to totals. */
export async function billedCountries(key, since) {
const tally = new Map();
for await (const inv of paginate(key, '/invoices',
{ status: 'paid', 'created[gte]': since })) {
const addr = inv.customer_address ?? {};
const country = (addr.country ?? '').toUpperCase();
if (!country) continue;
const k = country === 'US' && addr.state
? `US-${addr.state.toUpperCase()}` : country;
const prev = tally.get(k) ?? { count: 0, amount: 0 };
tally.set(k, { count: prev.count + 1, amount: prev.amount + (inv.amount_paid ?? 0) });
}
return tally;
}
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 = 365;
const registered = await registrationCountries(key, 'active');
const expired = await registrationCountries(key, 'expired');
const since = Math.floor(Date.now() / 1000) - days * 86400;
const tally = await billedCountries(key, since);
if (tally.size === 0) {
console.log(`no paid invoices with a customer country in the last ${days} days`);
return;
}
let findings = 0;
const ordered = [...tally.entries()].sort((a, b) => b[1].amount - a[1].amount);
for (const [country, { count, amount }] of ordered) {
const [state, detail] = verdict(country, registered, expired, amount, count);
const line = `${state.padEnd(12)} ${country.padEnd(6)} ${detail}`;
if (state === 'covered') { console.log(line); continue; }
findings += 1;
console.warn(line);
}
if (findings) {
console.warn('register with each authority, then record it so calculation ' +
'starts returning a number rather than a correct zero');
console.warn(` GET ${API}/tax/registrations?status=active ` +
'(the list this check compares against)');
console.warn(' Dashboard: Tax > Locations shows threshold progress per ' +
'jurisdiction, which this API cannot');
}
console.log(`${tally.size} billed country/state(s), ${findings} without an active registration`);
if (findings) 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 fix the three distinctions this check exists to make: registered against not, never-registered against expired, and small against large enough that a threshold is the likely explanation. The US case gets its own test, because a registration in one state covering the whole country is the assumption that costs the most.
from stripe_tax_registrations import verdict
REGISTERED = {"DE", "GB", "US-CA"}
EXPIRED = {"FR"}
def test_a_registered_country_is_covered():
state, detail = verdict("DE", REGISTERED, EXPIRED, 4200000, 214)
assert state == "covered"
assert "214 paid invoice(s)" in detail
def test_an_expired_registration_is_its_own_finding():
# Identical invoices to the never-registered case, different phone call.
state, detail = verdict("FR", REGISTERED, EXPIRED, 50000, 9)
assert state == "lapsed"
assert "expired" in detail
def test_small_revenue_in_a_new_country_is_still_reported():
assert verdict("NO", REGISTERED, EXPIRED, 12000, 3)[0] == "unregistered"
def test_large_revenue_escalates_to_exposed():
assert verdict("AU", REGISTERED, EXPIRED, 999999, 40)[0] == "unregistered"
assert verdict("AU", REGISTERED, EXPIRED, 1000000, 40)[0] == "exposed"
def test_one_us_state_does_not_cover_another():
assert verdict("US-CA", REGISTERED, EXPIRED, 800000, 60)[0] == "covered"
assert verdict("US-NY", REGISTERED, EXPIRED, 800000, 60)[0] == "unregistered"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-tax-registrations.mjs';
const REGISTERED = new Set(['DE', 'GB', 'US-CA']);
const EXPIRED = new Set(['FR']);
test('a registered country is covered', () => {
const [state, detail] = verdict('DE', REGISTERED, EXPIRED, 4200000, 214);
assert.equal(state, 'covered');
assert.ok(detail.includes('214 paid invoice(s)'));
});
test('an expired registration is its own finding', () => {
const [state, detail] = verdict('FR', REGISTERED, EXPIRED, 50000, 9);
assert.equal(state, 'lapsed');
assert.ok(detail.includes('expired'));
});
test('small revenue in a new country is still reported', () => {
assert.equal(verdict('NO', REGISTERED, EXPIRED, 12000, 3)[0], 'unregistered');
});
test('large revenue escalates to exposed', () => {
assert.equal(verdict('AU', REGISTERED, EXPIRED, 999999, 40)[0], 'unregistered');
assert.equal(verdict('AU', REGISTERED, EXPIRED, 1000000, 40)[0], 'exposed');
});
test('one US state does not cover another', () => {
assert.equal(verdict('US-CA', REGISTERED, EXPIRED, 800000, 60)[0], 'covered');
assert.equal(verdict('US-NY', REGISTERED, EXPIRED, 800000, 60)[0], 'unregistered');
});
FAQ
Stripe Tax is enabled and the status says complete. Why is the tax zero?
Because calculation consults your registrations and you have none in that jurisdiction. Zero is the correct answer to the question Stripe was asked, so the status is complete and no error appears. The line's taxability_reason says not_collecting, which is the only field that distinguishes this from a genuinely tax-free sale.
Does enabling Stripe Tax create registrations for me?
No. Registration happens with the tax authority in each jurisdiction. Recording it in Stripe afterwards is what lets calculation return a number. Only the second half is visible through the API, which is why this check can tell you what Stripe knows and not whether you are actually registered somewhere.
Why compare against paid invoices rather than customers?
Because a customer record with an address may never have bought anything, and a country with revenue is the one that accrues towards a threshold. Paid invoices, read through customer_address.country, are the closest thing the API has to a list of the jurisdictions you actually sell into.
Why does the script treat US states separately?
Because US registration is per state and country_options.us.state names which one. Treating a California registration as covering the country is the mistake this check is most useful for catching, so a US row is tracked as US-CA rather than US.
What about registrations that have expired?
They are reported as their own state. An expired registration means collection stopped on a specific date and everything invoiced since then behaves like an unregistered country. Knowing the date is what makes the cleanup finite, so it is worth separating from countries you never registered in.
Related field notes
- automatic_tax disabled while selling abroad
- Prices left at tax_behavior unspecified
- Draft invoices that never finalized
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.
- List tax registrations — Stripe API reference
- Zero tax amounts — Stripe Docs
- Monitor your tax obligations — Stripe Docs
- The invoice 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.