Official TypeScript + Node.js SDK for the vatverify VAT validation API. VIES goes down on Tuesdays, HMRC rate-limits, and the Swiss UID register speaks SOAP. One typed client handles all three.
- 🇪🇺 EU-27 via VIES
- 🇬🇧 UK via HMRC
- 🇨🇭 Switzerland / Liechtenstein via BFS (UID register)
- 🇳🇴 Norway via Brønnøysundregistrene
- 🇩🇪 Germany via BZSt eVatR — §18e qualified confirmation (
client.confirm()) - Freshness-aware responses (
live/cached/degraded) so a registry outage never 502s your checkout /decidetax-rules engine for reverse-charge invoice decisions- Runs on Node.js 22+, Bun, Deno, Vercel Edge, and Cloudflare Workers, with zero runtime dependencies
npm install @vatverify/node| Country | Registry | Transport |
|---|---|---|
| EU-27 | VIES | SOAP |
| XI (Northern Ireland) | VIES | SOAP |
| UK (GB) | HMRC | REST |
| CH / LI | BFS (Swiss UID) | SOAP |
| NO | Brønnøysundregistrene | REST |
| DE (§18e qualified confirmation) | BZSt eVatR | REST |
Live rolling 30-day uptime and p50/p95 latency per registry: vatverify.dev/status. All numbers come from the public GET /v1/status.json endpoint, with no made-up SLAs.
Northern Ireland VATs use the XI prefix under the Brexit protocol; they validate through VIES like any EU member. GB numbers route to HMRC.
import { Vatverify } from '@vatverify/node';
const client = new Vatverify('vtv_live_...');
const { data, meta } = await client.validate({ vat_number: 'IE6388047V' });
console.log(data.valid, data.company?.name);
console.log('latency:', meta.latency_ms, 'ms');
console.log('freshness:', meta.source_status); // 'live' | 'cached' | 'degraded'Get an API key at vatverify.dev. Free tier: 500 live validations / month plus unlimited test-mode calls, no credit card.
Validate a single VAT number. Available on every plan.
const { data, meta } = await client.validate({
vat_number: 'IE6388047V',
cache: false, // optional: bypass the 30-day cache
requester_vat_number: 'DE100000001', // optional: consultation number / audit trail
});Validate up to 50 VAT numbers in one request. Requires the Pro or Business plan.
const { data, meta } = await client.validateBatch({
vat_numbers: ['IE6388047V', 'DE811569869', 'FR44732829320'],
});
console.log(`${data.summary.successful}/${data.summary.total} succeeded`);
for (const item of data.results) {
if (item.ok) console.log(item.data.vat_number, '→', item.data.valid);
else console.log('error:', item.error.code, item.error.message);
}The differentiator. Answers "should I charge VAT on this invoice, or is it reverse-charge / out-of-scope?" and returns the legal basis plus the exact invoice_note string to print on the invoice. Requires the Business plan.
// DE seller → FR B2B buyer: intra-EU reverse charge
const { data } = await client.decide({
seller_vat: 'DE123456789',
buyer_vat: 'FR44732829320',
});
data.mechanism; // 'reverse_charge'
data.invoice_note; // 'Reverse charge: VAT to be accounted for by the recipient (Art. 196 VAT Directive).'
data.legal_basis; // 'EU Directive 2006/112/EC, Art. 196'// DE seller → DE B2B buyer: domestic, standard VAT
await client.decide({ seller_vat: 'DE123456789', buyer_vat: 'DE811569869' });
// → { mechanism: 'standard', rate: 19, ... }// DE seller → non-EU buyer: out of scope
await client.decide({ seller_vat: 'DE123456789', buyer_country: 'US' });
// → { mechanism: 'out_of_scope', invoice_note: '...' }mechanism is one of 'standard', 'reverse_charge', 'zero_rated', 'out_of_scope'. Both VATs are validated against their live registries in the same call, so you get validation + decision for one quota unit, pooled with /validate.
German sellers shipping inside the EU need a qualifizierte Bestätigungsmitteilung from the Bundeszentralamt für Steuern (BZSt) as legal evidence under §18e UStG. The SDK calls BZSt's eVatR endpoint, returns a per-field A/B/C/D match grid (name / street / postcode / town), and stores the result for 10 years per German law. Business plan only.
const { data, meta } = await client.confirm({
vat_number: 'NL007051104B01',
company: {
name: 'ASML Netherlands B.V.',
street: 'De Run 6501',
postcode: '5504DR',
town: 'Veldhoven',
},
});
data.qualified; // true only if every supplied field returned 'A'
data.matches.name; // 'A' | 'B' | 'C' | 'D'
data.confirmation_id; // your evidence id — retrievable for 10 years
meta.bzst_status_code; // 'evatr-0000' on full match
meta.bzst_id; // BZSt's own request identifier (independent evidence)Retrieve a stored confirmation later:
const { data } = await client.confirmations.get(confirmationId);Match codes: A matches, B does not match, C not requested, D not provided by the foreign EU registry. The requester VAT (a German VAT-IdNr.) defaults to the one stored on the API key; override per-call with requester_vat_number. Pass an idempotency_key UUID to safely retry on network flakes — replays return the same confirmation_id for 24h.
Every /validate, /validate_batch, and /decide response is persisted by request_id. Retrieve the exact response envelope later for tax-audit evidence or dispute resolution.
const { data } = await client.audits.get('019dbc5f-6191-77f6-b3c1-ed4ba503bc44');
data.endpoint; // 'validate' | 'decide' | 'validate_batch'
data.response; // original response body
data.created_at; // ISO timestamp
data.expires_at; // retention cutoffRetention: 7 days (Starter), 30 days (Pro), 90 days (Business). Free plan keys return 404. Full docs: vatverify.dev/docs/api/audit/get-audit-log.
const { data } = await client.rates.list();
console.log(`${data.length} countries`);
const { data: de } = await client.rates.get('de');
console.log(de.standard_rate, de.currency); // 19 EURRates endpoints are public, no auth required. For fully offline rates plus format/checksum validation (no API call at all), use @vatverify/vat-rates instead.
Every registry fails in its own way. The SDK and API surface what's happening so your code can respond:
meta.source_status: 'live': fresh response from the registry.meta.source_status: 'cached': served from the 30-day cache (within freshness window).meta.source_status: 'degraded': the registry failed live, so the response was served from the fallback cache window. The VAT is still validated, but treat the answer as "last known good" rather than real-time.
This means a VIES outage doesn't break your checkout: the request returns a degraded response instead of a 502. The public status page (vatverify.dev/status) shows the live state of every registry.
The SDK retries on network errors, timeouts, 429, 502, 503, and 504: up to 2 retries (3 total attempts), exponential backoff with jitter, capped at 2s. Retry-After on 429 takes precedence (capped at 30s). Retries never fire on 400, 401, 402, 404, which are caller errors.
// disable retries globally or per request
const client = new Vatverify({ api_key: '...', max_retries: 0 });
await client.validate(input, {
request_options: { max_retries: 0, timeout: 5000, signal: controller.signal },
});Test keys (vtv_test_...) exercise the full API deterministically without consuming quota or hitting registries:
const client = new Vatverify('vtv_test_...');
await client.validate({ vat_number: 'IE6388047V' }); // valid, Apple Distribution International Ltd
await client.validate({ vat_number: 'DE811569869' }); // valid, Zalando SE
await client.validate({ vat_number: 'FR44732829320' }); // valid, BlaBlaCar SAS
await client.validate({ vat_number: 'IE0000000X' }); // invalid, no company data
await client.validate({ vat_number: 'DE999999999' }); // 502 registry_unavailable
// any other well-formed VAT → valid, synthesized "Magic Corp (XX)"Full fixture list at vatverify.dev/docs/test-mode. Test-mode calls are unlimited on every plan including free.
// string shorthand (common case)
const client = new Vatverify('vtv_live_...');
// or full config
const client = new Vatverify({
api_key: 'vtv_live_...',
base_url: 'https://api.vatverify.dev', // default
timeout: 30_000, // default: 30s per attempt
});
// or VATVERIFY_API_KEY env var
const client = new Vatverify();Advanced options (max_retries, fetch, user_agent_extra, on_response hook) are documented at vatverify.dev/docs/sdks/node.
import {
VatverifyError,
AuthError, ValidationError, NotFoundError, PlanError,
RateLimitError, RegistryError, NetworkError, TimeoutError,
} from '@vatverify/node';
try {
await client.validate({ vat_number: 'xxx' });
} catch (e) {
if (e instanceof RateLimitError) {
console.log(`Retry after ${e.retry_after}s; remaining: ${e.rate_limit.remaining}`);
} else if (e instanceof RegistryError) {
console.log('Upstream registry failed; response was served degraded or unavailable');
} else if (e instanceof AuthError) {
console.log('Rotate the API key');
} else if (e instanceof VatverifyError) {
console.log(e.code, e.status_code, e.request_id, e.attempt_count);
}
}Every error exposes code, status_code, request_id (quote this in support tickets), response_body, and attempt_count. RateLimitError additionally carries retry_after and rate_limit: { limit, remaining, reset }.
| Runtime | Supported |
|---|---|
| Node.js 22+ | ✅ |
| Bun | ✅ |
| Deno | ✅ |
| Vercel Edge | ✅ |
| Cloudflare Workers | ✅ |
| Browsers (direct API key) | ❌ (API keys must stay server-side) |
Zero runtime dependencies. Uses only fetch, AbortController, URL, Headers.
Types ship with the package and are auto-generated from the production OpenAPI spec. Every method is fully typed end-to-end:
import type { ValidateResponse, CountryRate, BatchResultItem, DecideResponse } from '@vatverify/node';MIT. See LICENSE.