Notifio sells a one-time licence for a desktop app from a UK base, to buyers who are mostly not in the UK. The product is a rental alert monitor, so the customers are wherever the rental market is miserable: Amsterdam, Berlin, Madrid, Dublin. The price is on notifio.app/pricing.
That shape of business runs into indirect tax early. A UK seller of automated digital services owes VAT in the customer's EU country from the very first sale. There is no threshold to hide under, and 27 registrations is not a thing one person does.
Stripe's Managed Payments solves it by becoming the legal seller of record: Stripe calculates, collects, files and remits the tax, and carries the liability. It costs 3.5% on top of normal card processing for those sessions, and the tax is withheld from the payout, so you only ever receive net.
The interesting engineering decision was not how to turn it on. It was deciding where to turn it off.
Global on is the expensive default
The obvious integration is one line: enable managed_payments on every Checkout Session and stop thinking about tax. That is wrong in two directions at once for us.
/**
* We enable it SELECTIVELY, only for countries where a UK-based seller of
* automated digital services genuinely owes foreign tax from the first sale.
* Everywhere else it stays OFF, so we neither pay the 3.5% fee nor charge tax
* that isn't due:
* - UK (GB): OFF while under the VAT-registration threshold. Turning MoR
* on for UK sales would make Stripe charge 20% UK VAT immediately, throwing
* away the small-business threshold benefit.
* - US: OFF. No US sales-tax nexus at our volume.
* - Anything not on the allowlist below: OFF.
* - EU-27: ON. VAT is due in the customer's country from the first sale.
*/
The UK case is the one worth staring at. We are under the VAT registration threshold, which means we are not obliged to charge UK VAT and do not. Switching merchant of record on for a UK buyer hands the sale to a seller of record who is registered, so 20% appears on a domestic sale that owed nothing. You pay 3.5% for the privilege of making your home market 20% more expensive.
Everything not on the allowlist is off, which means the list is the entire policy:
export const MANAGED_PAYMENTS_COUNTRIES: ReadonlySet<string> = 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",
]);
export function shouldEnableManagedPayments(country?: string | null): boolean {
if (!isManagedPaymentsEnabled()) return false;
if (!country) return false;
return MANAGED_PAYMENTS_COUNTRIES.has(country.trim().toUpperCase());
}
Norway, Switzerland, Australia, Canada, Japan, Singapore and several others each run their own digital services tax regime for foreign sellers, and each is a candidate. None of them goes in that set until an accountant has confirmed the treatment and Stripe's tax coverage actually includes it. An allowlist you are afraid to grow is doing its job.
The country you route on is not the country you tax on
The routing decision happens when the Checkout Session is created, before the customer has typed anything. All you have is the IP:
export function countryFromRequest(request: Request): string | null {
return request.headers.get("x-vercel-ip-country");
}
That is a guess, and people on VPNs exist. It would be a real problem if it decided the tax. It does not: once merchant of record is on, Stripe computes tax from the billing address entered on its own hosted checkout page. The IP only picks the coarse on/off routing.
Which leaves exactly one gap: a customer whose IP says Norway and whose billing address says Germany gets routed away from merchant of record, completes as an untaxed EU sale, and nothing complains. So the webhook looks for that shape after the fact:
export function isPossibleMoRMisroute(
country?: string | null,
taxAmount?: number | null
): boolean {
if (!country) return false;
if ((taxAmount ?? 0) > 0) return false;
return MANAGED_PAYMENTS_COUNTRIES.has(country.trim().toUpperCase());
}
An EU billing country with zero tax collected. It is review-only, and it does produce the occasional false positive, because a genuine B2B reverse-charge sale with a valid VAT number also shows zero tax. For a consumer product those are rare, and a flagged sale someone looks at beats an under-collection nobody ever sees.
The tax optimisation that can take your checkout down
This is the part I would not have predicted, and it is the reason the integration is not three lines.
If Managed Payments is not fully activated on the account, the terms of service have not been accepted in the Dashboard, or the tax code is not on the eligible list, Stripe rejects the session outright. Every session carrying the field. Which is every EU session. Which is most of our customers.
A tax optimisation that fails closed takes the whole European market offline, and it does it the moment you deploy, at a point where nothing in your test suite is talking to a live Stripe account in that state.
So the shared create wrapper retries once without merchant of record, and only for that specific cause:
export async function createCheckoutSession(
params: Stripe.Checkout.SessionCreateParams,
context: { managedPayments: boolean; country?: string | null }
): Promise<Stripe.Checkout.Session> {
try {
return await stripe.checkout.sessions.create(params);
} catch (error) {
if (context.managedPayments && isManagedPaymentsRejection(error)) {
Sentry.captureMessage("Managed Payments checkout rejected, retried without MoR", {
level: "error",
tags: { area: "checkout" },
extra: { country: context.country ?? null, stripe: describeStripeError(error) },
});
return await stripe.checkout.sessions.create(stripManagedPayments(params));
}
throw error;
}
}
The sale completes untaxed, the webhook safety net above flags it, and a human fixes the account setting. That is strictly better than a customer seeing a broken checkout, because a broken checkout is a customer you never hear from again.
The retry has to be narrow or it eats real errors
A retry like that is only safe if you can tell "Stripe objected to the merchant of record field" apart from "the card was declined". Get it wrong and every failure quietly becomes a second attempt with fewer fields, which is how a card error turns into a mystery.
export function isManagedPaymentsRejection(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const e = error as { type?: unknown; param?: unknown; message?: unknown };
// Only request-validation errors describe a bad or unsupported parameter.
// Card, rate limit, connection and generic API errors are not MoR routing issues.
if (e.type !== undefined && e.type !== "StripeInvalidRequestError") return false;
const param = typeof e.param === "string" ? e.param : "";
if (param === "managed_payments" || param.startsWith("managed_payments")) return true;
// Message matching is looser, so only trust it for a real invalid-request error.
if (e.type !== "StripeInvalidRequestError") return false;
const message = typeof e.message === "string" ? e.message : "";
return /managed[_\s]?payments/i.test(message);
}
Two deliberate choices in there. The type gate comes first, so no amount of string matching can rescue a card error into the retry path. And the detection is duck-typed rather than instanceof Stripe.errors.StripeInvalidRequestError, so it survives however the SDK happens to surface the error and stays unit testable without constructing SDK internals.
Stripping the fields is likewise not a guess, because we know exactly which keys we added: top-level managed_payments, price_data.tax_behavior, and price_data.product_data.tax_code. It returns a shallow clone and never mutates the caller's params, since the caller may still want to log what it originally sent.
Inclusive or exclusive is a product decision wearing a config flag
export const MANAGED_PAYMENTS_TAX_BEHAVIOR: "inclusive" | "exclusive" = "exclusive";
inclusive means the advertised price already contains the tax and Stripe extracts it, so VAT comes out of your margin and a German customer and a UK customer pay the same number. exclusive means it is added at checkout, so the German customer pays local VAT on top and everyone else pays the flat price.
We chose exclusive, and the honest caveat is that EU and UK consumer law generally expects business-to-consumer prices to be shown tax inclusive. A price that rises at the last step is a real trade-off, disclosed in the footnote on the pricing page. If you are copying this decision rather than the code, that is the sentence to take to an accountant.
What the integration actually looks like at the call site
The whole point of routing being a policy in one module is that the checkout routes stay boring:
const mp = managedPaymentsCheckout(countryFromRequest(request));
await createCheckoutSession({
...mp.session,
line_items: [{
price_data: {
currency: PRICE_CURRENCY,
product_data: { name, ...mp.productData },
unit_amount,
...mp.priceData,
},
quantity: 1,
}],
}, { managedPayments: mp.enabled, country });
When merchant of record does not apply, every one of those objects is empty, so the spreads are no-ops and the non-MoR behaviour is byte for byte what it was before. That property is worth designing for deliberately: it means the feature can be switched off for the whole world with one environment variable and you know exactly what you get back.
Notifio is the app all of this is charging for: a desktop monitor that watches rental search pages and tells you the second something new is posted, rather than waiting for a portal's email. There is a per-site breakdown at notifio.app/alerts, the download is at /download, and I have written before about why we stopped letting Stripe convert our price, which is the display side of the same problem this post is the tax side of.
Top comments (0)