Build Against the VAT API Without Burning Quota

9 September 2026

Build Against the VAT API Without Burning Quota

Build Against the VAT API Without Burning Quota

Every vatnode account gets a test API key automatically, prefixed vat_test_ instead of vat_live_. Point it at a reserved XX VAT number and you get back a fixture response — no VIES call, no database write, no quota spent. That's the whole mechanism. The rest of this post is what you can actually build with it.

The API resolves the environment from the key itself, not from anything in the request body or URL. Send a vat_test_ key with any GET /v1/vat/:vatId request and you're in test mode for that call, full stop.

What a test key actually skips

When the API sees a vat_test_ key, it short-circuits before doing any of the work a live request does:

  • Format validation is skipped, except one check — the VAT ID has to start with XX.
  • No call to VIES or a national fallback is made.
  • Nothing is written to your validation history (vat_checks).
  • Your monthly quota counter is not incremented.

Be precise about that last point — "no effect on your data" oversells it slightly. Test calls never count against your monthly quota and are never written to your validation history. The only record kept is a one-time timestamp of your first test call, used internally for onboarding — it doesn't reflect the specific VAT number or result you tested. You won't see individual test checks anywhere in your dashboard, and you can hammer the endpoint as hard as you like.

Full reference, including the response schema, lives in Test Mode. If you haven't looked at how vatnode structures the seven documented error codes, Handling VIES Errors in Code is the companion piece — the fixtures below are the fastest way to actually trigger those codes without waiting for a real outage.

The XX fixture numbers

Test mode only accepts VAT numbers starting with XX — a prefix reserved specifically because it can never collide with a real member-state format. Five specific numbers map to distinct scenarios; everything else in the XX* space falls back to a default invalid result.

| VAT number | HTTP | Result | What it's for | | --------------- | ---------------------- | -------------------------------------------------- | ----------------------------------------------------------------- | | XX0000001 | 200 | valid: true, full company name + address | Happy path | | XX0000002 | 200 | valid: true, companyName/companyAddress null | Valid VAT where VIES returns no company details (mirrors Germany) | | XX0000003 | 200 | valid: false | Not found / deregistered | | XX0000004 | 503 VIES_UNAVAILABLE | — | VIES member-state node down | | XX0000005 | 502 VIES_ERROR | — | Unexpected upstream protocol fault | | any other XX* | 200 | valid: false | Default fallback | | non-XX number | 400 INVALID_FORMAT | — | Test mode rejects it — message tells you to use XX numbers |

A word on how close this is to a live response: the test response uses the same field names and types as a live response for every documented field. It won't be byte-for-byte identical — checkId, verifiedAt, and countryVat.countryVatUpdatedAt are generated fresh on each call, the same way they are live. What's fixed per fixture is the verdict and the company fields, so you get deterministic verdicts to assert against without deterministic timestamps to mock around.

One more detail that trips people up: fixture responses report source: "VIES" so your source-handling logic gets exercised the same way it would on a live VIES answer — but no call to VIES is made. Treat it as a label for your parser, not as evidence a lookup happened.

Happy path

curl https://api.vatnode.dev/v1/vat/XX0000001 \
  -H "Authorization: Bearer vat_test_your_test_key"
{
  "valid": true,
  "vatId": "XX0000001",
  "countryCode": "XX",
  "countryName": "Test Country",
  "companyName": "Test Company Ltd",
  "companyAddress": "1 Test Street, Test City, TC1 0AA",
  "consultationNumber": null,
  "verifiedAt": "2026-09-09T10:00:00.000Z",
  "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",
  "source": "VIES"
}

consultationNumber is always null in test mode, on every fixture, regardless of what requester VAT number is set on your account. It's only ever populated on a live VIES consultation.

And the same call in TypeScript:

async function checkVat(vatId: string, apiKey: string) {
  const res = await fetch(`https://api.vatnode.dev/v1/vat/${vatId}`, {
    headers: { Authorization: `Bearer ${apiKey}` },
  })

  if (!res.ok) {
    const { error } = await res.json()
    throw new Error(`${error.code}: ${error.message}`)
  }

  return res.json()
}

