VAT Number Validation for Xero and QuickBooks

Neither Xero nor QuickBooks checks a VAT number against a government registry. Both products give you a place to store one on a contact or customer record, and both will happily save whatever text a bookkeeper or customer types in, valid or not. If your business applies the EU reverse charge on invoices raised from either tool, that gap sits directly upstream of your tax exposure.

This guide covers what Xero and QuickBooks actually do with the number field they give you, why an unchecked number is a bigger problem than it looks, and three ways to close the gap: no-code automation, a direct API integration, and periodic bulk revalidation.

What Xero does with VAT numbers

A Xero Contact has a TaxNumber field (max 50 characters). Xero's own API reference describes it as "also known as the ABN (Australia), GST Number (New Zealand), VAT Number (UK) or Tax ID Number (US and global) in the Xero UI depending on which regionalized version of Xero you are using." It is a single free-text field: nothing in the schema enforces a country prefix, a checksum, or a specific length, and nothing calls out to VIES, HMRC, or any other registry when the field is saved.

Xero customers have asked for this directly. A product idea titled "Tax number validation - The ability to verify VAT/GST/ABN numbers of contacts directly with the country Tax Authority" is sitting in Xero's product ideas forum with "Gaining Support" status, requesting a "Check VAT" action against HMRC and equivalent checks for other tax authorities. That is a customer asking Xero to do exactly what this guide shows you how to bolt on yourself in the meantime.

What QuickBooks does with VAT numbers

QuickBooks Online's Customer entity has a PrimaryTaxIdentifier field, labeled "Tax reg. no." in the UK, Canadian, Indian, and Australian editions of the product. Intuit's own API reference describes it as representing "the tax ID of the Person or Organization" and states that the value "is masked in responses, exposing only last five characters" (an ID of 123-45-6789 comes back as XXXXXX56789). Like Xero, nothing in the schema validates the number against a registry before or after it is saved.

The masking matters for how you design an integration, not just what field name to use. If you write a number to PrimaryTaxIdentifier, every later API read of that customer gives you back the last five characters and nothing else. You cannot recover the full number from QuickBooks after the fact to revalidate it; you can only validate it at the moment you (or your signup flow) already have the full value, before it goes into QuickBooks at all.

Why an unvalidated number is a real problem

A VAT number field that accepts anything is a data hygiene issue right up until an invoice zero-rates a transaction on the strength of it. Since January 2020, a valid, active VAT number is a material condition for applying the 0% rate on an intra-Community supply under EU rules, not just a formatting nicety. If the number stored in Xero or QuickBooks was never checked against VIES, HMRC, or the relevant registry, and it turns out to be wrong, deregistered, or fictitious, your business is liable for the VAT that should have been charged, plus interest and penalties if a tax authority catches it later. See the EU VAT reverse charge guide for the full mechanics of when the reverse charge applies and what a compliant invoice needs to show.

Neither Xero's TaxNumber nor QuickBooks' PrimaryTaxIdentifier gives you anything to point to as evidence that a check happened. A VIES or HMRC consultation number, timestamped and tied to the specific lookup, is the difference between "we saved what the customer typed" and "we verified this on this date" if an auditor asks.

Three ways to close the gap

No-code automation with Zapier or Make

If you do not want to write and host an integration, both Xero and QuickBooks are supported trigger apps in Zapier and Make. Trigger on a new or updated contact/customer, call Avatcado with the stored number using the Webhooks/HTTP action, and route the result back to a tag, a spreadsheet, or a Slack notification. This is the fastest way to get validation running, and it is a good fit if you do not have engineering time to spend on it. See the Zapier guide and the Make guide for the step-by-step setup; both are written generically enough to apply directly with a Xero or QuickBooks trigger swapped in.

The tradeoff is the same one those guides call out for any CRM or accounting tool: task-based billing and no built-in bulk mode make Zapier and Make a poor fit for validating an existing backlog of hundreds of contacts. Use them for new records as they are created, and use the batch endpoint below for the backlog.

Direct API integration

For a webhook-driven flow, Xero's CONTACT event category supports Create and Update events. The payload only carries the contact's resourceId, so your handler fetches the full contact, reads TaxNumber, and validates it. Since TaxNumber is free text with no enforced country prefix, a bare digit string needs the contact's address country attached before Avatcado can route it to the right registry. Xero contacts do not support arbitrary custom fields, so rather than invent one, this flags failures by adding the contact to an existing Xero Contact Group (created once, up front, e.g. "VAT number needs review") via the ContactGroups endpoint:

