HMRC VAT Check API: OAuth, Examples & Error Handling

By Remco from Avatcado

Set up HMRC sandbox and production access, reuse OAuth tokens safely, preserve reference numbers, and route GB and XI registrations correctly.

HMRC's Check a UK VAT Number API verifies GB VAT registrations. Version 2 is a REST API protected by OAuth 2.0 client credentials. This guide covers sandbox and production access, token reuse, both lookup modes and the error codes a server-side integration must preserve.

Short answer: HMRC checks VAT registration, not VAT returns

Use this API to check whether a 9-digit or 12-digit UK VAT registration number belongs to a registered business, retrieve its name and address, and optionally obtain a reference proving you checked. It is distinct from Making Tax Digital APIs that submit VAT returns or read an organisation's tax account.

Version 2 has a simple mode and a verified mode. The simple path checks only the target. The verified path also includes your own registered VAT number and returns consultationNumber. In both cases, keep VAT registration evidence separate from the rules that decide how to tax a particular sale.

Set up sandbox and production access

  1. Register an application in the HMRC Software Developer Hub.
  2. Subscribe it to version 2 of Check a UK VAT Number.
  3. Use sandbox credentials and HMRC's published mock VAT numbers while developing.
  4. Test in the sandbox and accept the version 2 terms before receiving production credentials.

The sandbox base URL is https://test-api.service.hmrc.gov.uk. Production uses https://api.service.hmrc.gov.uk. HMRC's current API page says registration should take around two weeks and may take longer if more information is needed. Keep the environment an explicit server-side setting so test credentials never reach production by accident.

Get and reuse an OAuth access token

Application-restricted endpoints use the OAuth 2.0 client credentials grant. Request the read:vat scope and put the credentials in the form body. Store the client secret only on your server.

HMRC · cURL
Ready to copy
# Set HMRC_CLIENT_ID and HMRC_CLIENT_SECRET in this server environment first.
curl --max-time 15 \
  --request POST \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=client_credentials' \
  --data-urlencode "client_id=${HMRC_CLIENT_ID}" \
  --data-urlencode "client_secret=${HMRC_CLIENT_SECRET}" \
  --data-urlencode 'scope=read:vat' \
  'https://test-api.service.hmrc.gov.uk/oauth/token'

HMRC's token response includes expires_in, normally 14,400 seconds. Reuse the token until shortly before that deadline, then request a new one. Do not fetch a token for every VAT lookup. If a lookup returns 401, renew once and retry once; a second 401 needs investigation rather than an unbounded retry loop. Copy access_token from this response into the server-only HMRC_ACCESS_TOKENenvironment variable before running the lookup below.

Call the VAT lookup endpoint

Pass the number without a GB prefix and send the versioned Accept header. Both 9-digit and 12-digit target numbers are valid according to the version 2 specification.

HMRC · cURL
Ready to copy
# Set HMRC_ACCESS_TOKEN to the access_token returned above.
curl --max-time 15 \
  --header "Authorization: Bearer $HMRC_ACCESS_TOKEN" \
  --header 'Accept: application/vnd.hmrc.2.0+json' \
  'https://test-api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup/123456789'

The target-only response below is synthetic and demonstrates the documented fields. It omits requester and consultationNumber, so it carries no consultation proof. A call with your genuine registered requester may return those optional fields, but they are not guaranteed by this target-only request. This is not the result of a live production lookup.

HMRC · JSON
Ready to copy
{
  "target": {
    "name": "Example Ltd",
    "vatNumber": "123456789",
    "address": {
      "line1": "1 High Street",
      "postcode": "SW1A 1AA",
      "countryCode": "GB"
    }
  },
  "processingDate": "2026-09-15T10:00:00+00:00"
}

Run the TypeScript example

This Node.js 20+ example defaults to HMRC's sandbox. It keeps credentials on the server, requests the required scope, caches the token using expires_in, applies a 15-second timeout to every request, validates response bodies, and performs only the bounded 401 renewal described above. The source includes its run command.

HMRC · TypeScript
Ready to copy
type HmrcEnvironment = "sandbox" | "production";
type LookupOptions = { requesterVrn?: string; environment?: HmrcEnvironment };
type HmrcResult =
  | {
      status: "Valid";
      vat_number: string;
      name: string;
      address: { line1: string; postcode: string; countryCode: string };
      checked_at: string;
      requester_vat_number: string | null;
      consultation_number: string | null;
      proof_status: "provided" | "not_requested";
    }
  | {
      status: "Invalid";
      code: "NOT_FOUND";
      vat_number: string;
      checked_at: null;
      consultation_number: null;
      proof_status: "not_provided";
    }
  | { status: "Unavailable" | "Error"; code: string; message: string | null };

