Diagnostic Stripe
automatic_tax is off on every invoice while selling abroad
Stripe Tax was switched on eighteen months ago, the registrations were filed, and everyone moved on. The invoices going to Germany, France and the UK still carry no VAT, because enabling Tax in the Dashboard did nothing to the subscriptions the API had already created. Nothing errors. The totals are simply wrong, and the liability compounds every month.
Count how many active subscriptions have tax off: GET /v1/subscriptions?status=active and read automatic_tax.enabled on each, or let Stripe filter with automatic_tax[enabled]=false. Then take the countries you are actually invoicing from GET /v1/invoices?status=paid and customer_address.country on the untaxed ones.
Tax off plus a single domestic country may well be correct. Tax off plus invoices to the EU, the UK, Australia or Canada is the combination that compounds, because those are jurisdictions where a remote seller acquires an obligation and the invoices you already sent cannot be reissued with tax on them.
The problem in plain words
Every other failure in this section makes something visibly stop. This one makes everything keep working with the wrong number on it. The subscription bills, the card is charged, the invoice is delivered, the customer pays, and the tax line is absent because Stripe was never asked to compute one.
The cost is that it is retroactive. A webhook outage is bounded by the outage. Missing tax is bounded by however long the integration has been running, and the money was never collected from the customer, so the liability comes out of margin that has already been recognised, spent and reported. By the time an audit or a diligence process finds it, the exposure is the whole trading history in that jurisdiction rather than the current month.
Why it happens
The field defaults to false, and defaults do not announce themselves. automatic_tax.enabled is off unless the create call passes it. Nothing in the API response is missing, nothing is flagged, and the invoice looks complete because a total with no tax on it is still a valid total.
Enabling Stripe Tax in the Dashboard is not retroactive and not universal. It affects invoices created through the Dashboard. Subscriptions your code created keep whatever they were created with, forever, because automatic_tax lives on the subscription and not on the account. The team that turned Tax on genuinely believes it is on.
Nobody is watching the field they would need to watch. The failure has no owner: engineering sees payments succeeding, finance sees revenue arriving, and the person who would notice a missing VAT line is looking at a spreadsheet exported from a system that also has no tax column.
Enabling it is necessary and not sufficient. With automatic_tax[enabled]=true but no active registration for a jurisdiction, Stripe calculates zero and the invoices look exactly as wrong as before. And with tax on but an unresolvable customer address the calculation reports requires_location_inputs, which can hold the invoice in draft instead. Turning it on is the first step of three, not the whole repair.
The fix, as a flow
The script counts active subscriptions with tax switched off, then takes the countries from the invoices those subscriptions actually produced, because the countries are what turn a missing field into an exposure.
How to fix it
Count active subscriptions with automatic_tax off, against the total
The ratio is the diagnosis. All of them means it was never set on any create path. Some of them means the code was fixed at some point and the older subscriptions were never backfilled — which is the more dangerous version, because the recent invoices in the Dashboard all look right.
Take the countries from paid invoices, not from customer records
customer_address.country on the invoice is where the invoice was actually billed to. A customer's current address can be edited after the fact; the invoice keeps what was used. Collect the distinct set across untaxed paid invoices and you have the exposure map.
Treat a missing country as its own finding
If customer_address is null across the board, you are not selling tax free — you are unable to determine tax at all, and enabling Stripe Tax will produce requires_location_inputs rather than a tax line. Collecting the address is the prerequisite, not an afterthought.
Register before you enable
Stripe calculates tax only where you have an active registration. Turning automatic_tax on without registrations produces zero tax and a false sense that the problem is solved. Get the registrations in place, then enable, then verify one real invoice has a tax line.
Fix every create path, then backfill
automatic_tax[enabled]=true belongs on subscription creation and on Checkout Session creation, and both need doing or the next signup reintroduces the problem. Only then backfill the existing subscriptions, so the two sets meet in the middle instead of chasing each other.
Take the historical exposure to an accountant, not to a script
This script tells you which jurisdictions and how many subscriptions. What to do about invoices already issued is a question about voluntary disclosure and local rules, and it is not one an API can answer.
How to check it worked
Re-run the script after the backfill. Every active subscription should have tax enabled, whatever the countries look like.
python3 stripe_automatic_tax_off.py
# on automatic_tax is enabled on all 412 active subscription(s)
The full code
Two paginated GETs and no writes — a restricted key with read access to Subscriptions and Invoices is enough. The classifier is pure and takes the two counts plus the country list, because the same missing field is a note for the backlog on a single-country account and a compounding liability on one selling into the EU, and only the countries tell the two apart.
"""Report Stripe subscriptions billing without automatic_tax while invoicing abroad.
Read only. Two paginated GETs and no writes: give this a RESTRICTED key with read
access to Subscriptions and Invoices. The repair is printed, never performed.
This is a configuration check, not tax advice. It tells you where you are
invoicing with tax calculation switched off; whether that is a liability is a
question for an accountant.
"""
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_automatic_tax_off")
API = "https://api.stripe.com/v1"
# Jurisdictions where a remote seller most commonly acquires a collection
# obligation. Deliberately not exhaustive: the point is to raise the question for
# the obvious cases, not to decide the answer.
REGISTRATION_COUNTRIES = frozenset("""
AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE
GB NO CH AU NZ CA JP SG AE ZA IN
""".split())
def verdict(off_count, total_count, countries):
"""Classify the account. Pure, so the rules can be tested without a network.
`off_count` is active subscriptions with automatic_tax.enabled false,
`total_count` is all active subscriptions, and `countries` is the distinct set
of customer_address.country values seen on untaxed paid invoices. Returns
(state, detail).
"""
if not total_count:
return ("empty", "no active subscriptions to check")
if not off_count:
return ("on", "automatic_tax is enabled on all %d active subscription(s)"
% total_count)
seen = sorted({(c or "").upper() for c in (countries or []) if c})
if not seen:
return ("unknown",
"%d of %d active subscription(s) have automatic_tax off, and no "
"untaxed invoice carries customer_address.country: the exposure "
"cannot be judged, and Stripe could not compute tax either"
% (off_count, total_count))
exposed = [c for c in seen if c in REGISTRATION_COUNTRIES]
if exposed:
where = ", ".join(exposed)
if off_count >= total_count:
return ("exposed",
"automatic_tax is off on all %d active subscription(s), and "
"untaxed invoices went to %s" % (total_count, where))
return ("partial",
"%d of %d active subscription(s) have automatic_tax off: the "
"create path was fixed and the older ones never backfilled. "
"Untaxed invoices went to %s" % (off_count, total_count, where))
if len(seen) > 1:
return ("multi_country",
"%d of %d off, and untaxed invoices span %d countries (%s)"
% (off_count, total_count, len(seen), ", ".join(seen)))
return ("domestic",
"%d of %d off, but every untaxed invoice is billed to %s. Check that "
"against your registrations rather than assuming it is wrong"
% (off_count, total_count, seen[0]))
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):
out = []
params = dict(params, limit=100)
while True:
page = get(session, path, **params)
data = page.get("data", [])
out.extend(data)
if not data or not page.get("has_more") or len(out) >= limit:
break
params["starting_after"] = data[-1]["id"]
return out
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-subscriptions", type=int, default=2000,
help="stop paginating active subscriptions after this many")
ap.add_argument("--max-invoices", type=int, default=1000,
help="how many recent paid invoices to sample for countries")
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})
subs = page_all(s, "/subscriptions", args.max_subscriptions, status="active")
total = len(subs)
off = [x for x in subs if not (x.get("automatic_tax") or {}).get("enabled")]
countries = []
for inv in page_all(s, "/invoices", args.max_invoices, status="paid"):
if (inv.get("automatic_tax") or {}).get("enabled"):
continue
addr = inv.get("customer_address") or {}
if addr.get("country"):
countries.append(addr["country"])
state, detail = verdict(len(off), total, countries)
line = "%-13s %s" % (state, detail)
if state in ("on", "empty"):
log.info(line)
return 0
log.warning(line)
log.warning(" register first: Stripe calculates zero where you have no active "
"registration, which looks identical to tax being off")
log.warning(" then set it on every create path: POST %s/subscriptions and "
"POST %s/checkout/sessions both take automatic_tax[enabled]=true",
API, API)
log.warning(" then backfill: POST %s/subscriptions/<sub> "
"automatic_tax[enabled]=true", API)
for sub in off[:10]:
log.warning(" %s", sub.get("id", "<no id>"))
if len(off) > 10:
log.warning(" ... and %d more", len(off) - 10)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe subscriptions billing without automatic_tax while invoicing abroad.
*
* Read only. Two paginated GETs and no writes: give this a RESTRICTED key with
* read access to Subscriptions and Invoices. The repair is printed, never performed.
*
* This is a configuration check, not tax advice.
*/
const API = 'https://api.stripe.com/v1';
// Jurisdictions where a remote seller most commonly acquires a collection
// obligation. Deliberately not exhaustive: the point is to raise the question for
// the obvious cases, not to decide the answer.
export const REGISTRATION_COUNTRIES = new Set([
'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU',
'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES',
'SE', 'GB', 'NO', 'CH', 'AU', 'NZ', 'CA', 'JP', 'SG', 'AE', 'ZA', 'IN',
]);
/**
* Classify the account. Pure, so the rules can be tested without a network.
* `countries` is the distinct set of customer_address.country values seen on
* untaxed paid invoices.
*/
export function verdict(offCount, totalCount, countries) {
if (!totalCount) return ['empty', 'no active subscriptions to check'];
if (!offCount) {
return ['on', `automatic_tax is enabled on all ${totalCount} active subscription(s)`];
}
const seen = [...new Set((countries ?? []).filter(Boolean).map((c) => c.toUpperCase()))].sort();
if (seen.length === 0) {
return ['unknown',
`${offCount} of ${totalCount} active subscription(s) have automatic_tax off, ` +
'and no untaxed invoice carries customer_address.country: the exposure ' +
'cannot be judged, and Stripe could not compute tax either'];
}
const exposed = seen.filter((c) => REGISTRATION_COUNTRIES.has(c));
if (exposed.length) {
const where = exposed.join(', ');
if (offCount >= totalCount) {
return ['exposed',
`automatic_tax is off on all ${totalCount} active subscription(s), and ` +
`untaxed invoices went to ${where}`];
}
return ['partial',
`${offCount} of ${totalCount} active subscription(s) have automatic_tax off: ` +
'the create path was fixed and the older ones never backfilled. ' +
`Untaxed invoices went to ${where}`];
}
if (seen.length > 1) {
return ['multi_country',
`${offCount} of ${totalCount} off, and untaxed invoices span ${seen.length} ` +
`countries (${seen.join(', ')})`];
}
return ['domestic',
`${offCount} of ${totalCount} off, but every untaxed invoice is billed to ` +
`${seen[0]}. Check that against your registrations rather than assuming it is wrong`];
}
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 = { ...params, limit: 100 };
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) break;
q.starting_after = data[data.length - 1].id;
}
return out;
}
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 subs = await pageAll(key, '/subscriptions', 2000, { status: 'active' });
const off = subs.filter((x) => !(x.automatic_tax?.enabled));
const countries = [];
for (const inv of await pageAll(key, '/invoices', 1000, { status: 'paid' })) {
if (inv.automatic_tax?.enabled) continue;
const country = inv.customer_address?.country;
if (country) countries.push(country);
}
const [state, detail] = verdict(off.length, subs.length, countries);
const line = `${state.padEnd(13)} ${detail}`;
if (state === 'on' || state === 'empty') { console.log(line); return; }
console.warn(line);
console.warn(' register first: Stripe calculates zero where you have no active ' +
'registration, which looks identical to tax being off');
console.warn(` then set it on every create path: POST ${API}/subscriptions and ` +
`POST ${API}/checkout/sessions both take automatic_tax[enabled]=true`);
console.warn(` then backfill: POST ${API}/subscriptions/<sub> automatic_tax[enabled]=true`);
for (const sub of off.slice(0, 10)) console.warn(` ${sub.id ?? '<no id>'}`);
if (off.length > 10) console.warn(` ... and ${off.length - 10} more`);
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 hold the line between reporting a fact and giving tax advice. Tax off in one domestic market gets a state that says check your registrations; tax off with invoices to Germany gets one that says this compounds. The third case is the one people forget: tax off and no address anywhere, where the answer is that nothing can be judged yet.
from stripe_automatic_tax_off import verdict
def test_all_enabled_is_clear():
state, detail = verdict(0, 412, [])
assert state == "on"
assert "412" in detail
def test_off_everywhere_with_eu_invoices_is_the_loud_case():
state, detail = verdict(300, 300, ["DE", "FR", "de"])
assert state == "exposed"
assert "DE, FR" in detail
def test_a_fixed_create_path_with_no_backfill_reads_as_partial():
# The dangerous version: new invoices look right in the Dashboard while the
# older subscriptions keep billing untaxed.
state, detail = verdict(40, 300, ["GB"])
assert state == "partial"
assert "never backfilled" in detail
def test_one_domestic_country_is_a_question_not_a_verdict():
state, detail = verdict(50, 50, ["US"])
assert state == "domestic"
assert "registrations" in detail
def test_no_country_anywhere_cannot_be_judged():
state, detail = verdict(50, 50, [None, ""])
assert state == "unknown"
assert "cannot be judged" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-automatic-tax-off.mjs';
test('all enabled is clear', () => {
const [state, detail] = verdict(0, 412, []);
assert.equal(state, 'on');
assert.match(detail, /412/);
});
test('off everywhere with EU invoices is the loud case', () => {
const [state, detail] = verdict(300, 300, ['DE', 'FR', 'de']);
assert.equal(state, 'exposed');
assert.match(detail, /DE, FR/);
});
test('a fixed create path with no backfill reads as partial', () => {
const [state, detail] = verdict(40, 300, ['GB']);
assert.equal(state, 'partial');
assert.match(detail, /never backfilled/);
});
test('one domestic country is a question, not a verdict', () => {
const [state, detail] = verdict(50, 50, ['US']);
assert.equal(state, 'domestic');
assert.match(detail, /registrations/);
});
test('no country anywhere cannot be judged', () => {
const [state, detail] = verdict(50, 50, [null, '']);
assert.equal(state, 'unknown');
assert.match(detail, /cannot be judged/);
});
FAQ
Does enabling Stripe Tax in the Dashboard fix existing subscriptions?
No. automatic_tax lives on the subscription and on the invoice, not on the account. Turning Tax on in the Dashboard affects what the Dashboard creates from then on; a subscription your API created with the field unset keeps billing untaxed until something updates that subscription.
I enabled automatic_tax and the tax is still zero. Why?
Almost always a missing registration. Stripe calculates tax only for jurisdictions where you have an active registration recorded, so with the flag on and no registration the result is a correctly computed zero. The other cause is an unresolvable customer address, which shows up as automatic_tax.status of requires_location_inputs.
Why read the country off the invoice rather than the customer?
Because the invoice records the address that was actually used at the time. A customer record can be corrected later, which quietly rewrites the history you are trying to measure. customer_address on a paid invoice is what you billed.
Can I turn this on for everyone with one API call?
No, it is per subscription, and doing it in a loop is a write against a live billing account that changes what your customers are charged next cycle. Fix the create paths first so nothing new is added to the pile, then work through the existing subscriptions deliberately, in batches you can check.
Is a missing tax line something I can just fix going forward?
Going forward, yes. What to do about invoices already issued without tax is a question about local rules and voluntary disclosure, and it belongs with an accountant. The script's job is to tell you which jurisdictions are involved and how many subscriptions are affected, which is the information that conversation needs.
Related field notes
- Draft invoices sit for months and never finalize
- Open invoices are weeks past due_date and nobody chases
- Checkout Sessions carry no ID that maps back to your order
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.
- Stripe Tax and invoicing — Stripe Docs
- Set up Stripe Tax — Stripe Docs
- List all subscriptions — Stripe API reference
- 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.