Shopify VAT validation: from B2B sign-up to tax-exempt checkout
Add real VAT number validation to a Shopify store, mark cross-border B2B customers tax-exempt, and store the audit trail. Built around Shopify B2B, Customer Accounts, and the Admin GraphQL API.
TL;DR
- Capture the VAT number on customer registration or in a B2B account form.
- Validate it server-side with vatverify, then set
Customer.taxExempt = trueon cross-border B2B customers with a valid VAT number. - Persist the validation result on a customer metafield so the audit trail survives a re-validation cycle.
The Shopify VAT landscape
Shopify has improved on B2B compliance significantly since 2023. Three things matter for VAT validation:
- Shopify B2B is the merchant offering for wholesale and B2B stores. It supports company profiles, price lists, payment terms, and per-customer tax exemptions. Most of what follows assumes B2B is enabled, but the same pattern works for a single Shopify Plus store with a B2B section.
- Customer Accounts (new) are the modern customer-account experience replacing the legacy account UI. They can carry custom fields and metaobjects.
- Shopify Tax automatically calculates EU VAT at checkout based on the buyer's address, but it does not validate VAT numbers or decide reverse-charge. A tax-exempt flag has to come from somewhere; that's where vatverify slots in.
If you're still on the legacy checkout, parts of this pattern (theme.liquid customizations, the deprecated Script Editor) work but are increasingly fragile. The modern path is a Shopify app with the Admin GraphQL API.
Architecture overview
The validation flow has four moving parts:
- A form: collects the customer's company name, country, and VAT number. Lives on the customer registration page or a dedicated B2B onboarding flow.
- A Shopify app: receives form submission via webhook or app proxy, calls vatverify's
/v1/validateendpoint, and writes the result. - Shopify Admin API: updates
Customer.taxExemptand customer metafields based on the validation result. - A re-validation job: on a schedule (monthly is a sensible default), re-checks each B2B customer's VAT number to catch deregistrations.
Shopify charges Shopify Tax on every order by default. Setting taxExempt = true is what tells Shopify to skip VAT collection on that customer's orders.
Capturing the VAT number
For a Shopify B2B store, the cleanest place to capture the VAT number is the company-profile screen. For a non-B2B Shopify Plus store, add a custom field to the customer registration form using customer-accounts metaobjects.
A minimal form field rendered in a Customer Account UI extension:
import {
reactExtension,
TextField,
BlockStack,
Banner,
useApi,
} from '@shopify/ui-extensions-react/customer-account';
export default reactExtension('customer-account.profile.block.render', () => <VatField />);
function VatField() {
const { i18n } = useApi();
return (
<BlockStack>
<TextField
label={i18n.translate('vat.label', { defaultValue: 'EU VAT number' })}
name="vatNumber"
helpText="Enter your VAT number including the country prefix, e.g. DE123456789."
/>
<Banner status="info">
We validate cross-border B2B VAT numbers against VIES, HMRC, BFS, and BRREG.
</Banner>
</BlockStack>
);
}The field submission triggers a customer-update webhook, which the validation app subscribes to.
The validation app
The app is a server-side Shopify app receiving the customers/update webhook. The handler:
- Reads the new VAT number off the customer payload.
- Calls vatverify's
/v1/validateendpoint. - Writes the result to a customer metafield namespaced as
vat.validation. - Sets
taxExempt = trueif the validation succeeds and the customer's country differs from the shop's home country (cross-border B2B is the reverse-charge case).
import { authenticate } from '../shopify.server';
import { Vatverify } from '@vatverify/node';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
export async function action({ request }: { request: Request }) {
const { shop, payload, admin } = await authenticate.webhook(request);
const customer = payload as { id: string; email: string; metafields?: Array<{ namespace: string; key: string; value: string }> };
const vatNumber = readMetafield(customer.metafields, 'custom', 'vat_number');
if (!vatNumber) return new Response();
const result = await vat.validate({ vat_number: vatNumber });
const valid = result.data?.valid === true;
const buyerCountry = result.data?.country ?? null;
const sellerCountry = process.env.SHOP_HOME_COUNTRY!;
const isCrossBorderB2B = valid && buyerCountry && buyerCountry !== sellerCountry;
await admin.graphql(
`#graphql
mutation customerUpdate($input: CustomerInput!) {
customerUpdate(input: $input) { userErrors { field message } }
}`,
{
variables: {
input: {
id: `gid://shopify/Customer/${customer.id}`,
taxExempt: !!isCrossBorderB2B,
metafields: [
{
namespace: 'vat',
key: 'validation',
type: 'json',
value: JSON.stringify({
vat_number: vatNumber,
valid,
country: buyerCountry,
checked_at: new Date().toISOString(),
consultation_number: result.data?.consultation_number ?? null,
}),
},
],
},
},
},
);
return new Response();
}
function readMetafield(
fields: Array<{ namespace: string; key: string; value: string }> | undefined,
ns: string,
key: string,
) {
return fields?.find((f) => f.namespace === ns && f.key === key)?.value ?? null;
}Two design choices worth flagging:
- The metafield is the audit trail, not just a UI hint. A B2B audit asks "was this customer's VAT number valid on the day of the supply?" A JSON metafield with
checked_atandconsultation_numberanswers that question without a separate database. vatverify also retains the same record on its own audit log on Pro and Business plans. taxExempttoggles silently. The customer does not see a "tax-exempt" indicator anywhere unless the theme renders one. Consider adding a small badge on the customer-account profile screen so the customer knows their VAT was accepted.
Re-validation and the deregistration problem
A VAT number that was valid on the day of registration may be invalid six months later if the buyer deregistered (revenue dropped, business closed, restructured). Shopify will keep taxExempt = true until you flip it back. This is the silent failure mode of static B2B configuration.
A scheduled job once a month is sufficient for most stores:
export async function revalidateAllB2bCustomers(admin: AdminClient) {
const taxExemptCustomers = await admin.graphql(`#graphql
query taxExemptCustomers($cursor: String) {
customers(first: 100, after: $cursor, query: "tax_exempt:true") {
edges {
cursor
node {
id
metafield(namespace: "vat", key: "validation") { value }
}
}
pageInfo { hasNextPage }
}
}
`);
for (const edge of taxExemptCustomers.data.customers.edges) {
const v = JSON.parse(edge.node.metafield?.value ?? 'null');
if (!v?.vat_number) continue;
const fresh = await vat.validate({ vat_number: v.vat_number });
if (fresh.data?.valid !== true) {
await admin.graphql(`#graphql
mutation flipTaxExempt($id: ID!) {
customerUpdate(input: { id: $id, taxExempt: false }) { userErrors { field message } }
}
`, { variables: { id: edge.node.id } });
}
}
}Run this from a Shopify-app cron or an external scheduler hitting an authenticated app endpoint.
Caching
vatverify caches valid responses for 30 days and invalid ones for 24 hours by default. Shopify-side caching on top is unnecessary for the flow described: the customer-update webhook fires once per customer-profile change, not on every order. If you call validation from a checkout extension instead, add a per-customer in-memory cache so a single checkout does not re-validate on every keystroke.
Cross-border vs domestic supplies
A B2B customer in the same country as the shop is a domestic sale. Domestic B2B is not reverse-charge under EU rules; the seller charges the standard rate even with a valid VAT number. The cross-border check (buyerCountry !== sellerCountry) is what decides whether taxExempt = true is correct.
For a UK shop selling to an EU customer post-Brexit, the supply is a third-country export (zero-rated for the seller's UK VAT, with import VAT due in the EU destination country). The pattern is the same on the Shopify side: set taxExempt = true, but the legal basis is exports, not intra-Community reverse-charge.
What this guide does not cover
- OSS and IOSS for B2C distance sales. Different scheme, different evidence requirements. Shopify Tax handles part of this for cross-border B2C goods, but the OSS reporting itself is outside Shopify.
- Domestic-reverse-charge sectors (construction, scrap metal, mobile phones, integrated circuits in several EU countries). Same-country B2B with these supply types follows the local domestic-reverse-charge rule, which Shopify Tax does not model.
- Northern Ireland XI numbers. If you sell goods to Northern Ireland, the customer should provide their XI prefix number; vatverify routes XI through VIES. See the Northern Ireland page for the dual-number context.