class LookupFailure extends Error {
  constructor(
    readonly status: "Unavailable" | "Error",
    readonly code: string,
    readonly detail: string | null,
  ) {
    super(detail ?? code);
  }
}

const tokenCache = new Map<HmrcEnvironment, { accessToken: string; expiresAt: number }>();
const BASE_URLS: Record<HmrcEnvironment, string> = {
  sandbox: "https://test-api.service.hmrc.gov.uk",
  production: "https://api.service.hmrc.gov.uk",
};

function record(value: unknown): Record<string, unknown> | null {
  return value !== null && typeof value === "object" && !Array.isArray(value)
    ? value as Record<string, unknown>
    : null;
}

function text(value: unknown): string | null {
  return typeof value === "string" && value.trim().length > 0 ? value : null;
}

function owns(value: Record<string, unknown>, key: string): boolean {
  return Object.prototype.hasOwnProperty.call(value, key);
}

function cleanVrn(value: string): string {
  return value.replace(/[\s.-]/g, "");
}

function validVrn(value: string): boolean {
  return /^(?:\d{9}|\d{12})$/.test(value);
}

function statusFor(httpStatus: number): "Unavailable" | "Error" {
  return httpStatus === 408 || httpStatus === 429 || httpStatus >= 500 ? "Unavailable" : "Error";
}

type TimedResponse = {
  response: Response;
  signal: AbortSignal;
  finish: () => void;
};

async function timedFetch(input: string, init: RequestInit): Promise<TimedResponse> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 15_000);
  try {
    const response = await fetch(input, { ...init, signal: controller.signal });
    return { response, signal: controller.signal, finish: () => clearTimeout(timer) };
  } catch (error) {
    clearTimeout(timer);
    if (controller.signal.aborted) {
      throw new LookupFailure("Unavailable", "timeout", "HMRC did not answer within 15 seconds.");
    }
    if (error instanceof TypeError) {
      throw new LookupFailure("Unavailable", "network_error", "HMRC could not be reached.");
    }
    throw new LookupFailure("Error", "unexpected_error", error instanceof Error ? error.message : null);
  }
}

async function jsonObject(pending: TimedResponse): Promise<Record<string, unknown>> {
  let parsed: unknown;
  try {
    parsed = await pending.response.json();
  } catch (error) {
    if (pending.signal.aborted) {
      throw new LookupFailure("Unavailable", "timeout", "HMRC did not answer within 15 seconds.");
    }
    if (error instanceof TypeError) {
      throw new LookupFailure("Unavailable", "network_error", "The HMRC response could not be read.");
    }
    if (!pending.response.ok) {
      throw new LookupFailure(
        statusFor(pending.response.status),
        "http_" + pending.response.status,
        "HMRC returned a non-JSON error response.",
      );
    }
  } finally {
    pending.finish();
  }
  const body = record(parsed);
  if (body) return body;
  if (!pending.response.ok) {
    throw new LookupFailure(
      statusFor(pending.response.status),
      "http_" + pending.response.status,
      "HMRC returned an invalid error response.",
    );
  }
  throw new LookupFailure("Error", "malformed_response", "HMRC returned malformed JSON.");
}

function upstreamFailure(response: Response, body: Record<string, unknown>): LookupFailure {
  const code = text(body.code) ?? text(body.error) ?? "http_" + response.status;
  const message = text(body.message) ?? text(body.error_description);
  return new LookupFailure(statusFor(response.status), code, message);
}

async function accessToken(environment: HmrcEnvironment, baseUrl: string): Promise<string> {
  const cached = tokenCache.get(environment);
  if (cached && Date.now() < cached.expiresAt) return cached.accessToken;

  const clientId = process.env.HMRC_CLIENT_ID;
  const clientSecret = process.env.HMRC_CLIENT_SECRET;
  if (!clientId || !clientSecret) {
    throw new LookupFailure("Error", "missing_credentials", "Set HMRC_CLIENT_ID and HMRC_CLIENT_SECRET on the server.");
  }

  const pending = await timedFetch(baseUrl + "/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_id: clientId,
      client_secret: clientSecret,
      scope: "read:vat",
    }),
  });
  const response = pending.response;
  const body = await jsonObject(pending);
  if (!response.ok) throw upstreamFailure(response, body);

  const token = text(body.access_token);
  const expiresIn = body.expires_in;
  if (!token || typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
    throw new LookupFailure("Error", "malformed_response", "HMRC returned invalid token metadata.");
  }
  tokenCache.set(environment, { accessToken: token, expiresAt: Date.now() + expiresIn * 1_000 });
  return token;
}

async function lookup(
  baseUrl: string,
  targetVrn: string,
  requesterVrn: string | undefined,
  token: string,
): Promise<TimedResponse> {
  const path = "/organisations/vat/check-vat-number/lookup/" + targetVrn +
    (requesterVrn ? "/" + requesterVrn : "");
  return timedFetch(baseUrl + path, {
    method: "GET",
    headers: {
      Authorization: "Bearer " + token,
      Accept: "application/vnd.hmrc.2.0+json",
    },
  });
}