const result = await checkVat('XX0000001', 'vat_test_your_test_key')
console.log(result.valid, result.companyName)

Wiring the fixtures into a test suite

The five scenarios map cleanly onto assertions. This is the pattern to lift into your own integration tests — no mocking, no fixtures of your own to maintain, no live key required in CI:

const TEST_KEY = process.env.VATNODE_TEST_KEY! // vat_test_...

async function checkVat(vatId: string) {
  const res = await fetch(`https://api.vatnode.dev/v1/vat/${vatId}`, {
    headers: { Authorization: `Bearer ${TEST_KEY}` },
  })
  return { status: res.status, body: await res.json() }
}

test('valid VAT with full company details', async () => {
  const { status, body } = await checkVat('XX0000001')
  expect(status).toBe(200)
  expect(body.valid).toBe(true)
  expect(body.companyName).toBe('Test Company Ltd')
})

test('valid VAT with no company details on file', async () => {
  const { status, body } = await checkVat('XX0000002')
  expect(status).toBe(200)
  expect(body.valid).toBe(true)
  expect(body.companyName).toBeNull()
})

test('deregistered VAT number', async () => {
  const { status, body } = await checkVat('XX0000003')
  expect(status).toBe(200)
  expect(body.valid).toBe(false)
})

test('VIES unavailable degrades, does not fail closed', async () => {
  const { status, body } = await checkVat('XX0000004')
  expect(status).toBe(503)
  expect(body.error.code).toBe('VIES_UNAVAILABLE')
})

test('VIES protocol error', async () => {
  const { status, body } = await checkVat('XX0000005')
  expect(status).toBe(502)
  expect(body.error.code).toBe('VIES_ERROR')
})

test('non-XX number rejected in test mode', async () => {
  const { status, body } = await checkVat('DE143454214')
  expect(status).toBe(400)
  expect(body.error.code).toBe('INVALID_FORMAT')
})

Run that suite against XX0000004 and XX0000005 and you've exercised the exact retry/degrade branches from Handling VIES Errors in Code503 and 502 — without depending on VIES actually being down when your CI runs. The full error vocabulary, including the codes test mode can't reach (RATE_LIMITED, INVALID_REQUESTER, UPSTREAM_TIMEOUT, INTERNAL_ERROR), is in Errors.

The bulk endpoint runs the same fixtures through a real job

If you're validating in batches, Bulk VAT Validation accepts test keys too, and the same XX* fixtures apply per position. A test job is a real job — same jobId, same submit → poll → page contract a live job uses, just paid for in fixtures instead of quota. You can build and test your entire polling and pagination consumer, including per-position error branching, before a live VAT ID ever touches the endpoint.

Submitting is the same as with a live key — 202, nothing validated during the request:

curl -X POST https://api.vatnode.dev/v1/vat/bulk \
  -H "Authorization: Bearer vat_test_your_test_key" \
  -H "Content-Type: application/json" \
  -d '{"vatIds": ["XX0000001", "XX0000004", "DE12345"]}'
{
  "jobId": "0f8b1d64-4a1e-4a5e-9f4a-2b1c8d3e5f60",
  "status": "queued",
  "totalItems": 3,
  "duplicatesDropped": 0,
  "createdAt": "2026-09-09T10:00:00.000Z"
}

A test job runs every position at once rather than at the throttled concurrency a live job uses, so by the time your first GET lands it's usually already finished. Page through the results the same way you would for a live job:

curl "https://api.vatnode.dev/v1/vat/bulk/0f8b1d64-4a1e-4a5e-9f4a-2b1c8d3e5f60/results?page=1&limit=100" \
  -H "Authorization: Bearer vat_test_your_test_key"
{
  "items": [
    {
      "position": 0,
      "vatId": "XX0000001",
      "status": "done",
      "valid": true,
      "source": "VIES",
      "consultationNumber": null,
      "checkId": null,
      "error": null,
      "processedAt": "2026-09-09T10:00:00.400Z"
    },
    {
      "position": 1,
      "vatId": "XX0000004",
      "status": "done",
      "valid": null,
      "source": null,
      "consultationNumber": null,
      "checkId": null,
      "error": {
        "code": "VIES_UNAVAILABLE",
        "message": "VIES service is temporarily unavailable"
      },
      "processedAt": "2026-09-09T10:00:00.410Z"
    },
    {
      "position": 2,
      "vatId": "DE12345",
      "status": "done",
      "valid": null,
      "source": null,
      "consultationNumber": null,
      "checkId": null,
      "error": {
        "code": "INVALID_FORMAT",
        "message": "Test mode only accepts XX VAT numbers (e.g. XX0000001). Use your live key for real VAT IDs."
      },
      "processedAt": "2026-09-09T10:00:00.415Z"
    }
  ],
  "total": 3,
  "page": 1,
  "pages": 1
}

XX0000001 comes back with checkId: null — same as every test-mode result, single or bulk: no quota spent, no VIES call, no stored check.

XX0000004 and XX0000005 reproduce the same VIES_UNAVAILABLE and VIES_ERROR codes the single endpoint returns as an HTTP status, except a bulk position doesn't carry an HTTP status of its own at all. The job's own calls (submit, poll, page) stay in the 200s regardless of what happened to any individual position; the outcome lives entirely in that position's error object.

A non-XX entry like DE12345 becomes an accepted position carrying INVALID_FORMAT in error, not a 400 that would reject the whole submission.

Duplicate vatIds in the same batch are deduplicated on intake, test key or live — send XX0000001 three times and it becomes one position, counted in duplicatesDropped on the 202.

The position shape is leaner than the single endpoint here too — no companyName or companyAddress. Follow up with GET /v1/vat/:vatId if you need company details for a specific item.

Where test keys don't work

Test mode covers the VAT-check endpoints — single and bulk — and nothing else. Subscriptions and webhooks reject a vat_test_ key with 403 TEST_KEY_NOT_ALLOWED, because those act on real VAT numbers and deliver real HTTP callbacks to your endpoint. There's no sandbox version of "revalidate this customer monthly" or "POST a signed payload to your server" — both are live-key-only. Build and test the validation call against fixtures; switch to a vat_live_ key before you wire up monitoring or webhook delivery.

FAQ

Does using a vatnode test key ever hit the real VIES service?

No. A request authenticated with a vat_test_ key short-circuits before any network call — VIES and every national fallback are skipped entirely. The response comes from a hardcoded fixture, not a lookup.

What VAT numbers work in test mode, and why do they have to start with XX?

Only numbers starting with XX. XX is a reserved prefix that can never match a real member-state VAT number, so it can't be confused with production data. Five specific XX numbers (XX0000001 through XX0000005) trigger distinct scenarios; any other XX* number returns the default invalid result. A non-XX number with a test key returns 400 INVALID_FORMAT on the single endpoint; in a bulk job, the same rejection is a per-position INVALID_FORMAT error on an otherwise-accepted job, not a 400 for the whole submission.

Will test-mode requests count against my monthly quota or plan limits?

No. Test calls never count against your monthly quota and are never written to your validation history. The only record kept is a one-time timestamp of your first test call, used internally for onboarding — it doesn't reflect the specific VAT number or result you tested.

Can I get a consultationNumber back from a test-mode check?

No. consultationNumber is always null in test mode, on every fixture, regardless of whether your account has a requester VAT number configured. It's only ever populated on a live VIES consultation.

Next step

Every account already has a test key sitting in your dashboard's API Keys section — you don't need to request one or wait for approval. Point your integration at the VAT API or the VIES API reference for the full field list, build against the XX fixtures above, and swap in a live key once your error handling passes.

Get a test key and a live key from the same account

Sign up and vatnode creates your vat_test_ key automatically, ready to hit the fixtures above. The full API reference covers what changes when you switch to a live key.

Get your free API key