vatverify home
All guides

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 = true on 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:

  1. 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.
  2. Customer Accounts (new) are the modern customer-account experience replacing the legacy account UI. They can carry custom fields and metaobjects.
  3. 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:

  1. A form: collects the customer's company name, country, and VAT number. Lives on the customer registration page or a dedicated B2B onboarding flow.
  2. A Shopify app: receives form submission via webhook or app proxy, calls vatverify's /v1/validate endpoint, and writes the result.
  3. Shopify Admin API: updates Customer.taxExempt and customer metafields based on the validation result.
  4. 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:

extensions/customer-accounts-vat/src/Checkout.tsx
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:

  1. Reads the new VAT number off the customer payload.
  2. Calls vatverify's /v1/validate endpoint.
  3. Writes the result to a customer metafield namespaced as vat.validation.
  4. Sets taxExempt = true if the validation succeeds and the customer's country differs from the shop's home country (cross-border B2B is the reverse-charge case).
app/routes/webhooks.customers-update.ts
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_at and consultation_number answers that question without a separate database. vatverify also retains the same record on its own audit log on Pro and Business plans.
  • taxExempt toggles 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:

app/jobs/revalidate-b2b-customers.ts
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.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free