// Server-side Node.js 20+. The sandbox is the safe default.
export async function lookupHmrcVat(
  targetVatNumber: string,
  options: LookupOptions = {},
): Promise<HmrcResult> {
  const targetVrn = cleanVrn(targetVatNumber);
  const requesterVrn = options.requesterVrn === undefined ? undefined : cleanVrn(options.requesterVrn);
  const environment = options.environment ?? "sandbox";
  if (!validVrn(targetVrn)) {
    return { status: "Error", code: "invalid_vat_number", message: "HMRC accepts 9 or 12 digits." };
  }
  if (requesterVrn !== undefined && !validVrn(requesterVrn)) {
    return { status: "Error", code: "invalid_requester_vat_number", message: "HMRC accepts 9 or 12 digits." };
  }
  if (environment !== "sandbox" && environment !== "production") {
    return { status: "Error", code: "invalid_environment", message: "Choose sandbox or production explicitly." };
  }

  const baseUrl = BASE_URLS[environment];
  try {
    let token = await accessToken(environment, baseUrl);
    let pending = await lookup(baseUrl, targetVrn, requesterVrn, token);
    if (pending.response.status === 401) {
      try {
        await pending.response.body?.cancel();
      } finally {
        pending.finish();
      }
      tokenCache.delete(environment);
      token = await accessToken(environment, baseUrl);
      pending = await lookup(baseUrl, targetVrn, requesterVrn, token);
    }

    const response = pending.response;
    const body = await jsonObject(pending);
    const code = text(body.code) ?? text(body.error);
    if (response.status === 404 && code === "NOT_FOUND") {
      return {
        status: "Invalid",
        code: "NOT_FOUND",
        vat_number: targetVrn,
        checked_at: null,
        consultation_number: null,
        proof_status: "not_provided",
      };
    }
    if (!response.ok) throw upstreamFailure(response, body);

    const target = record(body.target);
    const address = record(target?.address);
    const name = text(target?.name);
    const returnedVrn = text(target?.vatNumber);
    const line1 = text(address?.line1);
    const postcode = text(address?.postcode);
    const countryCode = text(address?.countryCode);
    const processingDate = text(body.processingDate);
    const returnedRequester = text(body.requester);
    const consultationNumber = text(body.consultationNumber);
    const requesterPresent = owns(body, "requester");
    const consultationPresent = owns(body, "consultationNumber");
    const proofFieldsWellFormed =
      (!requesterPresent || returnedRequester !== null) &&
      (!consultationPresent || consultationNumber !== null);
    const proofIsConsistent = requesterVrn
      ? requesterPresent && consultationPresent && returnedRequester === requesterVrn && consultationNumber !== null
      : !requesterPresent && !consultationPresent;
    if (
      !target ||
      returnedVrn !== targetVrn ||
      !name ||
      !address ||
      !line1 ||
      !postcode ||
      !countryCode ||
      !processingDate ||
      !Number.isFinite(Date.parse(processingDate)) ||
      !proofFieldsWellFormed ||
      !proofIsConsistent
    ) {
      throw new LookupFailure("Error", "malformed_response", "HMRC returned inconsistent lookup data.");
    }

    return {
      status: "Valid",
      vat_number: returnedVrn,
      name,
      address: { line1, postcode, countryCode },
      checked_at: processingDate,
      requester_vat_number: returnedRequester,
      consultation_number: consultationNumber,
      proof_status: consultationNumber ? "provided" : "not_requested",
    };
  } catch (error) {
    if (error instanceof LookupFailure) {
      return { status: error.status, code: error.code, message: error.detail };
    }
    return { status: "Error", code: "unexpected_error", message: error instanceof Error ? error.message : null };
  }
}

// Save as hmrc-lookup.ts, set server credentials, then run with Node.js 20+:
// npx --yes tsx hmrc-lookup.ts 123456789 sandbox
// Add your genuine requester UK VAT number as the third argument when you need evidence.
// Pass production as the second argument only after HMRC grants production access.
const [targetVrn, selectedEnvironment, requesterVrn] = process.argv.slice(2);
if (targetVrn) {
  const environment = selectedEnvironment === "production" ? "production" : "sandbox";
  void lookupHmrcVat(targetVrn, { environment, requesterVrn }).then(console.log, console.error);
}

Choose unverified or reference-number mode

The simple endpoint is /organisations/vat/check-vat-number/lookup/{targetVrn}. It returns the target business and processingDate, but no audit reference.

