Skip to content

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.

Read-only key Python and Node.js Tests included
A lot of brown boxes that are open
Photo by Luke Heibert on Unsplash
The short answer

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.

Price createdtax_behavioromittedDefaults tounspecifiedno warninganywhereBills fine foryearsno tax beingcomputedAutomatic taxonline item rejectedValue nowpermanentreplace the price
The default value is the unsafe one, and it bills perfectly until somebody switches automatic tax on.

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.

GET /v1/pricesactive=true, plus subscriptioncountsExplicit, product codedreadyExplicit, no tax coderate falls back to defaultUnspecified, dormantset it now, onceUnspecified, livereplacement price, migrationUnspecified, tax online items rejected today
The same unspecified value means three different amounts of work depending on what is billing on it.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_price_tax_behavior.py
"""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())
stripe-price-tax-behavior.mjs
/**
 * 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.

test_stripe_price_tax_behavior.py
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
stripe-price-tax-behavior.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.