import Avatcado from "@avatcado/node";

const avatcado = new Avatcado(process.env.AVATCADO_API_KEY!);
const REVIEW_GROUP_ID = process.env.XERO_REVIEW_CONTACT_GROUP_ID!; // existing ContactGroup

// Handles the Xero webhook's CONTACT / Create event.
export async function handleXeroContactCreated(resourceId: string, tenantId: string, accessToken: string) {
  const res = await fetch(`https://api.xero.com/api.xro/2.0/Contacts/${resourceId}`, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "xero-tenant-id": tenantId,
      Accept: "application/json",
    },
  });
  const { Contacts } = (await res.json()) as {
    Contacts: Array<{ TaxNumber?: string; Addresses?: Array<{ AddressType: string; CountryCode?: string }> }>;
  };
  const contact = Contacts[0];

  const raw = contact.TaxNumber?.replace(/\s+/g, "");
  if (!raw) return; // nothing to check

  const country = contact.Addresses?.find((a) => a.AddressType === "STREET")?.CountryCode;
  const vatNumber = /^[A-Z]{2}/.test(raw) ? raw : `${country ?? ""}${raw}`;

  const { data, error } = await avatcado.vat.validate({ vatNumber });
  if (!error && data.data.valid) return; // valid, nothing to flag

  await fetch(`https://api.xero.com/api.xro/2.0/ContactGroups/${REVIEW_GROUP_ID}/Contacts`, {
    method: "PUT",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "xero-tenant-id": tenantId,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({ Contacts: [{ ContactID: resourceId }] }),
  });
}

Verify every delivery with the x-xero-signature header (HMAC-SHA256 over the raw payload with your webhook signing key) before trusting it, the same way you would for any inbound webhook.

For QuickBooks, the masking described above rules out a "read the customer back and validate it" approach for numbers that are already stored. The workable pattern is to validate before the number ever reaches QuickBooks, in your own signup or onboarding handler, and to keep the full result yourself:

// Validate before the customer is created in QuickBooks, since every
// later read of PrimaryTaxIdentifier from the QuickBooks API comes back
// masked to its last five characters.
export async function createQuickBooksCustomerWithVatCheck(
  input: { displayName: string; vatNumber: string },
  realmId: string,
  accessToken: string,
) {
  const { data, error } = await avatcado.vat.validate({ vatNumber: input.vatNumber });

  const res = await fetch(
    `https://quickbooks.api.intuit.com/v3/company/${realmId}/customer?minorversion=75`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${accessToken}`,
        "Content-Type": "application/json",
        Accept: "application/json",
      },
      body: JSON.stringify({
        DisplayName: input.displayName,
        PrimaryTaxIdentifier: input.vatNumber,
      }),
    },
  );
  const { Customer } = (await res.json()) as { Customer: { Id: string } };

  // QuickBooks will only ever hand you the masked value back from here on,
  // so this is the one place the full result is worth keeping.
  await db.vatChecks.insert({
    quickbooksCustomerId: Customer.Id,
    vatNumber: input.vatNumber,
    valid: !error && data.data.valid,
    companyName: !error ? data.data.company?.name : null,
    checkedAt: new Date().toISOString(),
  });
}

A scheduled sweep of the QuickBooks Query endpoint (GET /v3/company/{realmId}/query) is still worth running, just for a narrower job: finding customers where PrimaryTaxIdentifier is missing entirely, so someone can chase down the number in the first place. It cannot revalidate a number QuickBooks already has, because the field is masked in every response.

Periodic bulk revalidation

A VAT registration checked once at signup is not guaranteed to stay active. For an existing customer base, the CRM validation guide's batch approach applies here too: Avatcado's batch endpoint validates up to 50 numbers per request on Pro and Business plans, in one call, with per-item results.

For Xero, page through GET /Contacts (up to 100 contacts per call via ?page=), collect the TaxNumber values (returned unmasked), and submit them in groups of 50:

import Avatcado, { isBatchSuccess } from "@avatcado/node";

const avatcado = new Avatcado(process.env.AVATCADO_API_KEY!);

const { data, error } = await avatcado.vat.validateBatch({ vatNumbers: xeroTaxNumbers });
if (!error) {
  for (const item of data.data.results) {
    if (isBatchSuccess(item)) {
      if (!item.data.valid) flagContactForReview(item.data.vatNumber);
    } else {
      flagContactForReview(item.meta.vatNumber);
    }
  }
}

For QuickBooks, revalidate the numbers in your own database from the capture-time check above, not the masked copies stored on the Customer record, since a masked value cannot be resubmitted for a meaningful lookup. For a backlog large enough that 50-item batches are unwieldy, the async validation guide covers submitting up to 1,000 numbers at once and getting results back by webhook instead of holding a request open.

Get started

Avatcado's free tier includes 500 validations per month with no credit card required, enough to cover new contacts as they are created in either product while you decide whether you need the batch endpoint for a backlog.

Start validating for free →

Read the API documentation for the full endpoint reference, or see the Salesforce VAT validation guide for the same pattern applied to a CRM instead of an accounting tool.

Frequently asked questions

Does Xero validate VAT numbers on contacts?

No. Xero's Contact object has a TaxNumber field (a free-text field of up to 50 characters, labeled ABN, GST Number, VAT Number, or Tax ID Number depending on your regional edition), but Xero saves whatever text is entered without checking it against VIES, HMRC, or any other registry. Nothing in the schema enforces a country prefix, a checksum, or even a plausible length. Xero customers have asked for exactly this feature: a product idea requesting a built-in 'Check VAT' action against HMRC and equivalent tax authorities is currently sitting in Gaining Support status on Xero's product ideas forum. Until that ships, the check has to be bolted on: Xero's CONTACT webhook fires on Create and Update events, so a handler can fetch the contact, read TaxNumber, validate it through Avatcado, and flag failures by adding the contact to a 'needs review' Contact Group, since Xero contacts do not support arbitrary custom fields.

Does QuickBooks validate a customer's VAT or tax registration number?

No. QuickBooks Online's Customer entity has a PrimaryTaxIdentifier field, labeled 'Tax reg. no.' in the UK, Canadian, Indian, and Australian editions, but nothing in the QuickBooks API validates it against a government registry before or after it is saved; Intuit's API reference describes it simply as the tax ID of the person or organization. The field has a second property that shapes any integration you build: the value is masked in API responses, exposing only the last five characters. That means QuickBooks stores the number but never usefully returns it, so you cannot bolt validation on after the fact the way you can with Xero's unmasked TaxNumber. The workable pattern is to validate at capture time, in your own signup or onboarding handler, before the customer is created in QuickBooks, and to store the full number and validation result (valid flag, registered company name, timestamp) in your own database, which becomes the copy you revalidate from later.

Can I read back a customer's full VAT number from the QuickBooks API?

No. QuickBooks masks PrimaryTaxIdentifier in every API response, exposing only the last five characters; Intuit's own reference gives the example of 123-45-6789 coming back as XXXXXX56789. The masking is unconditional, so there is no scope, minor version, or permission that returns the full value. This has two concrete consequences for VAT validation. First, you can only validate the full number at the moment you or your signup flow captures it, before it is written to QuickBooks, because from then on the API will only ever hand you the masked value. Second, any revalidation program has to run against your own stored copy of the number, kept in your database alongside the validation result, not against what QuickBooks returns. A scheduled sweep of the QuickBooks Query endpoint is still useful for a narrower job: finding customers where PrimaryTaxIdentifier is missing entirely, so someone can chase down the number in the first place.

Should I validate a VAT number before or after saving it to Xero or QuickBooks?

For QuickBooks, validate before, and this is forced rather than preferred: the API masks PrimaryTaxIdentifier to its last five characters on every later read, so you cannot revalidate a stored value unless you kept your own copy. Run the Avatcado check in your signup or onboarding handler, then create the QuickBooks customer and store the full number plus the validation result in your own database. For Xero, you have both options, because TaxNumber comes back unmasked. Validate at creation by subscribing to the CONTACT webhook's Create and Update events, fetching the contact, and checking TaxNumber (prepending the contact's address country when the number lacks a prefix, since the field is free text), or validate later with a scheduled sweep that pages through the Contacts endpoint and submits the numbers to the batch endpoint in groups of 50. Either way, flag failures via a Contact Group, and keep the timestamped result as your audit evidence.

Sources

Try it on a real VAT number

Check any VAT or GST number against the official registry for free, no account needed.

Related guides