Diagnostic Stripe
prices left at tax_behavior unspecified break tax math
Somebody turns on automatic tax and the first invoice refuses to take a line item. The price is fine, the product is fine, the amount is right, and Stripe will not compute tax on it, because tax_behavior is unspecified and it genuinely does not know whether the 20 EUR on that price already has VAT in it or not.
GET /v1/prices?active=true&limit=100 and flag every price whose tax_behavior is unspecified. That is the default, so on an account that never set one it will be all of them.
Then find out which of those are live: GET /v1/subscriptions?price={price}&status=active&limit=100. A dormant price is a five-minute fix; a price with four hundred subscriptions on it is a migration, because tax_behavior can be set once while it is unspecified and never changed after that.
The problem in plain words
unspecified is not a placeholder for "no tax". It is Stripe recording that nobody has said which side of the amount the tax sits on, and that is not a question the tax engine can guess its way around. Twenty euros inclusive of 20% VAT and twenty euros plus 20% VAT are different prices, different revenue and different invoices, and the difference is exactly the amount somebody is arguing about later.
The consequence lands in two different places at two different times. Adding an item with tax_behavior=unspecified to an invoice with automatic tax fails outright, which at least happens loudly. Everything else is quiet: charges that compute tax against the wrong base, or against no base, on prices that have been billing happily for two years. The loud failure is the lucky one.
Why it happens
The default is the unsafe value, and nothing warns about it. A price created without tax_behavior gets unspecified. Nothing in the create call fails, nothing in the Dashboard flags it, and the price bills correctly for as long as nobody is calculating tax on it. The field only becomes load-bearing later, when automatic tax is switched on by somebody else.
The setting is effectively one-way. tax_behavior can be set while it is still unspecified, and once it is inclusive or exclusive it cannot be changed again. That is a deliberate protection — flipping it silently reprices everything on it — but it also means the fix has to be right first time, and that a price you would rather not touch has to be replaced instead of edited.
Live prices cannot be repriced under a subscription. Replacing the price on an active subscription is a real migration with proration decisions attached. So the number of active subscriptions is not a detail: it is the difference between a config change and a change-managed piece of work, and it is why this check counts them.
A missing product tax_code hides behind the same symptom. Tax behavior says how to apply tax; the product's tax code says which rate applies. A price with correct behavior on a product with no tax code falls back to your account default, which is right for some catalogues and quietly wrong for anything mixing digital services, physical goods or training.
The fix, as a flow
The script counts the active subscriptions on every unspecified price, because that count is the whole difference between a setting you change this afternoon and a priced migration.
How to fix it
List active prices and read tax_behavior
GET /v1/prices?active=true&limit=100, paginated. Archived prices do not matter unless a subscription still points at one, which is rare and worth checking separately if it does happen.
Count active subscriptions per flagged price
GET /v1/subscriptions?price={price}&status=active&limit=100. This is the number that decides the repair path, so do it per price rather than sampling. Zero means the price can be corrected in place; anything else means a migration.
Find out whether automatic tax is actually in use
GET /v1/subscriptions?automatic_tax[enabled]=true&limit=1. If anything comes back, unspecified prices are not a latent risk but an active one: those line items cannot be added to an automatic tax invoice at all.
Check the products for a tax code while you are there
GET /v1/products?active=true&limit=100 and look for tax_code at null. It is the same conversation with the same person and it is the other half of getting the rate right, so it belongs in the same report.
Set the default before creating anything new
Stripe's tax settings carry a default tax behavior that new prices inherit. Setting it is what stops the list growing again while you work through the existing one, and it is a Dashboard setting rather than a deploy.
Fix dormant prices in place, migrate the live ones
A price with no active subscriptions can have tax_behavior set directly, once. For a live price, create a replacement with the same product, amount, currency and interval plus an explicit tax_behavior, move subscriptions onto it with an explicit proration decision, then archive the old one so nothing new lands on it.
How to check it worked
Re-run the script. Every active price should carry an explicit behavior, and every product a tax code.
python3 stripe_price_tax_behavior.py
# ready price_123 exclusive, product tax code txcd_10000000
The full code
Four GETs and no writes — a restricted key with read access to Products, Prices and Subscriptions is enough, and is what you should give it. The classifier is pure and takes the behavior, the number of active subscriptions, the product's tax code and whether automatic tax is in use anywhere, because those four together decide whether this is a Dashboard change this afternoon or a priced migration.
"""Report Stripe prices left at tax_behavior unspecified, ranked by how live they are.
Read only. Four GETs, no writes: give this a RESTRICTED key with read access to
Products, Prices and Subscriptions. 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_price_tax_behavior")
API = "https://api.stripe.com/v1"
def verdict(tax_behavior, active_subscriptions, product_tax_code, automatic_tax_in_use):
"""Classify one active price. Pure, so the rules can be tested offline.
`tax_behavior` is the raw field, `active_subscriptions` how many live
subscriptions bill this price, `product_tax_code` the parent product's
tax_code or None, and `automatic_tax_in_use` whether any subscription on the
account has automatic tax on. Returns (state, detail).
"""
if tax_behavior == "unspecified":
if automatic_tax_in_use:
return ("blocking",
"unspecified while automatic tax is in use on this account: "
"line items on this price cannot be added to an automatic tax "
"invoice. %d active subscription(s)." % active_subscriptions)
if active_subscriptions:
return ("live",
"unspecified with %d active subscription(s). Setting it means "
"a replacement price and a migration, not an edit."
% active_subscriptions)
return ("dormant",
"unspecified with no active subscriptions. Set it now, while it is "
"still settable and nothing is billing on it.")
if not product_tax_code:
return ("no-tax-code",
"%s, but the product carries no tax_code, so the rate falls back to "
"the account default." % tax_behavior)
return ("ready", "%s, product tax code %s" % (tax_behavior, product_tax_code))
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 product_tax_codes(session):
"""Map product id to tax_code. The tax code can be a string id or an object."""
codes = {}
for prod in paginate(session, "/products", active="true"):
code = prod.get("tax_code")
if isinstance(code, dict):
code = code.get("id")
codes[prod["id"]] = code
return codes
def active_subscription_count(session, price_id, cap):
"""Count active subscriptions billing one price, stopping at `cap`.
The exact number stops mattering above a handful: any non-zero count means a
replacement price and a migration rather than an edit.
"""
count = 0
for _sub in paginate(session, "/subscriptions", price=price_id, status="active"):
count += 1
if count >= cap:
break
return count
def automatic_tax_in_use(session):
page = get(session, "/subscriptions", limit=1,
**{"automatic_tax[enabled]": "true"})
return bool(page.get("data"))
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--subscription-cap", type=int, default=200,
help="stop counting subscriptions per price at 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})
codes = product_tax_codes(s)
auto_tax = automatic_tax_in_use(s)
if auto_tax:
log.info("automatic tax is enabled on at least one subscription, so an "
"unspecified price is an active fault rather than a latent one")
findings = 0
total = 0
for price in paginate(s, "/prices", active="true"):
total += 1
behavior = price.get("tax_behavior")
product = price.get("product")
if isinstance(product, dict):
product = product.get("id")
subs = (active_subscription_count(s, price["id"], args.subscription_cap)
if behavior == "unspecified" else 0)
state, detail = verdict(behavior, subs, codes.get(product), auto_tax)
line = "%-12s %s %s" % (state, price["id"], detail)
if state == "ready":
log.info(line)
continue
findings += 1
log.warning(line)
if state == "dormant":
log.warning(" set tax_behavior on this price while it is still "
"unspecified; the value is permanent once set")
elif state in ("live", "blocking"):
log.warning(" create a replacement price on product %s with the same "
"amount, currency and interval plus an explicit "
"tax_behavior, migrate the subscriptions with an explicit "
"proration decision, then archive %s",
product, price["id"])
else:
log.warning(" set a tax_code on product %s so the rate stops falling "
"back to the account default", product)
log.info("%d active price(s), %d needing attention", total, findings)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe prices left at tax_behavior unspecified, ranked by how live they are.
*
* Read only. Four GETs, no writes: give this a RESTRICTED key with read access to
* Products, Prices and Subscriptions. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
/**
* Classify one active price. Pure, so the rules can be tested offline.
* Returns [state, detail].
*/
export function verdict(taxBehavior, activeSubscriptions, productTaxCode, automaticTaxInUse) {
if (taxBehavior === 'unspecified') {
if (automaticTaxInUse) {
return ['blocking',
'unspecified while automatic tax is in use on this account: line items on ' +
'this price cannot be added to an automatic tax invoice. ' +
`${activeSubscriptions} active subscription(s).`];
}
if (activeSubscriptions) {
return ['live',
`unspecified with ${activeSubscriptions} active subscription(s). Setting it ` +
'means a replacement price and a migration, not an edit.'];
}
return ['dormant',
'unspecified with no active subscriptions. Set it now, while it is still ' +
'settable and nothing is billing on it.'];
}
if (!productTaxCode) {
return ['no-tax-code',
`${taxBehavior}, but the product carries no tax_code, so the rate falls back ` +
'to the account default.'];
}
return ['ready', `${taxBehavior}, product tax code ${productTaxCode}`];
}
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;
}
}
/** Map product id to tax_code. The tax code can be a string id or an object. */
export async function productTaxCodes(key) {
const codes = new Map();
for await (const prod of paginate(key, '/products', { active: 'true' })) {
const code = prod.tax_code;
codes.set(prod.id, typeof code === 'object' && code !== null ? code.id : code);
}
return codes;
}
async function activeSubscriptionCount(key, priceId, cap) {
let count = 0;
for await (const _sub of paginate(key, '/subscriptions',
{ price: priceId, status: 'active' })) {
count += 1;
if (count >= cap) break;
}
return count;
}
async function automaticTaxInUse(key) {
const page = await get(key, '/subscriptions',
{ limit: 1, 'automatic_tax[enabled]': 'true' });
return (page.data ?? []).length > 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 cap = 200;
const codes = await productTaxCodes(key);
const autoTax = await automaticTaxInUse(key);
if (autoTax) {
console.log('automatic tax is enabled on at least one subscription, so an ' +
'unspecified price is an active fault rather than a latent one');
}
let findings = 0;
let total = 0;
for await (const price of paginate(key, '/prices', { active: 'true' })) {
total += 1;
const behavior = price.tax_behavior;
const product = typeof price.product === 'object' && price.product !== null
? price.product.id : price.product;
const subs = behavior === 'unspecified'
? await activeSubscriptionCount(key, price.id, cap) : 0;
const [state, detail] = verdict(behavior, subs, codes.get(product), autoTax);
const line = `${state.padEnd(12)} ${price.id} ${detail}`;
if (state === 'ready') { console.log(line); continue; }
findings += 1;
console.warn(line);
if (state === 'dormant') {
console.warn(' set tax_behavior on this price while it is still unspecified; ' +
'the value is permanent once set');
} else if (state === 'live' || state === 'blocking') {
console.warn(` create a replacement price on product ${product} with the same ` +
'amount, currency and interval plus an explicit tax_behavior, migrate the ' +
`subscriptions with an explicit proration decision, then archive ${price.id}`);
} else {
console.warn(` set a tax_code on product ${product} so the rate stops falling ` +
'back to the account default');
}
}
console.log(`${total} active price(s), ${findings} needing attention`);
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 pin the escalation, because the same unspecified value means three different amounts of work depending on what else is true. They also cover the price whose behavior is correct and whose product has no tax code, which is the finding people skip past on the way to the interesting ones.
from stripe_price_tax_behavior import verdict
def test_an_explicit_behavior_with_a_tax_code_is_ready():
state, detail = verdict("exclusive", 0, "txcd_10000000", True)
assert state == "ready"
assert "txcd_10000000" in detail
def test_a_dormant_unspecified_price_can_be_fixed_in_place():
state, detail = verdict("unspecified", 0, "txcd_10000000", False)
assert state == "dormant"
assert "still settable" in detail
def test_subscriptions_turn_the_fix_into_a_migration():
state, detail = verdict("unspecified", 412, "txcd_10000000", False)
assert state == "live"
assert "412 active subscription(s)" in detail
def test_automatic_tax_makes_it_an_active_fault():
# The same field, the same value, but now line items are rejected outright.
state, detail = verdict("unspecified", 0, "txcd_10000000", True)
assert state == "blocking"
assert "cannot be added" in detail
def test_a_correct_behavior_on_a_product_with_no_tax_code_is_still_flagged():
state, detail = verdict("inclusive", 0, None, False)
assert state == "no-tax-code"
assert "account default" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { verdict } from './stripe-price-tax-behavior.mjs';
test('an explicit behavior with a tax code is ready', () => {
const [state, detail] = verdict('exclusive', 0, 'txcd_10000000', true);
assert.equal(state, 'ready');
assert.ok(detail.includes('txcd_10000000'));
});
test('a dormant unspecified price can be fixed in place', () => {
const [state, detail] = verdict('unspecified', 0, 'txcd_10000000', false);
assert.equal(state, 'dormant');
assert.ok(detail.includes('still settable'));
});
test('subscriptions turn the fix into a migration', () => {
const [state, detail] = verdict('unspecified', 412, 'txcd_10000000', false);
assert.equal(state, 'live');
assert.ok(detail.includes('412 active subscription(s)'));
});
test('automatic tax makes it an active fault', () => {
const [state, detail] = verdict('unspecified', 0, 'txcd_10000000', true);
assert.equal(state, 'blocking');
assert.ok(detail.includes('cannot be added'));
});
test('a correct behavior on a product with no tax code is still flagged', () => {
const [state, detail] = verdict('inclusive', 0, null, false);
assert.equal(state, 'no-tax-code');
assert.ok(detail.includes('account default'));
});
FAQ
What does tax_behavior=unspecified actually mean?
That nobody has told Stripe whether the price already contains tax. It is the default on every price created without the field. Inclusive means the tax is inside the amount, exclusive means it is added on top, and unspecified means the tax engine has no basis to compute either.
Why can I not just change tax_behavior on the price?
You can, once, while it is still unspecified. After it is inclusive or exclusive it is fixed, because changing it would silently reprice everything billing on it. That is why the check counts active subscriptions: it decides whether you are editing a price or replacing one.
What breaks if I leave it alone?
Line items on an unspecified price cannot be added to an invoice with automatic tax, so that fails loudly. Everything short of that is quiet: tax computed against the wrong base, or not computed at all, on prices that otherwise bill perfectly.
Why does the script also look at product tax codes?
Because behavior and tax code are the two halves of getting the number right. Behavior says how tax relates to the amount; the tax code says which rate applies. A product with no tax_code falls back to your account default, which is fine for a single-category catalogue and wrong the moment it mixes digital services with anything else.
How do I stop new prices arriving unspecified?
Set the default tax behavior in Stripe's tax settings so new prices inherit it. Doing that first is what keeps the list from growing while you migrate the prices already on it.
Related field notes
- No tax registrations while invoicing abroad
- automatic_tax disabled while selling abroad
- Pending invoice items that never reach an invoice
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 price object — Stripe API reference
- The invoice object — Stripe API reference
- Stripe Tax and invoicing — Stripe Docs
- The product 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.