BigCommerce VAT validation: customer groups, webhooks, and tax exemption
Validate EU VAT numbers on a BigCommerce store, route cross-border B2B customers into a tax-exempt customer group, and persist the audit trail. Built around BigCommerce V3 API and webhooks.
TL;DR
- Capture the VAT number on registration via a BigCommerce form field (or a custom Stencil widget).
- A small server-side app subscribes to the
store/customer/updatedwebhook, calls vatverify, and moves valid B2B customers into a "Tax-exempt B2B" customer group. - Validation results live as customer attributes so the audit trail is queryable from the admin panel and survives a re-validation cycle.
The BigCommerce VAT landscape
BigCommerce ships with built-in tax classes and customer groups. For EU VAT validation, the relevant pieces are:
- Customer groups: each customer can belong to one group; groups can have category-level discounts and price-list overrides. They cannot directly toggle tax exemption, but they can map to a tax class via the tax-zone configuration.
- Tax zones: BigCommerce calculates tax based on the buyer's address and the store's configured tax zone. A tax zone can include or exclude specific customer groups.
- Customer attributes and customer attribute values: free-form metadata you can attach to any customer. The right place to store the VAT number plus the validation timestamp.
- BigCommerce B2B Edition is the dedicated B2B layer with company accounts, sales reps, and quote management. The pattern below works for both B2B Edition and a standard BigCommerce store.
Architecture overview
The flow has four moving parts:
- A registration form field captures the customer's VAT number. Either a Stencil widget on the registration page or a B2B Edition company-onboarding field.
- A server-side app subscribes to the
store/customer/updatedwebhook, validates via vatverify, and writes the result. - The BigCommerce V3 API assigns the validated B2B customer to the "Tax-exempt B2B" customer group, which the tax-zone configuration treats as exempt from EU VAT.
- A re-validation job runs monthly to catch customer deregistrations.
Capturing the VAT number
Add a custom field to the registration form by extending the registration form schema in BigCommerce admin:
Settings → Account creation → Customize the form
Add field: "EU VAT number" (text)
Field name: vat_numberThis stores the value as a form_fields entry on the customer object. The field is then visible on the customer's account profile and editable by the customer or the merchant.
Alternatively, on a Stencil-themed store, add a styled input to templates/components/account/login-form.html and POST to a custom app endpoint that updates the customer attribute via the V3 API.
The validation app
The webhook payload contains a customer ID; the app re-fetches the full customer record, reads the VAT number from the form field, validates, and writes back.
import { Vatverify } from '@vatverify/node';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
const BC_API = `https://api.bigcommerce.com/stores/${process.env.BC_STORE_HASH}/v3`;
const BC_HEADERS = {
'X-Auth-Token': process.env.BC_API_TOKEN!,
'Content-Type': 'application/json',
};
const TAX_EXEMPT_GROUP_ID = Number(process.env.BC_TAX_EXEMPT_GROUP_ID);
export async function POST(req: Request) {
const event = await req.json();
const customerId = event.data.id as number;
const customerRes = await fetch(
`${BC_API}/customers?id:in=${customerId}&include=formfields,attributes`,
{ headers: BC_HEADERS },
);
const { data: [customer] } = await customerRes.json();
if (!customer) return new Response();
const vatField = customer.form_fields?.find(
(f: { name: string; value: string }) => f.name === 'vat_number',
);
const vatNumber = vatField?.value as string | undefined;
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 isCrossBorderB2B =
valid && buyerCountry && buyerCountry !== process.env.SHOP_HOME_COUNTRY;
await fetch(`${BC_API}/customers`, {
method: 'PUT',
headers: BC_HEADERS,
body: JSON.stringify([
{
id: customerId,
customer_group_id: isCrossBorderB2B ? TAX_EXEMPT_GROUP_ID : 0,
},
]),
});
await fetch(`${BC_API}/customers/attribute-values`, {
method: 'PUT',
headers: BC_HEADERS,
body: JSON.stringify([
{
customer_id: customerId,
attribute_id: Number(process.env.BC_ATTR_VAT_VALIDATION_ID),
value: JSON.stringify({
vat_number: vatNumber,
valid,
country: buyerCountry,
checked_at: new Date().toISOString(),
consultation_number: result.data?.consultation_number ?? null,
}),
},
]),
});
return new Response();
}Two design choices worth flagging:
- The customer attribute is the audit trail. A B2B audit asks "was this customer's VAT number valid on the day of the supply?" A JSON-encoded attribute 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. - Customer-group assignment is the lever, not a per-customer tax flag. BigCommerce ties tax exemption to the group, so flipping the group is what tells the tax engine to skip EU VAT on this customer's orders.
Tax-zone configuration
In BigCommerce admin, go to Settings → Tax → Manual tax setup. For each EU tax zone:
- Set the rate as the destination country's standard VAT rate.
- Under "Tax exemption", exclude the "Tax-exempt B2B" customer group.
For Shopify-style automatic EU tax management, BigCommerce also offers Avalara AvaTax integration. AvaTax does not validate VAT numbers; you still need vatverify to decide whether the customer belongs in the exempt group. The AvaTax integration then respects the group assignment.
Re-validation
A scheduled job once a month is sufficient for most stores. Loop over the tax-exempt customer group, re-validate each VAT number, and demote any that fail back to the default group.
async function revalidateAllB2bCustomers() {
let page = 1;
while (true) {
const res = await fetch(
`${BC_API}/customers?customer_group_id=${TAX_EXEMPT_GROUP_ID}&page=${page}&limit=250&include=formfields`,
{ headers: BC_HEADERS },
);
const { data, meta } = await res.json();
if (data.length === 0) break;
for (const customer of data) {
const vatField = customer.form_fields?.find(
(f: { name: string }) => f.name === 'vat_number',
);
if (!vatField) continue;
const fresh = await vat.validate({ vat_number: vatField.value });
if (fresh.data?.valid !== true) {
await fetch(`${BC_API}/customers`, {
method: 'PUT',
headers: BC_HEADERS,
body: JSON.stringify([{ id: customer.id, customer_group_id: 0 }]),
});
}
}
if (page >= meta.pagination.total_pages) break;
page += 1;
}
}Run it from a host you control (Vercel cron, GitHub Actions, a Render cron job, anything that can hit an authenticated endpoint on a schedule).
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 the customer belongs in the tax-exempt group.
For a UK shop selling to an EU customer post-Brexit, the supply is a third-country export (zero-rated for UK VAT, import VAT due in the EU destination country). Same group assignment on the BigCommerce side; different legal basis.
What this guide does not cover
- OSS and IOSS for B2C cross-border distance sales. Different scheme, different evidence requirements.
- Domestic-reverse-charge sectors (construction, scrap metal, integrated circuits in several EU countries). Same-country B2B with these supply types follows the local domestic-reverse-charge rule, which BigCommerce's tax engine does not model.
- Northern Ireland XI numbers for goods trade with the EU. The customer should provide their XI prefix number; vatverify routes XI through VIES.