Append your own genuine registration number for the verified endpoint: /organisations/vat/check-vat-number/lookup/{targetVrn}/{requesterVrn}. A successful response adds requester and consultationNumber. The requester may also be 9 or 12 digits. If HMRC rejects it, preserve that failure. Falling back silently to the simple endpoint would turn a requested proof check into an unverified result.

Handle HMRC errors by code

Parse the JSON error before deciding what happened. HTTP status identifies the broad class, while the stable code tells your integration whether the documented condition occurred. Error messages can change.

Status and codeMeaningApplication action
400 INVALID_REQUESTThe target or requester is not a valid 9-digit or 12-digit parameter.Correct the request. Do not retry unchanged input.
403 INVALID_REQUESTThe requester does not match a registered company in reference-number mode.Stop and correct the requester. Do not downgrade the check.
404 NOT_FOUNDThe target does not match a registered company.Record a completed not-registered outcome.
401 OAuth errorThe token is absent, invalid or expired.Renew once, retry once, then surface the authentication failure.
500 INTERNAL_SERVER_ERRORHMRC did not complete the lookup.Keep the result unavailable and retry through your operational policy.

Interpret 404 as not registered only when the body contains HMRC's documented NOT_FOUND code. A proxy, gateway or unrelated route can also return 404. Likewise, never turn malformed JSON, timeouts or server errors into an invalid VAT result.

Route GB and XI numbers correctly

Remove the GB prefix before calling HMRC. Northern Ireland numbers used for goods arrangements have an XI prefix and remain in VIES, so send those to the European Commission instead. The UK VAT number format page covers the accepted GB formats, while the VIES API guide covers XI lookups and their different failure model.

HMRC directly vs Avatcado

HMRC direct is the authoritative choice when you only need GB checks and can manage application registration, secrets, tokens and error mapping. Avatcado is useful when the same billing flow also accepts EU and other supported registrations.

ConcernHMRC directlyAvatcado
AuthenticationOAuth client credentials, read:vat, token expiryOne server-side API key
EnvironmentsSeparate sandbox and production URLs and credentialsLive and deterministic test keys with one response contract
CoverageGB VAT registrationsGB, EU, XI, CH, LI, NO and AU through one endpoint
ErrorsPreserve each HMRC status and JSON codeMachine-readable errors in a consistent envelope
EvidenceStore processing date and consultation number yourselfReturns requested_at, source metadata and the consultation number when supplied by HMRC
Repeat lookupsOwn any storage and refresh policyBuilt-in caching, with freshness visible in metadata

This native cURL request keeps the API key in a server environment variable, quotes the URL and bounds the target-only request to 15 seconds. Only add requester_vat_number when you need consultation evidence and can supply your own genuine registered requester UK VAT number. If you do not need consultation evidence or do not have an eligible registration, omit it.

Avatcado · cURL
Ready to copy
# Set AVATCADO_API_KEY in this server environment first.
curl --max-time 15 \
  "https://api.avatcado.com/v1/validate?vat_number=GB123456789" \
  --header "Authorization: Bearer $AVATCADO_API_KEY"

With requester_vat_number, Avatcado defaults cache reads and national register fallback to off. However, if HMRC rejects that requester with 400 or 403, Avatcado currently retries once without the requester. The direct HMRC example above preserves the failure and does not downgrade to an unverified lookup.

After Avatcado returns, inspect requested_at, meta.source, meta.source_status, meta.cached, meta.stale and consultation_number. A missing consultation_number means there is no reference evidence, and it does not guarantee the final result came from a fresh verified requester check.

Avatcado's free plan includes 500 validations per month and requires no credit card.

Frequently asked questions

Do I need OAuth to validate UK VAT numbers?

Yes, when you call version 2 of HMRC's API directly. Use the client credentials grant with the read:vat scope, cache the returned token for its expires_in lifetime, and send it as a Bearer token with the version 2 Accept header. Avatcado uses one API key for GB and every other supported registry, so your application does not manage HMRC tokens.

How long does HMRC application approval take?

HMRC's current version 2 page says registration should take around two weeks and may take longer if more information is needed. You receive production credentials after testing in the sandbox and accepting the version 2 terms. Treat that as HMRC's estimate rather than a delivery guarantee.

Why does HMRC return 404 for invalid VAT numbers?

HMRC documents 404 with code NOT_FOUND when targetVrn does not match a registered company. Check both the status and JSON code before recording that outcome, because proxies and unrelated routes can also return 404. Keep malformed bodies, timeouts, authentication failures and server errors as unavailable or failed checks rather than converting them to not registered.

Can I validate Northern Ireland VAT numbers?

Yes. XI-prefixed registrations used for Northern Ireland goods arrangements are validated through VIES, while GB-prefixed registrations go to HMRC. A direct integration needs both paths and their different error handling. Avatcado reads the prefix and routes each number to the correct registry through one endpoint.

Sources

Related guides