# vatverify: full documentation & guides REST API for VAT number validation across 32 countries. Real checksums, 30-day caching, tax-rules engine. --- # Documentation ## Authentication API keys, Bearer tokens, and the difference between test-mode and live-mode keys. All requests require an API key passed as a Bearer token in the `Authorization` header. ## Key types | Prefix | Purpose | Counts against quota | |---|---|---| | `vtv_live_` | Production traffic, real registry calls | Yes | | `vtv_test_` | Testing, CI, local dev (deterministic magic numbers) | No, unlimited | Both are issued the same way from your dashboard. ## Making an authenticated request ```bash curl "https://api.vatverify.dev/v1/validate?vat_number=DE811569869" \ -H "Authorization: Bearer vtv_live_xxx..." ``` ## Error responses - `401 unauthorized`: key missing, invalid format, or revoked - `402 plan_required`: key valid but the endpoint requires a higher plan (e.g., `/v1/decide` requires Business) See [Error codes](/docs/errors) for the full list. ## Key rotation Rotate keys from the dashboard. Revoked keys stop working within 60 seconds (our auth cache TTL). Source: https://www.vatverify.dev/docs/authentication --- ## Caching 30-day TTL for valid responses, 24-hour TTL for invalid. How to bypass cache when you need a fresh lookup. Every successful validation is cached. Hitting the cache returns in ~2ms and doesn't count against your quota. ## TTLs | Response type | TTL | |---|---| | `valid: true` | 30 days | | `valid: false` | 24 hours | | `registry_unavailable` | 5 minutes (soft fail) | Why 30 days? Most companies' VAT status doesn't change often, but monthly re-verification is a common compliance requirement. 30 days lets you cache for ~one month with a buffer for business logic. ## Detecting cache hits Every response includes `meta.cached` and `meta.source_status`: ```json { "data": { "valid": true, "vat_number": "IE6388047V", "verified_at": "2026-04-10T09:14:29Z" }, "meta": { "cached": true, "source": "vies", "source_status": "cached", "latency_ms": 2, "request_id": "a1b2c3d4-..." } } ``` `data.verified_at` always reflects the original registry fetch time, even on cache hits. `meta.source_status` is `"live"` for fresh lookups, `"cached"` for cache hits, or `"degraded"` when the registry was unavailable and we fell back to a cached result. ## Bypassing cache For compliance workflows requiring a fresh registry call, add `cache=false` as a query parameter: ```bash curl "https://api.vatverify.dev/v1/validate?vat_number=IE6388047V&cache=false" \ -H "Authorization: Bearer vtv_live_xxx" ``` Each bypass counts as a live registry call (uses quota). Use sparingly. Source: https://www.vatverify.dev/docs/caching --- ## Introduction vatverify, the VAT validation API rebuilt for modern stacks. Get started in under 2 minutes. vatverify is a REST API for validating VAT numbers across **32 countries**: EU-27, UK, Switzerland, Liechtenstein, Norway. One endpoint wraps the official registries (VIES, HMRC, BFS UID, Brønnøysund) with real checksum validation, 30-day caching, and a tax-rules engine. ## New here? - **[Quickstart](/docs/quickstart):** first request in 2 minutes - **[Authentication](/docs/authentication):** API keys and test mode - **[API reference](/docs/api/validation/validate-vat):** every endpoint, every parameter ## Questions? - Pricing: see [vatverify.dev/pricing](/pricing) - Contact: email [hello@vatverify.dev](mailto:hello@vatverify.dev) Source: https://www.vatverify.dev/docs --- ## Quickstart Make your first VAT validation request in under 2 minutes. Sign up, grab a test key, paste the snippet. ## 1. Get a test API key Sign up at [vatverify.dev/signup](https://vatverify.dev/signup). Your dashboard shows a test key like `vtv_test_xxx`. Use this one while you're integrating. Test keys skip the plan quota entirely and return deterministic responses. Perfect for CI. ## 2. Validate your first VAT number ```bash title="cURL" curl "https://api.vatverify.dev/v1/validate?vat_number=IE6388047V" \ -H "Authorization: Bearer vtv_test_xxx" # { "data": { "valid": true, "company": { "name": "Apple Distribution International Ltd", ... }, ... }, ... } ``` ## Next steps - **Switch to a live key** before production: see [Authentication](/docs/authentication) - **Understand quotas:** see [Rate limits](/docs/rate-limits) - **Explore the full API:** see [API reference](/docs/api/validation/validate-vat) Source: https://www.vatverify.dev/docs/quickstart --- ## Rate limits Monthly quotas per plan, what happens at the limit, and how to read X-RateLimit headers. Every plan has a **monthly quota** and a **burst rate limit**. Cache hits are free and don't count toward either. ## Plan quotas | Plan | Monthly quota | Burst rate | API keys | |---|---|---|---| | Free | 500 | 30 req/min | 2 | | Starter | 2,000 | 45 req/min | 5 | | Pro | 10,000 | 90 req/min | 10 | | Business | 50,000 | 180 req/min | Unlimited | Test-mode keys (`vtv_test_`) bypass all plan gates entirely. ## Burst rate limiting Each API key is limited to a per-minute request rate based on its plan. The burst check runs before the monthly quota check. If you exceed the burst limit, you get a 429 with a `Retry-After` header indicating when the current minute window resets. Burst limits protect upstream registries from traffic spikes. Cached responses are fast (under 2ms) and don't count against burst limits. ## `/v1/rates` endpoint The public `/v1/rates` endpoint has a separate IP-based rate limit of 20 requests per hour for unauthenticated callers. Authenticated callers (any valid API key) get unlimited access to rates data. ## Response headers Every successful response includes: ``` X-RateLimit-Limit: 10000 X-RateLimit-Remaining: 9876 X-RateLimit-Reset: 1714521600 ``` `Reset` is a Unix timestamp for when your quota resets (always 00:00 UTC on the 1st of the next month). ## Hitting the limit When you exceed quota, we return: ```json title="429 Too Many Requests" { "error": { "code": "rate_limited", "message": "Monthly quota exceeded." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` Also with `Retry-After: ` header. **What counts**: one live registry call per request. Cache hits are free. Test-mode calls are free. Source: https://www.vatverify.dev/docs/rate-limits --- ## Reliability How to handle registry outages. What live, cached, and degraded mean in API responses. National VAT registries have bad days. VIES has historically gone offline most Tuesdays for maintenance. HMRC rate-limits aggressively. BFS speaks SOAP and occasionally times out. The vatverify API is designed so a registry outage never returns a 502 to your checkout; instead, the response carries a freshness signal you can act on. ## Three freshness states Every response includes `meta.source_status`. There are exactly three values: | `source_status` | Meaning | Quota | Latency | |---|---|---|---| | `live` | Fresh response straight from the registry | 1 unit | 200ms–2s | | `cached` | Served from the 30-day cache (within freshness window) | 0 units | ~2ms | | `degraded` | Registry failed live; served from the fallback cache (last known good) | 0 units | ~2ms | `live` and `cached` are the happy paths. `degraded` is the one to handle. ## What `degraded` means When a registry is unreachable, vatverify returns the most recent successful lookup for that VAT number rather than a `502 registry_unavailable`. The VAT number is still validated against real registry data, just *last known good* rather than *real time*. ```json { "data": { "valid": true, "vat_number": "IE6388047V", "verified_at": "2026-04-22T08:14:29Z", "company": { "name": "Apple Distribution International Ltd" } }, "meta": { "cached": true, "source": "vies", "source_status": "degraded", "latency_ms": 3, "request_id": "a1b2c3d4-..." } } ``` `data.verified_at` always reflects when the data was originally fetched from the registry. On a `degraded` response that's the last time the registry answered for this VAT number, which could be hours or days old. ## How to handle it For most use cases (onboarding flows, Stripe customer creation, checkout), `degraded` should be treated the same as `live`. The whole point is your funnel doesn't stall because VIES is having a Tuesday. ```typescript const result = await vat.validate({ vat_number: 'IE6388047V' }); if (result.data.valid) { // ship it (works for live, cached, and degraded) await createCustomer(result.data); } ``` For compliance-sensitive workflows (tax audit evidence, invoice issuance with reverse-charge), you may want to flag or queue degraded responses for re-verification once the registry is back. ```typescript if (result.meta.source_status === 'degraded') { await queueForReVerification(result.data.vat_number); // still proceed, but mark the record for follow-up } ``` ## Why not just retry? The SDK already retries `502`, `503`, `504` with exponential backoff (see [Retries](/docs/sdks/node#retries)). When the registry is genuinely down for hours, retrying won't help. You'd just stack latency before failing. The `degraded` fallback gives you a useful answer in 2ms instead of a timeout in 30 seconds. If you want hard-fail behavior instead, use `cache=false` to force a live registry call. That bypasses both the cache and the degraded fallback, and will return `502 registry_unavailable` when the registry is down. ```typescript await vat.validate({ vat_number: 'IE6388047V', cache: false }); ``` ## Live status Real registry uptime and latency for the past 30 days, broken out per registry: [vatverify.dev/status](/status). All numbers come from the public [`GET /v1/status.json`](/docs/api/status/get-status) endpoint, no marketing SLAs. Source: https://www.vatverify.dev/docs/reliability --- ## Response format Every field in the success, error, and batch response envelopes. Every response from the vatverify API uses the same outer envelope: a `data` or `error` object, always accompanied by a `meta` block. ## Success envelope ```json { "data": { "valid": true, "vat_number": "IE6388047V", "country": { "code": "IE", "name": "Ireland", "vat": { "name": "Value Added Tax", "abbreviation": "VAT", "currency": "EUR", "standard_rate": 23, "reduced_rates": [9, 13.5], "super_reduced_rate": 4.8, "parking_rate": 13.5, "number_format": "IE + 7 digits + 1-2 letters", "number_pattern": "^IE\\d{7}[A-Z]{1,2}$", "updated_at": "2026-04-01" } }, "company": { "name": "GOOGLE IRELAND LIMITED", "address": "3RD FLOOR, GORDON HOUSE, BARROW STREET, DUBLIN 4" }, "verify_id": "WAPIAAAAZ2Re-K4H", "verified_at": "2026-04-15T14:11:49.110Z" }, "meta": { "request_id": "019d917e-4fa4-766e-9e0f-d977b47bf2c6", "latency_ms": 2310, "cached": false, "source": "vies", "source_status": "live" } } ``` ### data fields | Field | Type | Description | |---|---|---| | `data.valid` | boolean | `true` if the VAT number is registered and active with the registry. `false` if not registered. | | `data.vat_number` | string | Normalized form of the input: uppercase, no spaces or punctuation. | | `data.country` | object | Country metadata: `code`, `name`, and a `vat` object with rates and format info (see below). | | `data.company` | object \| null | `name` and `address` from the registry. `null` when the registry withholds data (e.g. Germany withholds company details by policy). | | `data.verify_id` | string \| null | Official consultation identifier from the registry (VIES consultation number or HMRC reference). Populated only when `requester_vat_number` is provided and the source supports it. `null` for CH, LI, and NO lookups. Tax auditors accept this as proof of verification. | | `data.verified_at` | string | ISO 8601 timestamp of the original registry fetch. Unchanged on cache hits, always reflects when the data was actually retrieved. | ### meta fields | Field | Type | Description | |---|---|---| | `meta.request_id` | string | UUID for this request. Include it when contacting support. | | `meta.latency_ms` | number | Server-side processing time in milliseconds. ~2ms on cache hits, 500–3000ms on live registry calls. | | `meta.cached` | boolean | `true` if this result was served from cache. Cache hits don't count toward your monthly quota. | | `meta.source` | string | Which registry answered: `vies`, `hmrc`, `bfs`, or `brreg`. | | `meta.source_status` | string | `live`: fresh from the registry. `cached`: served from cache. `degraded`: registry was unavailable and we fell back to a cached result. | ## Error envelope ```json { "error": { "code": "rate_limited", "message": "Monthly quota of 500 exceeded." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` | Field | Type | Description | |---|---|---| | `error.code` | string | Machine-readable error code. See [Error reference](/docs/errors). | | `error.message` | string | Human-readable description. Safe to log; do not display directly to end users. | | `meta.request_id` | string | UUID for the failed request. | | `meta.latency_ms` | number | Always `0` for auth and format errors (rejected before processing). | ## Response headers Every response includes: | Header | Description | |---|---| | `X-RateLimit-Limit` | Your plan's monthly quota. | | `X-RateLimit-Remaining` | Calls remaining this month. | | `X-RateLimit-Reset` | Unix timestamp when quota resets (00:00 UTC on the 1st of next month). | | `X-Request-Id` | Same UUID as `meta.request_id`. Useful for logging before parsing the body. | On `429` responses, a `Retry-After` header is also included (seconds until reset). ## Batch envelope Batch responses wrap per-item results in a summary: ```json { "data": { "summary": { "total": 3, "successful": 2, "failed": 1 }, "results": [ { "ok": true, "data": { "..." : "..." } }, { "ok": false, "error": { "code": "registry_unavailable", "message": "..." } }, { "ok": true, "data": { "..." : "..." } } ] }, "meta": { "..." : "..." } } ``` Each item in `results` is a discriminated union on `ok`: - `ok: true` → `data` contains a full validation result (same shape as the single validate response `data` field) - `ok: false` → `error` contains `code` and `message` for that item only Per-item errors (`invalid_format`, `country_unsupported`, `registry_unavailable`) do not fail the whole batch. Auth, plan, and rate-limit errors fail the whole request before any items are processed. See [POST /v1/validate/batch](/docs/api/validation/validate-batch) for the full endpoint reference. Source: https://www.vatverify.dev/docs/response-format --- ## Supported countries vatverify validates VAT numbers across 32 countries via 4 official registries: EU-27, UK, Switzerland, Liechtenstein, and Norway. vatverify validates VAT numbers across **32 countries** via 4 official registries. ## Coverage | Flag | Country | Code | Registry | Status | |------|---------|------|----------|--------| | 🇦🇹 | Austria | AT | VIES | Live | | 🇧🇪 | Belgium | BE | VIES | Live | | 🇧🇬 | Bulgaria | BG | VIES | Live | | 🇭🇷 | Croatia | HR | VIES | Live | | 🇨🇾 | Cyprus | CY | VIES | Live | | 🇨🇿 | Czech Republic | CZ | VIES | Live | | 🇩🇰 | Denmark | DK | VIES | Live | | 🇪🇪 | Estonia | EE | VIES | Live | | 🇫🇮 | Finland | FI | VIES | Live | | 🇫🇷 | France | FR | VIES | Live | | 🇩🇪 | Germany | DE | VIES | Live | | 🇬🇷 | Greece | EL | VIES | Live | | 🇭🇺 | Hungary | HU | VIES | Live | | 🇮🇪 | Ireland | IE | VIES | Live | | 🇮🇹 | Italy | IT | VIES | Live | | 🇱🇻 | Latvia | LV | VIES | Live | | 🇱🇹 | Lithuania | LT | VIES | Live | | 🇱🇺 | Luxembourg | LU | VIES | Live | | 🇲🇹 | Malta | MT | VIES | Live | | 🇳🇱 | Netherlands | NL | VIES | Live | | 🇵🇱 | Poland | PL | VIES | Live | | 🇵🇹 | Portugal | PT | VIES | Live | | 🇷🇴 | Romania | RO | VIES | Live | | 🇸🇰 | Slovakia | SK | VIES | Live | | 🇸🇮 | Slovenia | SI | VIES | Live | | 🇪🇸 | Spain | ES | VIES | Live | | 🇸🇪 | Sweden | SE | VIES | Live | | 🇬🇧 | Northern Ireland | XI | VIES | Live | | 🇬🇧 | United Kingdom | GB | HMRC | Sandbox | | 🇨🇭 | Switzerland | CH | BFS UID | Live | | 🇱🇮 | Liechtenstein | LI | BFS UID | Live | | 🇳🇴 | Norway | NO | Brønnøysund | Live | ## Registries ### VIES: EU-27 + Northern Ireland The European Commission's VAT Information Exchange System. Covers all 27 EU member states plus Northern Ireland (XI prefix, active under the Brexit protocol). Pass `requester_vat_number` on any VIES lookup to trigger an authenticated consultation. The response will include a `verify_id` (the official VIES consultation number), which tax auditors accept as documented proof of verification. ### HMRC: United Kingdom Her Majesty's Revenue and Customs REST API, authenticated via OAuth 2.0 client credentials. Currently operating in sandbox mode. Production credentials were applied for on 2026-04-13 and are under review (~10 working days). UK lookups will return live registry data once production credentials are active. ### BFS UID Register: Switzerland and Liechtenstein Switzerland and Liechtenstein share a single VAT territory. Both countries validate via the Swiss Federal Statistical Office UID register (uid-wse V5.0 SOAP). Liechtenstein VAT numbers are validated as CHE numbers. ### Brønnøysund Register Centre: Norway Norway's public business register, accessible via a JSON REST API with no authentication required. ## Notes - Greece uses the code `EL`, not `GR`. Sending `GR` returns `country_unsupported`. - Switzerland and Liechtenstein share one VAT territory. A Liechtenstein VAT number is validated as a CHE number through the Swiss registry. - `verify_id` is only populated for VIES and HMRC lookups. Switzerland, Liechtenstein, and Norway lookups always return `verify_id: null`. - UK (GB) lookups currently use the HMRC sandbox. Results are realistic but not authoritative until production credentials are live. Source: https://www.vatverify.dev/docs/supported-countries --- ## Test mode Use vtv_test_* keys to validate VAT numbers deterministically, perfect for CI, local dev, and end-to-end tests. Test mode is deterministic. Given the same input, you get the same output: no network calls, no registry variability. ## Magic VAT numbers These numbers always return known responses: | VAT number | Response | |---|---| | `IE6388047V` | `valid: true`, Apple Distribution International Ltd | | `DE811569869` | `valid: true`, Zalando SE | | `FR44732829320` | `valid: true`, BlaBlaCar SAS | | `IE0000000X` | `valid: false`, no company data | | `DE999999999` | 502 `registry_unavailable` | | Any other well-formed VAT | `valid: true`, synthesized "Magic Corp (XX)" | Malformed VAT numbers (`invalid_format`) and unsupported country codes (`country_unsupported`) return 400 errors in both test and live mode. The format check runs before the registry lookup. ## Example request ```bash title="cURL" curl "https://api.vatverify.dev/v1/validate?vat_number=IE6388047V" \ -H "Authorization: Bearer vtv_test_xxx" ``` ```json title="200 Response" { "data": { "valid": true, "vat_number": "IE6388047V", "country": { "code": "IE", "name": "Ireland" }, "company": { "name": "Apple Distribution International Ltd", "address": "Hollyhill Industrial Estate, Hollyhill, Cork" }, "verify_id": null, "verified_at": "2026-04-15T10:00:00Z" }, "meta": { "cached": false, "source": "vies", "source_status": "live", "latency_ms": 12, "request_id": "a1b2c3d4-..." } } ``` Test-mode keys never count against your quota and never hit the real registries. Source: https://www.vatverify.dev/docs/test-mode --- ## Webhooks Receive real-time notifications when validations and batch jobs complete, without polling the API. ## Overview Webhooks let vatverify push results to your server the moment they are ready. Instead of polling `/v1/validate` or checking batch status on a timer, you register an HTTPS endpoint and vatverify sends an HTTP POST to it whenever a relevant event occurs. This is especially useful for batch jobs, where results may take several seconds to produce. Your endpoint receives the full response payload, the same data you would get by calling the API directly. Webhooks require a **Pro or Business plan**. Test-mode API keys do not trigger delivery. --- ## Events vatverify currently emits two event types: | Event | Fired when | |---|---| | `validation.completed` | A single `/v1/validate` request finishes | | `batch.completed` | A `/v1/validate/batch` job finishes processing all numbers | --- ## Payload structure Every webhook POST has the same envelope: ```json { "event": "validation.completed", "created_at": "2026-04-01T12:34:56.789Z", "data": { "data": { "valid": true, "vat_number": "DE811569869", "country": { "code": "DE", "name": "Germany" }, "company": { "name": "Zalando SE", "address": "Tamara-Danz-Str. 1, 10243 Berlin" }, "verified_at": "2026-04-01T12:34:55.000Z" }, "meta": { "request_id": "0190f8ea-a5b2-7000-a123-000000000000", "cached": false, "source": "vies", "source_status": "live", "latency_ms": 312 } } } ``` The inner `data` field is the complete API response, identical to what you would receive if you called `/v1/validate` directly. For `batch.completed` events, it contains the full batch result object. The per-delivery unique ID is carried on the `Vatverify-Delivery-Id` header, not inside the payload. --- ## Verifying signatures Every webhook request includes four headers: - `Vatverify-Timestamp`: Unix timestamp (seconds) at delivery time - `Vatverify-Signature`: HMAC-SHA256 hex digest, prefixed with `sha256=` - `Vatverify-Event`: event name (e.g. `validation.completed`) - `Vatverify-Delivery-Id`: UUID v7 uniquely identifying this delivery attempt The signature is computed over the string `{timestamp}.{raw_body}` using the signing secret returned when you created the endpoint, then prefixed with the literal string `sha256=`. Strip the prefix before comparing hex bytes. **Always verify the signature before processing a webhook.** This confirms the request originated from vatverify and was not tampered with in transit. vatverify rejects replays: if the timestamp is more than 5 minutes old, consider the request invalid regardless of signature validity. ```typescript import { createHmac, timingSafeEqual } from "crypto" export function verifyWebhookSignature( rawBody: string, timestamp: string, signature: string, secret: string, ): boolean { const fiveMinutes = 5 * 60 * 1000 const requestTime = parseInt(timestamp, 10) * 1000 if (Date.now() - requestTime > fiveMinutes) return false // Incoming header is "sha256="; strip the prefix before comparing. const receivedHex = signature.startsWith("sha256=") ? signature.slice("sha256=".length) : signature const payload = `${timestamp}.${rawBody}` const expectedHex = createHmac("sha256", secret).update(payload).digest("hex") const expectedBuf = Buffer.from(expectedHex, "hex") const receivedBuf = Buffer.from(receivedHex, "hex") if (expectedBuf.length !== receivedBuf.length) return false return timingSafeEqual(expectedBuf, receivedBuf) } ``` Use this in your endpoint handler before doing anything with the payload: ```typescript // Next.js App Router example export async function POST(req: Request) { const rawBody = await req.text() const timestamp = req.headers.get("Vatverify-Timestamp") ?? "" const signature = req.headers.get("Vatverify-Signature") ?? "" const valid = verifyWebhookSignature( rawBody, timestamp, signature, process.env.VATVERIFY_WEBHOOK_SECRET!, ) if (!valid) return new Response("Unauthorized", { status: 401 }) const event = JSON.parse(rawBody) // handle event.event, event.data ... return new Response(null, { status: 204 }) } ``` --- ## Delivery reliability Webhooks are delivered through a durable message queue. If your endpoint does not respond with a 2xx status within 10 seconds, delivery will be retried up to **3 times** with exponential back-off before the attempt is marked as failed. To avoid retries, respond with a 2xx as quickly as possible, even before you finish processing the event. Enqueue heavy work asynchronously. --- ## Managing endpoints Use the API to register and manage your webhook endpoints: - [Create webhook](/docs/api/webhooks/create-webhook): register a new endpoint and receive its signing secret - [List webhooks](/docs/api/webhooks/list-webhooks): view all registered endpoints (secrets not returned) - [Delete webhook](/docs/api/webhooks/delete-webhook): remove an endpoint immediately - [Test webhook](/docs/api/webhooks/test-webhook): fire a `test` event directly to verify reachability Each API key may have up to **5 registered endpoints**. Source: https://www.vatverify.dev/docs/webhooks --- ## b2c_not_supported The /decide endpoint only handles B2B transactions. B2C is not supported in v1. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "b2c_not_supported", "message": "B2C transactions are not supported. /decide handles B2B only." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/decide` is designed for B2B transactions, where both parties are VAT-registered businesses. B2C (business-to-consumer) involves different rules in each country that are out of scope for v1. ## How to fix For B2C transactions, apply your seller country's **standard VAT rate** to the sale. You can retrieve this from `GET /v1/rates/:country`: ```bash curl https://api.vatverify.dev/v1/rates/de # Returns standard_rate: 19 for Germany ``` The buyer's location determines the rate for digital services (OSS rules), but for most physical goods, the seller's country rate applies. ## Why /decide is B2B-only `/v1/decide` answers "should I charge VAT, and which country's?" by combining the EU VAT Directive's reverse-charge logic with the buyer-side validity check. The reverse-charge mechanism is fundamentally a B2B concept under Articles 138 and 196 of the Directive: it triggers on a valid VAT-registered counterparty. B2C falls outside that mechanism and into a different decision tree: - **Distance sales of goods** route through OSS once the EU-wide €10,000 threshold is crossed. - **Digital services to EU consumers** use the destination-country rate under place-of-supply rules and report through OSS. - **Local B2C sales** apply the seller's domestic rate. Each of those branches has different evidence requirements (location proofs, threshold tracking, OSS registration status) that v1 does not model. Folding them into `/decide` would change the contract substantially. ## How to detect B2C client-side If you call `/v1/decide` without a buyer VAT number, the API treats the transaction as B2C and returns this error. The check on your side is simple: if `buyer_vat` is empty, do not call `/decide`. Use `/v1/rates/:country` instead and apply the seller-country logic. See the [reverse charge glossary](/glossary/reverse-charge) for the rule that drives the B2B-only design, and the [OSS glossary](/glossary/oss) for the B2C path. ## Common mistakes - **Calling `/decide` when the buyer has no VAT number**: if there is no `buyer_vat`, the transaction is B2C. Use the rates endpoint instead. - **Expecting `/decide` to handle all transaction types**: it is intentionally scoped to EU B2B only in v1. ## Related errors - [`seller_country_unsupported`](/docs/errors/seller-country-unsupported): seller country is outside EU-27 - [`buyer_vat_not_registered`](/docs/errors/buyer-vat-not-registered): buyer has a VAT number but it is not registered - [`country_unknown`](/docs/errors/country-unknown): country code not in the rates dataset Source: https://www.vatverify.dev/docs/errors/b2c-not-supported --- ## batch_too_large The vat_numbers array contains more than 50 items. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "batch_too_large", "message": "Batch size 73 exceeds the maximum of 50 items per request." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/validate/batch` accepts a maximum of 50 VAT numbers per request. Sending more than 50 items in `vat_numbers` is rejected immediately before any validation is attempted. ## How to fix 1. Split your input into chunks of at most 50 items. 2. Make multiple batch requests, one per chunk. 3. Cache results on your side to avoid re-validating the same numbers. ```typescript // Example: chunk an array into batches of 50 const BATCH_SIZE = 50; const chunks = []; for (let i = 0; i < vatNumbers.length; i += BATCH_SIZE) { chunks.push(vatNumbers.slice(i, i + BATCH_SIZE)); } const results = await Promise.all( chunks.map(chunk => client.validateBatch({ vat_numbers: chunk })) ); ``` ## Common mistakes - **Assuming the API truncates to 50:** it rejects, it doesn't truncate. The whole request fails. - **Not chunking before sending:** always pre-split large arrays client-side. - **Ignoring cache hits:** cache hits don't count against quota. Filtering out recently-validated numbers before batching can significantly reduce costs. ## Related errors - [`rate_limited`](/docs/errors/rate-limited): batch was a valid size but quota was exhausted - [`plan_required`](/docs/errors/plan-required): batch endpoint requires Pro or Business plan Source: https://www.vatverify.dev/docs/errors/batch-too-large --- ## buyer_vat_not_registered The buyer_vat number passed to /decide is invalid or not registered with the registry. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "buyer_vat_not_registered", "message": "buyer_vat 'FR00000000000' is not a registered business." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/decide` validates the `buyer_vat` against the registry before computing the VAT treatment. If the buyer's VAT number is invalid (bad format or not registered), this error is returned. `/decide` requires a verified buyer. An unregistered buyer means the transaction is likely B2C. ## How to fix 1. Call `GET /v1/validate?vat_number=` first. If `valid: false`, the buyer is not a registered business. 2. If the buyer is unregistered, treat the transaction as B2C and apply the seller's standard rate (see [`GET /v1/rates/:country`](/docs/api/rates/get-rate)). 3. If you believe the VAT number is valid but getting this error, the buyer may be temporarily deregistered or the registry may be lagging. ## Common mistakes - **Not pre-validating the buyer:** always validate the buyer VAT before calling `/decide` to avoid this error in production. - **Using test magic numbers for the buyer:** test magic numbers simulate valid/invalid responses but are not "registered" in the VIES sense that `/decide` checks. - **Passing a buyer in a country where the registry is slow to update:** new businesses can take days to appear in VIES after registration. ## Related errors - [`seller_country_unsupported`](/docs/errors/seller-country-unsupported): buyer is fine but seller country is outside EU-27 - [`b2c_not_supported`](/docs/errors/b2c-not-supported): no buyer VAT at all (consumer transaction) Source: https://www.vatverify.dev/docs/errors/buyer-vat-not-registered --- ## bzst_rejected BZSt returned an unrecognised status code that does not map to a typed vatverify error. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "bzst_rejected", "message": "BZSt returned unrecognised status code: 9999.", "retryable": false }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 890, "bzst_status_code": "9999" } } ``` ## What happened BZSt responded with a status code that is not covered by vatverify's mapping of known eVatR codes. This is rare. It typically means either the BZSt API contract changed and introduced a new status code, or an edge case in the BZSt response was not anticipated in the current implementation. The raw BZSt status code is always surfaced in `meta.bzst_status_code` so you can record it and report it. ## How to fix 1. Open a bug report with vatverify support, providing the `request_id` and the value from `meta.bzst_status_code`. This allows the unknown code to be mapped and a typed error added. 2. As a workaround, fall back to a plain `/v1/validate` call for the same VAT number. This queries VIES rather than BZSt and will confirm whether the number is currently registered, though it will not produce a §18e qualified confirmation. 3. Do not retry `bzst_rejected` automatically. It is marked `retryable: false` because a retry against BZSt will produce the same unrecognised status. ## Related errors - [`bzst_unavailable`](/errors/bzst-unavailable): BZSt unreachable or in maintenance - [`bzst_server_error`](/errors/bzst-server-error): BZSt internal error on a known code ## See also - [BZSt](/glossary/bzst) - [/v1/confirm endpoint](/docs/api/confirmations/confirm) Source: https://www.vatverify.dev/docs/errors/bzst-rejected --- ## bzst_server_error BZSt accepted the request but returned an internal server error (evatr-2004, evatr-2011, evatr-3011). **HTTP `502` · Bad Gateway** ## Example response ```json { "error": { "code": "bzst_server_error", "message": "BZSt evatr-2004: internal server error.", "retryable": true }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 1620, "bzst_status_code": "2004" } } ``` ## What happened The BZSt eVatR endpoint received and processed the request but returned an internal error status: `2004`, `2011`, or `3011`. The request was well-formed and the connection to BZSt was successful. The failure originated inside the BZSt system. This error is distinct from [`bzst_unavailable`](/errors/bzst-unavailable), where the eVatR service itself was unreachable or in a maintenance state. Here the service was reachable but encountered a fault while handling the specific request. These codes occur rarely. They are most common during high-load periods at BZSt or immediately after a deployment on the BZSt side. ## How to fix 1. Retry once after a short delay (10-15 seconds). Most `bzst_server_error` responses resolve on the next attempt. 2. If the error persists across multiple retries, contact vatverify support and include the `request_id` from `meta`. This lets the support team correlate the BZSt status code with the upstream request log. 3. Do not discard the original request parameters. A persistent `bzst_server_error` on the same number may indicate a BZSt-side data issue for that specific VAT number that requires manual investigation. ## Related errors - [`bzst_unavailable`](/errors/bzst-unavailable): BZSt service unreachable or in maintenance - [`registry_unavailable`](/errors/registry-unavailable): general upstream registry outage ## See also - [BZSt](/glossary/bzst) - [Registry unavailable](/errors/registry-unavailable) Source: https://www.vatverify.dev/docs/errors/bzst-server-error --- ## bzst_session_limit The API hit the per-session quota for qualified confirmations against BZSt (evatr-0008). **HTTP `429` · Too Many Requests** ## Example response ```json { "error": { "code": "bzst_session_limit", "message": "BZSt evatr-0008: session request limit reached. Retry after 60 s." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 310, "bzst_status_code": "0008" } } ``` ## What happened BZSt returned status code `0008` from the eVatR service, indicating the per-session quota for qualified confirmations has been reached. This is a BZSt-side throttle, separate from your vatverify plan quota. It applies at the eVatR session level and can occur during bursts of `/v1/confirm` calls even when your vatverify monthly quota is nowhere near exhausted. Free-tier sessions are managed automatically, but high-frequency bursts can trigger this limit before the session is recycled. ## How to fix 1. Wait at least 60 seconds before retrying the same request. 2. If you are sending many confirmations in quick succession, add a delay between calls rather than firing them concurrently. 3. Use `X-RateLimit-Remaining` on vatverify responses to track your plan quota separately. `bzst_session_limit` is a BZSt-side limit, not a vatverify quota limit. 4. If you see this error repeatedly at moderate call volumes, contact support with the `request_id` so the session configuration can be reviewed. ## Related errors - [`rate_limited`](/errors/rate-limited): your vatverify plan quota exhausted - [`bzst_unavailable`](/errors/bzst-unavailable): BZSt unreachable or degraded ## See also - [BZSt](/glossary/bzst) - [Rate limited](/errors/rate-limited) - [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) Source: https://www.vatverify.dev/docs/errors/bzst-session-limit --- ## bzst_unavailable BZSt is temporarily unreachable or returned a degradation status (evatr-0011, evatr-1001 through evatr-1004). **HTTP `503` · Service Unavailable** ## Example response ```json { "error": { "code": "bzst_unavailable", "message": "BZSt evatr-1001: service temporarily unavailable.", "retryable": true }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 4200, "bzst_status_code": "1001" } } ``` ## What happened The BZSt eVatR service returned a degradation status code: `0011` (general unavailability), or one of `1001` through `1004` (various maintenance and overload states). The request reached vatverify correctly and was forwarded to BZSt, but BZSt could not fulfil it. This is distinct from [`bzst_server_error`](/errors/bzst-server-error), where BZSt accepted the request but failed internally. BZSt schedules maintenance windows, particularly outside German business hours. During these windows the eVatR endpoint returns unavailability codes rather than timing out. ## How to fix 1. Retry with exponential backoff. A starting interval of 30 seconds is appropriate for most maintenance windows. 2. Check [/status](/status) for the current BZSt registry health before assuming a persistent outage. 3. If your workflow is time-sensitive and BZSt is down, fall back to a plain VIES check via `/v1/validate`. A VIES check confirms the VAT number is currently registered but does not produce the qualified BZSt confirmation required by §18e UStG. 4. If `bzst_unavailable` persists beyond 30 minutes, check the [status page](/status) and consider queueing the confirmation for retry once the registry recovers. ## Related errors - [`bzst_server_error`](/errors/bzst-server-error): request reached BZSt but their server failed - [`registry_unavailable`](/errors/registry-unavailable): general upstream registry outage ## See also - [Registry status](/status) - [BZSt](/glossary/bzst) - [Registry unavailable](/errors/registry-unavailable) Source: https://www.vatverify.dev/docs/errors/bzst-unavailable --- ## country_unknown The country code passed to the rates endpoint does not exist in the dataset. **HTTP `404` · Not Found** ## Example response ```json { "error": { "code": "country_unknown", "message": "Country code 'xx' not found in rates dataset." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened You called `GET /v1/rates/:country` with a country code that does not exist in the VAT rates dataset. Unlike `country_unsupported` (which is about live validation), this error is specific to the rates endpoints. ## How to fix 1. Call `GET /v1/rates` (no country code) to get the full list of valid country codes. 2. Country codes in the rates endpoint are **lowercase**: use `de`, not `DE`. 3. For Greece, use `el` not `gr`. ## Country code conventions The rates dataset uses ISO 3166-1 alpha-2 codes in lowercase, with two intentional deviations that match the EU VAT system: - **`el`** for Greece (not `gr`). The EU VAT prefix uses `EL` derived from "Hellas". - **`xi`** for Northern Ireland (not part of ISO 3166). The XI prefix was created post-Brexit for Northern Ireland goods trade with the EU. Both deviations also apply when validating VAT numbers through `/v1/validate`. See the [VAT number glossary](/glossary/vat-number) for the full list of country code conventions. ## Common scenarios that hit this error - **Pulling rates from a hardcoded country list that includes non-EU codes.** Calling `/v1/rates/us` or `/v1/rates/jp` will return `country_unknown` because the rates dataset is scoped to the 32 supported countries (EU-27 plus XI, GB, CH, LI, NO). - **Uppercase codes**: `GET /v1/rates/DE` is rejected; the canonical form is lowercase. - **Country codes that exist on the validation side but not in rates**: in current versions of the API, all 32 validation-supported countries also have rates, so this divergence does not occur. If new validation-only or rates-only countries are added in future, this error is what surfaces the gap. ## Common mistakes - **Uppercase country codes**: `GET /v1/rates/DE` will fail; use `/v1/rates/de`. - **Using `gr` for Greece**: the correct code in the rates dataset is `el`. - **Confusing this with `country_unsupported`**: `country_unknown` only comes from the rates endpoints, not from validation. ## Related errors - [`country_unsupported`](/docs/errors/country-unsupported): country not supported for live validation - [`invalid_format`](/docs/errors/invalid-format): VAT number format does not match the country - [`not_found`](/docs/errors/not-found): generic resource-not-found Source: https://www.vatverify.dev/docs/errors/country-unknown --- ## country_unsupported The VAT number's country prefix maps to a country that vatverify does not support. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "country_unsupported", "message": "Country AU is not supported." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The two-letter country prefix on the VAT number (e.g. `AU123456789`) maps to a country outside our 32 supported. vatverify covers EU-27, UK, Switzerland, Liechtenstein, and Norway. Other countries return this error. ## How to fix 1. Check the [Supported Countries](/docs/supported-countries) page for the full list of valid country codes. 2. If you are validating a Greek VAT number, use `EL` not `GR`. Greece is the one EU member state that uses a non-ISO code in VIES. 3. For unsupported countries, apply offline format validation using [`@vatverify/vat-rates`](https://www.npmjs.com/package/@vatverify/vat-rates) which covers 44 countries with checksum algorithms. ## Common mistakes - **Using `GR` for Greece:** the correct VIES code is `EL`. This is the most common cause of unexpected `country_unsupported` errors from EU developers. - **Sending Australian ABN numbers:** AU is not yet supported for live validation. - **Passing a raw number without a country prefix:** the API requires the full VAT number including the country code prefix (e.g. `DE811569869`, not `811569869`). ## Related errors - [`country_unknown`](/docs/errors/country-unknown): country code not in the rates dataset (rates endpoints only) - [`invalid_format`](/docs/errors/invalid-format): the VAT number string itself is malformed Source: https://www.vatverify.dev/docs/errors/country-unsupported --- ## Error reference Complete list of error codes, HTTP status codes, and links to detailed guides for each. Every error response follows this envelope: ```json { "error": { "code": "unauthorized", "message": "Missing API key." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## Error codes | Code | HTTP | When | Guide | |---|---|---|---| | `unauthorized` | 401 | Missing / invalid / revoked API key | [→](/docs/errors/unauthorized) | | `plan_required` | 402 | Endpoint requires a higher plan | [→](/docs/errors/plan-required) | | `invalid_format` | 400 | Malformed body or missing required fields | [→](/docs/errors/invalid-format) | | `country_unsupported` | 400 | Country not in the 32 supported | [→](/docs/errors/country-unsupported) | | `country_unknown` | 404 | Country code not in the rates dataset | [→](/docs/errors/country-unknown) | | `batch_too_large` | 400 | More than 50 items in a batch request | [→](/docs/errors/batch-too-large) | | `webhook_limit_reached` | 400 | More than 5 webhook endpoints registered for this key | [→](/docs/errors/webhook-limit-reached) | | `seller_country_unsupported` | 400 | `/decide`: seller not in EU-27 | [→](/docs/errors/seller-country-unsupported) | | `b2c_not_supported` | 400 | `/decide`: B2C transactions not supported | [→](/docs/errors/b2c-not-supported) | | `buyer_vat_not_registered` | 400 | `/decide`: buyer VAT invalid or unregistered | [→](/docs/errors/buyer-vat-not-registered) | | `not_found` | 404 | Audit record or resource not found | [→](/docs/errors/not-found) | | `rate_limited` | 429 | Monthly quota exceeded | [→](/docs/errors/rate-limited) | | `registry_unavailable` | 502 | Upstream registry down or faulted | [→](/docs/errors/registry-unavailable) | ## Retry guidance - **429:** respect the `Retry-After` header. Don't busy-retry. - **502:** exponential backoff: 250ms → 500ms → 1s, max 3 retries. - **400 / 401 / 402 / 404:** do not retry. Fix the request or key. Source: https://www.vatverify.dev/docs/errors --- ## invalid_format The request body is malformed, a required field is missing, or a field has the wrong type. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "invalid_format", "message": "vat_numbers must be a non-empty array." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The request failed schema validation before reaching any registry. Common causes: a required field is absent, a field has the wrong type (e.g. a string where an array is expected), or the JSON body is syntactically invalid. ## How to fix 1. Check the [API reference](/docs/api/validation/validate-vat) for required fields on the endpoint you're calling. 2. For `GET /v1/validate`, `vat_number` is required as a query parameter. 3. For `POST /v1/validate/batch`, `vat_numbers` must be a non-empty array of strings. 4. For `POST /v1/decide`, both `seller_vat` and `buyer_vat` are required. 5. Verify your request includes `Content-Type: application/json` on POST requests. ## Common mistakes - **Sending `vat_number` as an array:** the single-validate endpoint takes a string, not an array. - **Omitting `Content-Type: application/json` on POST requests:** the body is read as empty without it. - **Sending an empty `vat_numbers: []` array to the batch endpoint:** minimum 1 item required. - **Invalid JSON syntax:** trailing commas, unquoted keys. Use a JSON validator before debugging the API. ## Related errors - [`country_unsupported`](/docs/errors/country-unsupported): format is valid but country isn't supported - [`batch_too_large`](/docs/errors/batch-too-large): array is valid but exceeds 50 items Source: https://www.vatverify.dev/docs/errors/invalid-format --- ## invalid_requester_vat The requester_vat_number supplied to /v1/confirm is malformed or fails the MOD-11 checksum required for §18e qualified confirmations. **HTTP `422` · Unprocessable Entity** ## Example response ```json { "error": { "code": "invalid_requester_vat", "message": "requester_vat_number 'DE12345678' is invalid: must be DE followed by 9 digits and pass MOD-11 check." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The `/v1/confirm` endpoint requires a valid German USt-IdNr. as `requester_vat_number` to submit a §18e qualified confirmation request to BZSt. BZSt mandates that the requester identify themselves with their own German VAT number so the confirmation can be recorded against their tax record. The number you supplied failed validation before the request was forwarded to BZSt. Common causes: - The number is too short or too long (must be exactly 11 characters: `DE` + 9 digits). - The final digit does not satisfy the MOD-11 checksum. - A Steuernummer (10-13 digits, no `DE` prefix) was supplied instead of the USt-IdNr. - The number contains spaces, dots, or other non-digit characters after the `DE` prefix. ## How to fix 1. Confirm the requester number is exactly 11 characters in the form `DE` followed by 9 digits (e.g. `DE123456789`). 2. Verify the number passes MOD-11. The [MOD-11](/glossary/mod-11) glossary entry explains how the check digit is computed. 3. Do not confuse the USt-IdNr. with the Steuernummer. The Steuernummer is for domestic income-tax purposes and is not accepted by BZSt for §18e confirmations. 4. If you use the same requester number for all calls, set it as the default in your API key settings at `/dashboard/keys` to avoid passing it on every request. ## Related errors - [`invalid_format`](/errors/invalid-format): the target VAT number being validated is malformed ## See also - [USt-IdNr.](/glossary/ust-idnr) - [§18e UStG](/glossary/paragraph-18e-ustg) - [/v1/confirm endpoint](/docs/api/confirmations/confirm) Source: https://www.vatverify.dev/docs/errors/invalid-requester-vat --- ## not_found You are using a test-mode key and sent a VAT number that is not in the magic number registry. **HTTP `404` · Not Found** ## Example response ```json { "error": { "code": "not_found", "message": "VAT number 'DE123456789' not found in test mode registry." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened Test-mode keys (`vtv_test_*`) do not make real registry calls. Instead, they return deterministic responses for a fixed set of magic VAT numbers. Sending a number that isn't in the magic registry returns `not_found`. ## How to fix Use one of the documented magic VAT numbers for test mode: | VAT number | Response | |---|---| | `IE6388047V` | `valid: true`, Google Ireland Limited | | `DE811569869` | `valid: true`, Zalando SE | | `FR44732829320` | `valid: true`, BlaBlaCar SAS | | `IE0000000X` | `valid: false`, no company data | | `DE999999999` | 502 `registry_unavailable` | | Any other well-formed VAT | `valid: true`, synthesized "Magic Corp (XX)" | See the full [Test mode](/docs/test-mode) guide. ## Common mistakes - **Using a real customer's VAT number with a test key:** test keys don't hit the registry. Switch to a live key for real lookups. - **Expecting test keys to behave like live keys for arbitrary inputs:** they don't. Test keys are for CI and integration testing with known inputs. ## Related errors - [`unauthorized`](/docs/errors/unauthorized): key is missing or invalid - [`invalid_format`](/docs/errors/invalid-format): VAT number is malformed Source: https://www.vatverify.dev/docs/errors/not-found --- ## plan_required The endpoint you called requires a higher plan than your current one. **HTTP `402` · Payment Required** ## Example response ```json { "error": { "code": "plan_required", "message": "Batch validation requires a Pro or Business plan." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The endpoint is gated to a higher plan. `POST /v1/validate/batch` requires **Pro or Business**. `POST /v1/decide` requires **Business**. ## How to fix 1. Check your current plan in the dashboard. 2. Upgrade to the required plan, or 3. During development, use a `vtv_test_` key. Test keys bypass all plan gates and have unlimited quota. ## Plan-gate matrix The vatverify endpoint set is layered by plan. As of the current API version: - **Free / Starter / Pro / Business**: `/v1/validate` (single VAT lookup), `/v1/rates`, all read endpoints. - **Pro / Business only**: `/v1/validate/batch` (batch VAT validation up to 50 numbers per request), webhooks. - **Business only**: `/v1/decide` (tax-rules engine), BZSt qualified confirmation, audit log replay beyond 30 days. Hitting `plan_required` always points at the same fix path: either upgrade, or swap the live key for a test key while you build the integration. ## Why test keys bypass plan gates Test mode is intentionally permissive. The contract: test-mode keys (prefix `vtv_test_`) return deterministic synthetic data, never hit live registries, and never count against quota or plan limits. The point is to let developers build and CI-test against the full API surface without upgrading first. A practical consequence: a test integration that works fine in CI will hit `plan_required` the moment it is switched to a live key on a lower plan. The right place to gate-test plan restrictions is in staging with a live key on the actual deployed plan. ## Common mistakes - **Calling `/v1/validate/batch` on a Free or Starter plan**: batch requires Pro+. - **Calling `/v1/decide` on a Pro plan**: decide requires Business. - **Expecting test keys to reflect plan restrictions**: they do not. Use a live key to test plan gating. ## Related errors - [`unauthorized`](/docs/errors/unauthorized): key is missing or invalid entirely - [`rate_limited`](/docs/errors/rate-limited): key is valid but quota is exhausted - [`webhook_limit_reached`](/docs/errors/webhook-limit-reached): webhook slots full Source: https://www.vatverify.dev/docs/errors/plan-required --- ## rate_limited Your monthly quota of live registry calls has been exhausted. **HTTP `429` · Too Many Requests** ## Example response ```json { "error": { "code": "rate_limited", "message": "Monthly quota of 500 exceeded. Resets 2026-05-01T00:00:00Z." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` Headers on this response: ``` X-RateLimit-Limit: 500 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1746057600 Retry-After: 1382400 ``` ## What happened Every plan has a monthly quota of live registry calls. Cache hits are free and don't count. When the quota is exhausted, all further requests that would require a registry call are rejected with `429` until the quota resets on the 1st of the next month. ## How to fix 1. Check `X-RateLimit-Remaining` on every response to track remaining quota before hitting the limit. 2. Respect `Retry-After`. It tells you the exact seconds until reset. Don't retry before then. 3. Use `cache=false` sparingly. Bypassing cache forces a live call and counts quota even if the same number was recently validated. 4. Upgrade your plan for a higher monthly quota. 5. Cache validation results on your side (e.g. in your database) to avoid re-validating the same VAT numbers. ## Common mistakes - **Busy-retrying on 429:** retrying immediately wastes calls and won't succeed. Respect `Retry-After`. - **Not monitoring `X-RateLimit-Remaining`:** quota exhaustion mid-month is avoidable with monitoring. - **Re-validating known customers on every checkout:** store the validation result in your database with a timestamp. Re-validate only when the stored result is older than your compliance requirement (e.g. 30 days). - **Bypassing cache unnecessarily:** `cache=false` is for audited compliance workflows, not default usage. ## Related errors - [`plan_required`](/docs/errors/plan-required): quota is fine but endpoint requires higher plan - [`batch_too_large`](/docs/errors/batch-too-large): request rejected before quota is consumed Source: https://www.vatverify.dev/docs/errors/rate-limited --- ## registry_unavailable The upstream registry (VIES, HMRC, BFS, or Brønnøysund) returned an error or is temporarily down. **HTTP `502` · Bad Gateway** ## Example response ```json { "error": { "code": "registry_unavailable", "message": "VIES fault: MS_MAX_CONCURRENT_REQ" }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 1840 } } ``` ## What happened The upstream registry returned an error, timed out, or is temporarily unavailable. This is not a problem with your request. It is a transient failure at the registry level. VIES in particular is prone to throttling errors (`MS_MAX_CONCURRENT_REQ`, `MS_UNAVAILABLE`) during peak hours. Check `meta.source` in the response to see which registry failed (`vies`, `hmrc`, `bfs`, or `brreg`). ## How to fix Implement retry logic with exponential backoff: ```typescript // Retry 502 with backoff: 250ms → 500ms → 1s const delays = [250, 500, 1000]; let result; for (let i = 0; i <= delays.length; i++) { try { result = await client.validate({ vat_number }); break; } catch (err) { if (err instanceof RegistryError && i < delays.length) { await new Promise(r => setTimeout(r, delays[i])); } else { throw err; } } } ``` The `@vatverify/node` SDK handles this automatically with `max_retries: 2` by default. ## Common mistakes - **Treating 502 as a permanent failure:** registry outages are temporary. Always retry before surfacing an error to the user. - **Retrying with no backoff:** immediate retries under load can worsen throttling. Use backoff. - **Not checking `meta.source_status: "degraded"`:** when `source_status` is `degraded`, we returned a cached result despite the registry being down. The response is still useful. The `valid` value is from a recent real lookup. - **Missing per-item 502s in batch responses:** in batch calls, a `registry_unavailable` per-item error means that specific number failed. The rest of the batch may still have succeeded. Always iterate `results` and handle `ok: false` items. ## Related errors - [`rate_limited`](/docs/errors/rate-limited): quota exhausted (your side, not the registry) Source: https://www.vatverify.dev/docs/errors/registry-unavailable --- ## seller_country_unsupported The seller_vat country prefix is not in EU-27. The /decide endpoint only supports EU sellers. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "seller_country_unsupported", "message": "seller_vat country 'GB' is not supported. /decide covers EU-27 sellers only." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/decide` only calculates VAT treatment for EU-27 sellers. The `seller_vat` you passed has a country prefix outside the EU-27 (e.g. GB, CH, NO). Non-EU VAT rules involve different tax frameworks that are out of scope for v1. ## How to fix 1. Verify the `seller_vat` prefix is one of the EU-27 country codes (AT, BE, BG, CY, CZ, DE, DK, EE, EL, ES, FI, FR, HR, HU, IE, IT, LT, LU, LV, MT, NL, PL, PT, RO, SE, SI, SK). 2. For UK sellers (GB), apply UK VAT rules directly. UK B2B exports to EU are generally zero-rated. 3. For CH/NO sellers, consult local tax regulations. These are outside the EU VAT system. ## Common mistakes - **Passing a UK (GB) seller VAT:** UK left the EU VAT system post-Brexit. `/decide` does not cover GB sellers. - **Confusing XI (Northern Ireland) with GB:** XI is under the EU VAT rules for goods (Brexit protocol) and is supported. - **Passing a Swiss (CH) or Norwegian (NO) seller:** not supported by `/decide`. ## Related errors - [`buyer_vat_not_registered`](/docs/errors/buyer-vat-not-registered): seller is fine but buyer VAT is invalid - [`b2c_not_supported`](/docs/errors/b2c-not-supported): seller is fine but transaction is B2C Source: https://www.vatverify.dev/docs/errors/seller-country-unsupported --- ## unauthorized The API key is missing, malformed, or has been revoked. **HTTP `401` · Unauthorized** ## Example response ```json { "error": { "code": "unauthorized", "message": "Missing API key." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The `Authorization` header was absent, the key format was invalid, or the key has been revoked. Authentication happens before any other processing. This error costs no quota. ## How to fix 1. Confirm the `Authorization` header is present on every request: `Authorization: Bearer vtv_live_xxx` 2. Check that your key is valid and not revoked in the dashboard. 3. If you revoked the key by mistake, generate a new one. Old keys cannot be restored. 4. For local development, use a `vtv_test_` key. It behaves identically but never counts quota. ## Why authentication runs first `unauthorized` is the cheapest error vatverify can return: it short-circuits before any quota counting, registry lookup, or plan-gate check. The trade-off is that downstream errors mask each other. If your key is revoked, you will see `unauthorized` even on requests that would have failed for other reasons (rate limit, plan gate, format). Fix the auth issue first, then the next layer of errors becomes visible. For debugging, the `request_id` in the response is the key piece of context. vatverify retains request logs for support purposes; producing the `request_id` lets us trace exactly which key was attempted and at what time. ## Key prefixes vatverify keys follow a fixed prefix scheme: - **`vtv_live_`**: live keys, count against quota and plan gates apply. - **`vtv_test_`**: test-mode keys, return deterministic synthetic data, never count quota, bypass plan gates. A key that does not start with one of these prefixes will fail the format check and return `unauthorized` without a registry lookup. If you suspect your environment is mangling the key (whitespace, BOM, copy-paste artifacts), the prefix mismatch is usually the first symptom. ## Common mistakes - **Missing the `Bearer ` prefix**: the value must be `Bearer vtv_live_xxx`, not just `vtv_live_xxx`. - **Passing the key as a query param**: `?api_key=vtv_live_xxx` is not supported. The header is required. - **Using a test key for integration tests against endpoints that need a live key**: test keys work everywhere; the reverse is not true. - **Whitespace around the key**: trailing newlines from env file parsing (`vtv_live_xxx\n`) will cause this error. - **Key revoked from a different team member's dashboard**: keys revoked by anyone with access cannot be restored. Generate a new one. ## Related errors - [`plan_required`](/docs/errors/plan-required): key is valid but the endpoint requires a higher plan - [`rate_limited`](/docs/errors/rate-limited): key is valid but quota is exhausted - [`webhook_limit_reached`](/docs/errors/webhook-limit-reached): webhook slots full Source: https://www.vatverify.dev/docs/errors/unauthorized --- ## webhook_limit_reached You have reached the maximum of 5 registered webhook endpoints for this API key. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "webhook_limit_reached", "message": "Maximum of 5 webhook endpoints per key." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened Each API key can have at most 5 registered webhook endpoints. This limit prevents runaway registration loops. Deletion is the off switch: remove an existing endpoint to free a slot. ## How to fix 1. List your current endpoints with `GET /v1/webhooks` to see what is registered. 2. Delete an endpoint you no longer need with `DELETE /v1/webhooks/{id}`. 3. Retry your registration request. ## Why the limit exists The 5-endpoint cap is per API key, not per account. The cap protects two failure modes: - **Registration loops**: a buggy deploy script that re-registers the same webhook on every boot will hit the cap quickly. Hitting `webhook_limit_reached` is the signal to fix the loop, not to lift the cap. - **Fan-out cost**: every webhook event is delivered to every registered endpoint with retries on failure. A larger cap on a low-tier plan would let one customer generate disproportionate outbound traffic. If your architecture genuinely needs more than 5 endpoints under one key, the typical pattern is to fan out internally from a single webhook receiver to multiple downstream consumers. That keeps the cap for vatverify simple and moves the multiplexing into your own infrastructure where you can scale it. ## Edge cases - **Endpoint with the same URL counts as a separate registration.** If you accidentally register the same URL twice, both consume slots. List with `GET /v1/webhooks` and dedupe. - **A revoked endpoint still counts until deleted.** Disabling an endpoint via the dashboard pauses delivery but does not free the slot. Use `DELETE /v1/webhooks/{id}` to actually remove it. - **Test-mode keys have the same cap.** Test-mode webhooks are useful for local development but the 5-endpoint cap applies; rotate test endpoints aggressively rather than accumulating them. ## Related errors - [`plan_required`](/docs/errors/plan-required): webhooks require Pro or Business plan - [`invalid_format`](/docs/errors/invalid-format): webhook URL must be HTTPS - [`unauthorized`](/docs/errors/unauthorized): API key missing or revoked Source: https://www.vatverify.dev/docs/errors/webhook-limit-reached --- ## Node.js SDK Official @vatverify/node SDK: typed client for the vatverify API. Validate VAT numbers, run the tax-rules engine, and fetch rates with full TypeScript support. For offline format + checksum + rates lookup without an API key, use [`@vatverify/vat-rates`](/libraries) instead, same author, MIT licensed, zero dependencies. Most production setups use both: the rates lib as a cheap pre-filter, the SDK for live registry verification. ## Install ```bash npm install @vatverify/node ``` ## Initialize ```typescript import { Vatverify } from '@vatverify/node'; // From a string, also reads VATVERIFY_API_KEY env var automatically const vat = new Vatverify('vtv_live_xxx'); // Or via config object const vat = new Vatverify({ api_key: process.env.VATVERIFY_API_KEY!, timeout: 15_000, // ms, default 30_000 max_retries: 3, // default 2 (retries 429/502/503/504) }); ``` `vat.is_test_mode` is `true` when the key starts with `vtv_test_`. ## Validate a VAT number ```typescript const result = await vat.validate({ vat_number: 'IE6388047V' }); if (result.data.valid) { console.log(result.data.company?.name); // "Apple Distribution International Ltd" } ``` Force a fresh registry call (bypass cache): ```typescript const result = await vat.validate({ vat_number: 'IE6388047V', cache: false }); ``` Get a VIES consultation number for audit trails: ```typescript const result = await vat.validate({ vat_number: 'IE6388047V', requester_vat_number: 'DE100000001', }); console.log(result.data.verify_id); // VIES consultation number ``` ## Validate a batch ```typescript const result = await vat.validateBatch({ vat_numbers: ['IE6388047V', 'DE811569869', 'FR44732829320'], }); for (const item of result.data.results) { if (item.ok) { console.log(item.data.vat_number, item.data.company?.name); } else { console.log(item.error.code); } } ``` With per-item `verify_id` (Pro plan, VIES countries only): ```typescript const result = await vat.validateBatch({ vat_numbers: ['IE6388047V', 'DE811569869'], requester_vat_number: 'DE100000001', }); ``` ## Tax rules (`/v1/decide`) Business plan only. Determines whether to charge VAT on a cross-border B2B transaction. ```typescript const decision = await vat.decide({ seller_vat: 'DE811569869', buyer_vat: 'FR40303265045', }); if (!decision.data.charge_vat) { console.log(decision.data.invoice_note); // "Reverse charge — VAT to be accounted for by the recipient" } ``` ## VAT rates ```typescript // All countries const rates = await vat.rates.list(); // Single country const ie = await vat.rates.get('IE'); console.log(ie.data.standard_rate); // 23 ``` ## Audit log Retrieve a stored request record by its `request_id` (Pro and Business plans). ```typescript const record = await vat.audits.get('019d917e-4fa4-766e-9e0f-d977b47bf2c6'); console.log(record.data.endpoint); // "validate" console.log(record.data.response); // full API response envelope console.log(record.data.expires_at); // ISO timestamp ``` Records are retained for 30 days (Pro) or 90 days (Business). Returns `NotFoundError` if the record has expired or belongs to a different key. ## Webhooks Manage webhook endpoints (Pro and Business plans). vatverify sends signed `validation.completed` and `batch.completed` events to your registered HTTPS endpoints after each request. ```typescript // Register an endpoint: secret shown once, store it securely const endpoint = await vat.webhooks.create('https://example.com/webhooks/vatverify'); console.log(endpoint.secret); // "whsec_..." // List registered endpoints (secrets not included) const { data } = await vat.webhooks.list(); // Verify your endpoint is reachable const result = await vat.webhooks.test('endpoint-id'); console.log(result.delivered, result.status); // true, 200 // Remove an endpoint await vat.webhooks.delete('endpoint-id'); ``` See the [Webhooks guide](/docs/webhooks) for payload shapes and signature verification. ## Error handling The SDK throws typed error classes, never raw HTTP errors. ```typescript import { VatverifyError, AuthError, ValidationError, PlanError, WebhookLimitError, RateLimitError, RegistryError, TimeoutError, } from '@vatverify/node'; try { const result = await vat.validate({ vat_number: 'IE6388047V' }); } catch (err) { if (err instanceof RateLimitError) { console.log(`Quota exceeded. Retry in ${err.retry_after}s`); console.log(err.rate_limit); // { limit, remaining, reset } } else if (err instanceof RegistryError) { // 502 (upstream VIES/HMRC/BFS/brreg down) } else if (err instanceof ValidationError) { // 400: invalid_format, country_unsupported, etc. } else if (err instanceof AuthError) { // 401: bad or missing API key } else if (err instanceof PlanError) { // 402: endpoint requires higher plan } else if (err instanceof WebhookLimitError) { // 400: webhook_limit_reached, delete an endpoint to free a slot } else if (err instanceof TimeoutError) { // request timed out (client-side) } else if (err instanceof VatverifyError) { console.log(err.code, err.status_code, err.request_id); } } ``` All error classes share: | Property | Type | Description | |---|---|---| | `code` | `ErrorCode` | Machine-readable code (e.g. `rate_limited`) | | `status_code` | `number` | HTTP status | | `request_id` | `string \| null` | From response for support traces | | `response_body` | `unknown` | Raw parsed response | | `attempt_count` | `number` | How many attempts were made | ## Retries The SDK retries automatically on transient failures so you don't have to wrap calls in your own retry loop. | Behaviour | Default | |---|---| | Max retries | `2` (3 total attempts) | | Backoff | Exponential with jitter, capped at `2s` | | Retried on | network errors, timeouts, `429`, `502`, `503`, `504` | | Never retried | `400`, `401`, `402`, `404` (caller errors, retrying won't change the answer) | | `Retry-After` | Honored on `429`, capped at `30s` | Disable retries globally or per request: ```typescript // globally const vat = new Vatverify({ api_key: 'vtv_live_xxx', max_retries: 0 }); // per request; also pass a timeout or AbortSignal const controller = new AbortController(); await vat.validate( { vat_number: 'IE6388047V' }, { request_options: { max_retries: 0, timeout: 5000, signal: controller.signal } }, ); ``` Every error exposes `attempt_count` so you can log how many tries it took. ## TypeScript types ```typescript import type { ValidateRequest, ValidateResponse, ValidateData, ValidateBatchRequest, ValidateBatchResponse, BatchResultItem, DecideRequest, DecideResponse, RatesListResponse, RatesSingleResponse, WebhookEndpointPublic, WebhookEndpointWithSecret, WebhookListResponse, WebhookTestResponse, AuditRecord, AuditResponse, Meta, ErrorEnvelope, } from '@vatverify/node'; ``` ## Configuration reference | Option | Type | Default | Description | |---|---|---|---| | `api_key` | `string` | — | Required. Also read from `VATVERIFY_API_KEY` env var | | `base_url` | `string` | `https://api.vatverify.dev` | Override for local dev or proxy | | `timeout` | `number` | `30000` | Per-request timeout in ms | | `max_retries` | `number` | `2` | Retry count for 429/502/503/504 | | `fetch` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation | | `user_agent_extra` | `string` | — | Appended to the `User-Agent` header | | `on_response` | `function` | — | Hook called after every response (useful for logging) | Source: https://www.vatverify.dev/docs/sdks/node --- ## Python SDK Official vatverify Python SDK, coming soon. The Python SDK is coming soon. In the meantime: - For **offline format + checksum + rates lookup**, install [`vatverify-rates`](/libraries) from PyPI: `pip install vatverify-rates`. Zero dependencies, fully typed, no API key required. - For **live registry validation**, use the REST API directly. It works with any HTTP client. ```bash title="cURL example" curl "https://api.vatverify.dev/v1/validate?vat_number=IE6388047V" \ -H "Authorization: Bearer vtv_live_xxx" ``` See the [API reference](/docs/api/validation/validate-vat) for the full endpoint documentation. Source: https://www.vatverify.dev/docs/sdks/python --- ## Retrieve a past response by request_id Returns the full response envelope for a previous validate, decide, or batch call. Records are retained for 7 days (Starter), 30 days (Pro), or 90 days (Business). Free plan keys always return 404. Source: https://www.vatverify.dev/docs/api/audit/get-audit-log --- ## List recent audit records for the calling key Returns the most recent audit records for the calling API key, newest first. Filterable by endpoint and date range. Cursor pagination via `meta.next_cursor` (pass it as `cursor=` on the next call). Source: https://www.vatverify.dev/docs/api/audit/list-audit-logs --- ## BZSt §18e qualified VAT confirmation (German legal evidence) Issues a qualified Bestätigungsmitteilung from the German Bundeszentralamt für Steuern (BZSt) confirming a foreign EU VAT and, field-by-field, whether the supplied company details match what the foreign registry has on file. The result is stored for 10 years as tax-audit evidence under §18e UStG. Business plan only. Supports `Idempotency-Key` header (UUID) for safe retries within 24 hours. Source: https://www.vatverify.dev/docs/api/confirmations/confirm --- ## Fetch a stored §18e confirmation by id Returns a previously issued qualified confirmation. Scoped to the calling api_key — the same key that created the confirmation. Readable regardless of current plan (evidence retention is a contractual promise, not a plan feature). Source: https://www.vatverify.dev/docs/api/confirmations/get-confirmation --- ## Liveness probe Returns { ok: true } when the server is up. No authentication required. Source: https://www.vatverify.dev/docs/api/meta/healthcheck --- ## Get VAT rates for a single country Return VAT rates for a single country by ISO 3166-1 alpha-2 code (case-insensitive). Public endpoint — no API key required. Same rate limit and cache semantics as `GET /v1/rates`. Source: https://www.vatverify.dev/docs/api/rates/get-rate --- ## List VAT rates for all 44 countries Return VAT rates for all 44 countries in our dataset (EU-27 + XI + 16 non-EU European states). Public endpoint — no API key required. Rate-limited to 20 requests per hour per IP. Response body is cacheable (`Cache-Control: public, max-age=3600, stale-while-revalidate=86400`). Clients should honor the `ETag` and use `If-None-Match` for conditional GETs to avoid hitting the limit. Source: https://www.vatverify.dev/docs/api/rates/list-rates --- ## Decide whether to charge VAT Given seller and buyer VAT numbers, returns whether to charge VAT, at what rate, under which mechanism, with legal basis and invoice note. Requires a Business-plan API key. Shares the monthly quota pool with /v1/validate. Source: https://www.vatverify.dev/docs/api/tax-rules/decide-tax --- ## Per-day usage rollup for a key Returns day-level rollups keyed by (day, endpoint, country, source) for one API key across a date range (≤ 90 days). Free plan returns 402. Source: https://www.vatverify.dev/docs/api/usage/get-usage-rollup --- ## Validate up to 50 VAT numbers in one request Validate up to 50 VAT numbers in a single request. Each item is normalized and routed to the correct registry (VIES/HMRC/BFS/BRREG) independently. Per-item successes and failures coexist in the 200 response under `data.results`. Cache hits do not count against your monthly quota. Pass `requester_vat_number` to obtain a per-item `verify_id` (VIES consultation number) for each EU result — useful for tax-audit trails. Calls with a requester always bypass the cache. Pro plan or Business plan required. Source: https://www.vatverify.dev/docs/api/validation/validate-batch --- ## Validate a VAT number Validate a VAT number against the appropriate upstream service (VIES, HMRC, BFS, or BRREG) and return the validity status, company details, and VAT metadata for the country. Input is normalized automatically — spaces, dots, and casing are handled for you ("de 811.569.869" → "DE811569869"). Results are cached for 30 days. Cached responses include `meta.cached: true`; `data.verified_at` always reflects the original registry fetch time. Pass `cache=false` to bypass the cache and force a fresh lookup from the upstream service. Source: https://www.vatverify.dev/docs/api/validation/validate-vat --- ## Register a webhook endpoint Registers an HTTPS URL to receive signed webhook events. The `secret` is returned once — store it securely. Requires Pro or Business plan. Source: https://www.vatverify.dev/docs/api/webhooks/create-webhook --- ## Delete a webhook endpoint Source: https://www.vatverify.dev/docs/api/webhooks/delete-webhook --- ## List recent failures for one endpoint Source: https://www.vatverify.dev/docs/api/webhooks/list-webhook-failures-for-endpoint --- ## List recent webhook delivery failures across all endpoints Source: https://www.vatverify.dev/docs/api/webhooks/list-webhook-failures --- ## List registered webhook endpoints Returns all webhook endpoints for the authenticated key. Secrets are never returned. Source: https://www.vatverify.dev/docs/api/webhooks/list-webhooks --- ## Rotate the webhook secret; returned once Source: https://www.vatverify.dev/docs/api/webhooks/rotate-webhook-secret --- ## Fire a test event to a registered endpoint Delivers a `test` event directly (bypasses the delivery queue) so you can verify your endpoint is reachable. Source: https://www.vatverify.dev/docs/api/webhooks/test-webhook --- ## Update webhook label or enabled state Source: https://www.vatverify.dev/docs/api/webhooks/update-webhook --- # Guides ## 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/updated` webhook, 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: 1. **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. 2. **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. 3. **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. 4. **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: 1. **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. 2. **A server-side app** subscribes to the `store/customer/updated` webhook, validates via vatverify, and writes the result. 3. **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. 4. **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_number ``` This 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. ```ts title="app/routes/webhooks.customer-updated.ts" 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_at` and `consultation_number` answers 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: 1. Set the rate as the destination country's standard VAT rate. 2. 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. ```ts title="app/jobs/revalidate-b2b.ts" 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. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Shopify VAT validation](/guides/shopify-vat-validation) - [WooCommerce VAT validation](/guides/woocommerce-vat-validation) - [Stripe Tax + vatverify](/guides/stripe-vat-validation) Source: https://www.vatverify.dev/guides/bigcommerce-vat-validation --- ## BZSt §18e qualified VAT confirmation (German legal evidence) Obtain a qualifizierte Bestätigungsmitteilung from Germany's BZSt with a single API call. Field-by-field match codes, 10-year evidence retention, idempotent retries. This is the technical reference for the BZSt §18e endpoint. For the broader audit-evidence story across all four tiers (VIES, audit log, qualified confirmation, retention), see [VAT compliance evidence](/guides/vat-compliance-evidence). German sellers making VAT-free intra-EU supplies are required by §18e UStG to obtain a *qualifizierte Bestätigungsmitteilung*, a confirmation from the Bundeszentralamt für Steuern (BZSt) that the foreign customer's VAT is valid **and** that the specific company details on file match what the seller has in their books. This is the legal evidence that protects the seller's good-faith position if the customer's VAT is later found to be fraudulent. vatverify's `POST /v1/confirm` wraps BZSt's eVatR endpoint and stores the result for 10 years. Every developer-first VAT API on the market today is VIES-only; vatverify is the first to expose BZSt as a first-class REST resource. ## Who needs this - You're a German-resident seller with `requester_vat_number` issued by a German Finanzamt (format `DExxxxxxxxx`). - You ship or invoice EU customers without charging VAT (reverse charge). - Your tax advisor or an auditor will eventually ask for per-field evidence, not just "VIES returned valid." If you're not German-resident, VIES validation (`POST /v1/validate` or `/v1/validate/batch`) is what you want; §18e doesn't apply. ## Quickstart Business-plan API key required. If you registered a default requester VAT on your key you can omit `requester_vat_number` below. ### curl ```bash curl -X POST "https://api.vatverify.dev/v1/confirm" \ -H "Authorization: Bearer $VATVERIFY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 00000000-0000-4000-8000-000000000001" \ -d '{ "vat_number": "FR44732829320", "company": { "name": "Airbus SAS", "street": "2 Rond-Point Emile Dewoitine", "postcode": "31700", "town": "Blagnac" } }' ``` ### Node.js SDK ```ts import { Vatverify } from "@vatverify/node" const vat = new Vatverify() const result = await vat.confirm({ vat_number: "FR44732829320", company: { name: "Airbus SAS", street: "2 Rond-Point Emile Dewoitine", postcode: "31700", town: "Blagnac", }, }, { idempotency_key: "00000000-0000-4000-8000-000000000001", }) result.data.qualified // true when every requested field matches result.data.matches.name // 'A' | 'B' | 'C' | 'D' result.data.confirmation_id // retrievable for 10 years result.meta.bzst_status_code // 'evatr-0000', 'evatr-2001', 'evatr-2002', ... ``` ## Interpreting the four match codes For each of `name`, `street`, `postcode`, `town` BZSt returns one of: | Code | Meaning | |------|---------| | `A` | Matches: BZSt's check against the foreign member state came back positive. | | `B` | Does not match: the field is wrong. | | `C` | Not requested: you did not supply this field in the request. | | `D` | Not provided: the foreign EU member state doesn't return this field (varies by country). | `qualified: true` only when **every** requested field returned `A`. If BZSt returns `evatr-0000` but one or more fields come back `B` or `D`, you get `valid: true, qualified: false`. The record still has legal weight (you can show you tried to confirm), but the seller should follow up with the customer to get the matching details. ## Idempotency Confirmations are legal evidence. A retried POST under network flakes must not mint a second row with a different `confirmation_id`. Pass an `Idempotency-Key` header (any UUID); retries with the same key within 24 hours return the originally stored confirmation. ```ts await vat.confirm(input, { idempotency_key: randomUUID() }) ``` ## Retrieving a stored confirmation ```ts const { data } = await vat.confirmations.get(result.data.confirmation_id) data.qualified // original outcome data.matches // original field-match grid data.bzst_status_code // original evatr-XXXX data.created_at // original BZSt call timestamp ``` Confirmations are scoped to the API key that created them; they remain readable even after the plan is downgraded. Retention is 10 years. ## Error codes | HTTP | `error.code` | When | |------|--------------|------| | 200 | — (`data.valid: false`) | BZSt answered but the VAT isn't valid right now: evatr-2001 (not issued), evatr-2002 (valid in future, see `valid_from`), evatr-2006 (was valid, see validity window). | | 400 | `invalid_format` | VAT format invalid, checksum failed, or BZSt returned evatr-0002/0004/0005/0012/2003. | | 402 | `plan_required` | Not on Business plan. | | 422 | `invalid_requester_vat` | Requester VAT isn't authorised (evatr-0006, 0007, 2005). | | 429 | `rate_limited` | Your burst rate limit. | | 429 | `bzst_session_limit` | BZSt returned evatr-0008. Retry with backoff. | | 502 | `registry_unavailable` | Network failure to BZSt. | | 502 | `bzst_server_error` | BZSt server error (evatr-2004, 2011, 3011). | | 503 | `bzst_unavailable` | BZSt service degraded (evatr-0011, 1001–1004). | The Node SDK throws a dedicated class for each: `BzstSessionLimitError`, `BzstUnavailableError`, `BzstRejectedError`, all extending `VatverifyError`. ## Default requester VAT If you always call `/v1/confirm` from the same German entity, set a per-key default in Supabase and omit `requester_vat_number` in every request. The dashboard UI for this ships in a follow-up; for now, ask support to set `api_keys.default_requester_vat` for your key. ## What `confirm` is NOT - It does **not** validate German (`DE…`) VATs. Use `POST /v1/validate` for that. - It does **not** replace VIES in the general case; only German sellers need it. - It does **not** return a PDF. JSON is the canonical evidence; a PDF export endpoint is on the roadmap if customers ask. Source: https://www.vatverify.dev/guides/bzst-paragraph-18e-qualified-confirmation --- ## EU VAT rate changes 2024-2026: what changed and what your code needs to know A consolidated reference of the standard and reduced VAT rate changes across European jurisdictions in 2024-2026. Switzerland 8.1%, Estonia 22% then 24%, Finland 25.5%, Czechia consolidated 12% reduced, Slovakia 23%, with practical guidance on date-aware rate logic. ## TL;DR - Five European jurisdictions changed headline VAT rates in 2024-2025: Switzerland, Estonia, Finland, Czechia, and Slovakia. - Hardcoded rate constants from before 2024 will produce incorrect amounts on any invoice straddling the change date. - Date-aware rate lookup is the only correct pattern. The applicable rate is set by the date of supply, not the invoice date. ## Why this matters Reference tables published before 2024 are wrong on at least four EU and EEA countries. Reference tables that scrape Wikipedia or the EU Commission VAT rates table without a freshness check are wrong on the same set. A handful of widely-cited sources still show pre-2024 rates and rank well in search results, including ad-funded calculator sites and stale tax-blog posts. If your invoicing engine sources rates from any of those, every cross-border B2C sale to one of these countries since the change date is using the wrong rate. The seller-side liability for under-collection lands on you, not on the buyer. ## Switzerland: 7.7% to 8.1% (1 January 2024) Switzerland raised its standard VAT rate from 7.7% to 8.1% effective 1 January 2024. Reduced rates also moved: | Bracket | Pre-2024 | From 1 Jan 2024 | |---|---|---| | Standard | 7.7% | 8.1% | | Reduced | 2.5% | 2.6% | | Special accommodation | 3.7% | 3.8% | The funding rationale was the federal contribution to AHV (old-age) pension financing. The increase was approved by referendum in September 2022. Liechtenstein, which shares the Swiss VAT system under the 1923 customs and VAT union, applies the same rate matrix and changed dates. ## Estonia: 20% to 22% to 24% (Jan 2024, Jul 2025) Estonia raised its standard VAT rate twice in two years: | Effective date | Standard rate | |---|---| | Pre-2024 | 20% | | 1 January 2024 | 22% | | 1 July 2025 | 24% | Both changes were enacted under the 2024 budget agreement. The 2024 increase funds defense spending; the 2025 increase was added during budget negotiations later in the same legislative cycle. The Estonian reduced rate has historically been 9% across categories including books, accommodation, and pharmaceuticals; the reduced-rate composition has also been adjusted in recent budget cycles, with current values published by EMTA (the Estonian Tax and Customs Board). ## Finland: 24% to 25.5% (1 September 2024) Finland raised its standard VAT rate from 24% to 25.5% effective 1 September 2024. The mid-year effective date is unusual; most VAT rate changes align with calendar-year boundaries. The reduced rates remained at 14% (food and restaurant services) and 10% (books, accommodation, public transport, and several other categories) at the time of the change. For invoicing systems, the September 2024 cut-off is what matters: invoices with a date of supply on or after 1 September 2024 use 25.5%; earlier supplies use 24%. The invoice date is not the determining factor; the date of supply is. ## Czechia: reduced-rate consolidation (1 January 2024) Czechia consolidated its previous 10% and 15% reduced VAT brackets into a single 12% reduced rate effective 1 January 2024: | Bracket | Pre-2024 | From 1 Jan 2024 | |---|---|---| | Standard | 21% | 21% | | First reduced | 15% | 12% | | Second reduced | 10% | 12% | The consolidation moved several categories between brackets simultaneously. Hardcoded references to 10% or 15% for any Czech supply on or after 1 January 2024 will be incorrect; the correct rate is 12% across the consolidated bracket. The current Finanční správa publications are the authoritative source for which categories now sit in the 12% bracket versus 21% standard. ## Slovakia: 20% to 23% (2025 fiscal package) Slovakia raised its standard VAT rate from 20% to 23% under the 2025 consolidation package. The package also introduced a new 19% reduced rate for selected categories (food, accommodation, restaurant services), while retaining the 5% rate for a separate set of categories. | Bracket | Pre-2025 | From 2025 | |---|---|---| | Standard | 20% | 23% | | First reduced | 10% | 19% | | Second reduced | 5% | 5% | The 19% rate is structurally a "first reduced" bracket, not a renamed standard rate. The 23% standard applies to most goods and services; specific categories moved from the 20% standard into the 19% reduced bracket as part of the package. ## Croatia: euro adoption (1 January 2023) Croatia is included here for completeness even though no rate value changed. On 1 January 2023, Croatia adopted the euro, replacing the kuna. Headline rates remained 25% standard, 13% reduced, 5% super-reduced. For invoicing systems handling Croatian counterparties: - Pre-2023 invoices in kuna remain historical records only. - Conversions from kuna amounts in legacy systems use the official conversion rate fixed at adoption (HRK 7.53450 per euro). - New invoices, rates, and conversions all operate in euros. ## Cross-border implications Most of these rate changes affect B2C cross-border sellers more than B2B cross-border sellers. The reverse-charge mechanism on B2B intra-Community supplies means the seller invoices net of VAT regardless of the destination's rate; the buyer self-accounts at their local rate. The rate change matters to the buyer's recovery position, not the seller's invoice. For B2C sellers operating under OSS, every change affects the rate applied at the seller's checkout. Stores that run their own price-display logic by stripping or adding VAT to a base price (common in mainland-EU pricing flows) need a rate lookup that knows the date of the price change. ## Date-aware rate logic The pattern that works: ```ts interface RateLookupArgs { country: string; category?: string; asOfDate: Date; } interface RateResult { bracket: 'standard' | 'reduced' | 'super_reduced'; rate: number; effective_from: Date; effective_until: Date | null; } async function getRate(args: RateLookupArgs): Promise { const res = await fetch( `https://api.vatverify.dev/v1/rates/${args.country.toLowerCase()}` + `?as_of=${args.asOfDate.toISOString().slice(0, 10)}` + (args.category ? `&category=${encodeURIComponent(args.category)}` : ''), { headers: { Authorization: `Bearer ${process.env.VATVERIFY_API_KEY}` } }, ); return (await res.json()).data; } ``` The `as_of` parameter is the key: it tells the rates endpoint to return the rate that was in force on the supplied date, not the rate that is in force right now. For a re-issued invoice that corrects a 2024 supply, the rate returned should be the 2024 rate, not today's. ## What to do if your code is wrong Three steps: 1. **Find every place rates are hardcoded.** Search your codebase for the literal strings `19`, `20`, `7.7`, `2.5` near VAT-related identifiers. Most older codebases have them in at least three places: tax engine, invoice template, refund logic. 2. **Replace with a date-aware lookup.** vatverify's `/v1/rates` is one option; an internal rates table with dated rows is another. Either way the lookup takes a date and a country. 3. **Re-issue invoices straddling a rate change date if material.** For B2C sellers, this is mostly a price-display correction since the consumer is the one paying. For B2B sellers under reverse-charge, the buyer's recovery position can be off by the rate difference and the buyer will eventually flag it. ## Related guides - [EU VAT rates 2026](/eu-vat-rates) - [OSS and IOSS implementation guide](/guides/oss-ioss-implementation-guide) - [Country pages: 32 VAT systems explained](/check) - [VAT calculator](/calculator) Source: https://www.vatverify.dev/guides/eu-vat-changes-2024-2026 --- ## The EU VAT reverse-charge API: stop hand-coding B2B tax logic The /v1/decide endpoint decides whether to charge VAT, at what rate, under which mechanism. Full walkthrough with Stripe + invoice integration. ## TL;DR - `POST /v1/decide` takes `seller_vat` and `buyer_vat` (full VAT numbers with country prefix). - Returns `mechanism` (`standard` | `reverse_charge` | `zero_rated` | `out_of_scope`), `rate`, and `invoice_note`. - Eliminates the need to hand-code EU VAT Directive Article 196 logic in your app. `@vatverify/node` (used in the Stripe and invoice examples below) ships on npm at the API launch. Reading this before launch? The `/v1/decide` REST endpoint is live today. Replace any `vat.decide({ seller_vat, buyer_vat })` call with a `fetch('https://api.vatverify.dev/v1/decide', { method: 'POST', ... })` using the same `Authorization: Bearer` header. The SDK is a typed wrapper around the same JSON. ## The EU reverse-charge rule in plain English Concrete example: a German SaaS sells a €1,000/year subscription to a French company. Under normal VAT rules, the seller would charge the buyer's local VAT, which would force the German SaaS to register for VAT in France, file French VAT returns, and remit collected VAT to the French tax authority. Multiply that by 26 other member states and the compliance overhead kills cross-border B2B entirely. The reverse-charge mechanism fixes this. When a VAT-registered business in one EU member state sells services (or certain goods) to a VAT-registered business in *another* EU member state, the seller charges **zero VAT**. Instead, the buyer "self-accounts," declaring both the output VAT and the (usually offsetting) input VAT on their own domestic return. Liability shifts from seller to buyer, and the seller never has to register in the buyer's country. Three conditions must all be true for reverse-charge to apply: 1. **Both parties are VAT-registered businesses.** A valid, currently-registered VAT number on each side. Reverse-charge does not apply to B2C sales. 2. **They are in different EU member states.** Same-country B2B uses the standard domestic rate; non-EU sales follow export rules, not reverse-charge. 3. **The supply is covered by the general rule** (Article 44, covering most services including SaaS). Exceptions exist for real estate, passenger transport, and a few other categories. On the invoice itself, reverse-charge means zero VAT charged **plus** a mandatory legal note citing Article 196 of the EU VAT Directive. Missing that note is a common audit finding. The `/v1/decide` endpoint returns the note ready to print, so you can paste it verbatim. ## Why regex + VIES isn't enough Validating the VAT number is step one. The actual question your billing system needs to answer is: *"do I charge VAT on this invoice, and if so, at what rate?"* A raw VIES check returns `valid: true | false`, useful but not the complete answer. The real decision chain is: 1. **Is the buyer's VAT number currently registered?** (VIES for EU, HMRC for UK, BFS for CH.) 2. **What country is the seller established in?** 3. **What country is the buyer established in?** (Not always the same as the country prefix on the VAT number.) 4. **Is this a supply of services or goods?** (Different place-of-supply rules apply.) 5. **Are the seller and buyer in the same country?** (If yes, standard domestic rate; reverse-charge doesn't apply.) 6. **Does Article 44 / Article 196 cover this supply?** (SaaS yes; real estate, passenger transport, and restaurant services follow different rules.) 7. **If reverse-charge applies:** rate is 0, but an Article 196 invoice note is legally required. 8. **If standard domestic rate applies:** look up the current standard rate for the seller's country. 9. **If the seller is non-EU selling into the EU:** OSS / import-VAT rules kick in (out of scope for `/v1/decide` today). Hand-coding that is a few hundred lines of conditional logic, a rate table you have to keep current, and a set of legal-text templates you have to get exactly right. `/v1/decide` collapses it into one HTTP call and returns the full answer including the invoice note. ## Introducing `/v1/decide`: one call, complete answer ```bash curl -X POST https://api.vatverify.dev/v1/decide \ -H "Authorization: Bearer $VATVERIFY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "seller_vat": "DE811569869", "buyer_vat": "FR40303265045" }' ``` ## Request / response walkthrough Full request and response for an EU cross-border B2B sale: **Request:** ```json { "seller_vat": "DE811569869", "buyer_vat": "FR40303265045" } ``` **Response:** ```json { "data": { "charge_vat": false, "rate": 0, "mechanism": "reverse_charge", "legal_basis": "EU VAT Directive Article 196", "explanation": "Your customer provided a valid French VAT number, so this is an EU cross-border B2B supply. The reverse-charge mechanism applies, so you do not charge VAT, and the buyer self-accounts.", "invoice_note": "Reverse charge — VAT to be accounted for by the recipient", "disclaimer": "This is guidance, not legal advice. Confirm with a qualified tax adviser for your specific situation.", "buyer_vat": { "valid": true, "country": { "code": "FR", "name": "France" } }, "decided_at": "2026-04-27T12:00:00Z" }, "meta": { "request_id": "0190f8ea-a5b2-7000-a123-000000000000", "latency_ms": 422, "source_status": "live", "sources": [ { "role": "seller", "source": "vies", "source_status": "live", "cached": false, "cached_at": null, "stale_seconds": null }, { "role": "buyer", "source": "vies", "source_status": "live", "cached": false, "cached_at": null, "stale_seconds": null } ] } } ``` ## All decision cases ### EU seller → EU buyer with valid VAT (different countries) ```json { "seller_vat": "DE811569869", "buyer_vat": "FR40303265045" } ``` ```json { "mechanism": "reverse_charge", "rate": 0, "charge_vat": false } ``` ### EU seller → same-country B2B ```json { "seller_vat": "DE811569869", "buyer_vat": "DE123456789" } ``` ```json { "mechanism": "standard", "rate": 19, "charge_vat": true } ``` ### EU seller → B2C (no VAT number provided) The `/v1/decide` endpoint requires both `seller_vat` and `buyer_vat`. B2C transactions (where the buyer has no VAT number) are not supported in v1. ```json { "error": { "code": "b2c_not_supported", "message": "This endpoint is for B2B transactions only. B2C VAT rules (OSS/IOSS) are not currently supported." } } ``` HTTP status: `400` ### EU seller → unregistered buyer VAT number ```json { "seller_vat": "DE811569869", "buyer_vat": "FR00000000000" } ``` ```json { "error": { "code": "buyer_vat_not_registered", "message": "The buyer VAT number FR00000000000 is not registered in VIES." } } ``` HTTP status: `400` ### Non-EU seller Non-EU seller VAT numbers (US, UK, CH, NO) are not supported in v1. The API returns `seller_country_unsupported` (400). ```json { "error": { "code": "seller_country_unsupported", "message": "Seller country US is not supported. Currently only EU-27 seller countries are supported." } } ``` HTTP status: `400` ## Integration with Stripe checkout Use `/v1/decide` before creating the Stripe customer to set up correct tax handling: ```typescript title="lib/checkout.ts" import Stripe from 'stripe'; import { Vatverify } from '@vatverify/node'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function createCheckoutSession({ sellerVat, buyerVat, priceId, }: { sellerVat: string; buyerVat: string; priceId: string; }) { // Step 1: get the tax decision const decision = await vat.decide({ seller_vat: sellerVat, buyer_vat: buyerVat, }); // Step 2: create a Stripe customer with the validated tax ID const customer = await stripe.customers.create({ tax_id_data: [ { type: `eu_vat`, value: buyerVat, }, ], metadata: { vatverify_mechanism: decision.data.mechanism, vatverify_rate: String(decision.data.rate), }, }); // Step 3: create the checkout session const session = await stripe.checkout.sessions.create({ customer: customer.id, mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], // Stripe applies zero-rate automatically when a valid EU VAT ID is on the customer automatic_tax: { enabled: true }, success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`, cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout`, }); return session; } ``` ## Integration with invoice generation Inject `decision.data.invoice_note` directly into the invoice PDF to satisfy Article 226 of the EU VAT Directive: ```typescript title="lib/invoice.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function buildInvoiceData({ sellerVat, buyerVat, subtotal, }: { sellerVat: string; buyerVat: string; subtotal: number; }) { const decision = await vat.decide({ seller_vat: sellerVat, buyer_vat: buyerVat, }); const vatAmount = subtotal * (decision.data.rate / 100); const total = subtotal + vatAmount; return { subtotal, vatRate: decision.data.rate, vatAmount, total, // Paste this string directly onto the invoice PDF legalNote: decision.data.invoice_note, mechanism: decision.data.mechanism, }; } ``` ## Legal basis: EU VAT Directive Article 196 Article 196 of Council Directive 2006/112/EC (the EU VAT Directive) is the legal foundation for the cross-border B2B reverse-charge mechanism. The operative text: *"VAT shall be payable by any taxable person to whom the services referred to in Article 44 are supplied, where those services are supplied by a taxable person not established within the territory of the Member State."* In plain terms: when a taxable person (business) in one member state buys general-rule services from a supplier established elsewhere in the EU, the buyer (not the seller) is liable to account for VAT. That shift of liability is what makes zero-rating on the seller's invoice compliant instead of criminal. Article 226 of the same directive lists the mandatory particulars on a VAT invoice. For reverse-charge supplies, it specifically requires a reference to the provision under which the reverse-charge applies. The `/v1/decide` response returns `invoice_note: "Reverse charge — VAT to be accounted for by the recipient"` alongside `legal_basis: "EU VAT Directive Article 196"`. Print both verbatim on the invoice and the combination satisfies the Article 226 requirement without legal copy-editing. Article 196 covers the general rule for services under Article 44. Special rules exist for real estate (Article 47), passenger transport (Article 48), restaurant and catering services (Article 55), admission to cultural events (Article 53), and short-term hire of means of transport (Article 56). These are **out of scope** for `/v1/decide` today. For edge cases or non-standard supplies, consult a VAT advisor. The `/decide` response is intentionally conservative and will surface known limitations rather than guess. ## FAQ ### Does this endpoint cover UK VAT? Not in v1. UK coverage (including UK→EU and UK domestic scenarios under HMRC rules) is planned for v2. See the [HMRC VAT check guide](/guides/hmrc-vat-check-api) for UK-only validation in the meantime. ### Does it handle OSS or IOSS? No. The `/v1/decide` endpoint is B2B only. OSS (One Stop Shop) and IOSS apply to B2C cross-border sales and are not currently in scope. ### What about digital services with special place-of-supply rules? For most B2B digital services, the general rule (Article 44 of the EU VAT Directive) applies: place of supply is where the buyer is established, which triggers the reverse charge. Edge cases like pre-recorded online events may have different rules. Consult a tax advisor. ### Do distance-selling thresholds affect this? Distance-selling thresholds (€10,000 OSS threshold) apply to B2C sales, not B2B. If you have a valid buyer VAT number, the reverse charge applies regardless of transaction volume. ### What should I do if the API is down? If `/v1/decide` returns a 5xx, do not silently charge zero VAT. That could be incorrect. The safest fallback is to block checkout and show an error message. See [handle-vies-downtime](/guides/handle-vies-downtime) for retry and fallback patterns. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Stripe VAT validation](/guides/stripe-vat-validation) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) - [Decide endpoint](/features/decide) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/eu-vat-reverse-charge-api --- ## Handle VIES downtime with degraded cache, fallbacks, and retry patterns VIES goes down. Here's how vatverify handles it automatically, and how your code should handle the edge cases that still matter. ## TL;DR - VIES has real downtime: weekly maintenance, country-specific outages, peak-hour degradation - vatverify automatically returns degraded cache during outages with source_status: "degraded" - Your code should treat degraded as valid-for-now but log for later reconciliation - Fall back to format + checksum-only via @vatverify/vat-rates as a last resort `@vatverify/vat-rates` (the offline fallback library used below) is on npm today. `@vatverify/node` (the SDK shown in the retry examples) ships on npm at the API launch. Until then, use `fetch('https://api.vatverify.dev/v1/validate?...')` with `Authorization: Bearer `. ## What downtime looks like VIES hits about 99% overall, but specific country registries (ES, DE, IT) degrade noticeably during EU business hours. The MS_UNAVAILABLE error code is the VIES-native signal. Scheduled maintenance typically runs Monday mornings 03:00 to 05:00 UTC. Country-specific registries can lag or become unavailable for hours at a time. Spain's registry regularly hits more than 5% error rates between 09:00 to 12:00 CET. Peak-hour degradation across all EU registries is common 10:00 to 14:00 UTC. Real downtime is rare (less than 0.5%), but service degradation is part of the expected operational reality. ## How vatverify handles it automatically - **30-day cache TTL on valid responses:** most requests don't hit VIES - **Stale-while-revalidate:** when VIES returns an error and we have a cached copy, we return it with source_status: "degraded" (200 OK, not 502) - **When there's no cache and VIES is down:** we return 502 registry_unavailable with a structured error - **Retry strategy inside vatverify:** 3 attempts with exponential backoff before giving up ## What your code should do Handle three response shapes: ```typescript title="safe-validate.ts" import { Vatverify, VatverifyError } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); async function safeValidate(vatNumber: string) { try { const result = await vat.validate({ vat_number: vatNumber }); if (result.meta.source_status === 'degraded') { // Accept as valid for now, but log for reconciliation await logReconciliationTask(vatNumber); return { valid: true, confidence: 'degraded' }; } return { valid: result.data.valid, confidence: 'fresh' }; } catch (err) { if (err instanceof VatverifyError && err.code === 'registry_unavailable') { // Last resort - check format + checksum offline return fallbackOffline(vatNumber); } throw err; } } async function fallbackOffline(vatNumber: string) { const { validate } = await import('@vatverify/vat-rates'); const result = validate(vatNumber); return { valid: result.valid, confidence: 'format-only' }; } ``` ## Retry with exponential backoff For cases where you want a longer retry window (for example, a background job that can wait 30 seconds): ```typescript title="retry-validate.ts" async function retryValidate(vatNumber: string, maxRetries: number = 3) { let delay = 250; for (let i = 0; i < maxRetries; i += 1) { try { return await vat.validate({ vat_number: vatNumber }); } catch (err) { if (err instanceof VatverifyError && err.code === 'registry_unavailable' && i < maxRetries - 1) { await new Promise(r => setTimeout(r, delay)); delay *= 2; continue; } throw err; } } } ``` Note: vatverify already retries internally. This wrapper is for cases where you want a longer total window. ## The offline fallback library The @vatverify/vat-rates library runs offline, no network, instant. It won't tell you a VAT is registered, but it will tell you it's correctly shaped and has valid check digits. This is enough to reject obvious garbage at checkout. Use it to catch structural errors before calling the API, or as the final safety net when VIES is completely unavailable. On checkout pages, you can use offline validation for immediate user feedback while the API call completes in the background. ## Monitoring your own downtime handling - Log the distribution of `source_status` values (`live` / `cached` / `degraded`). If `degraded` responses exceed 5% over an hour, investigate upstream. - Alert if `registry_unavailable` errors exceed 1% of requests over 5 minutes. This signals the upstream registry is down and your cache is being missed. - Track reconciliation task count. If the degraded-hit rate stays above 10% for an hour, something upstream is broken. ## FAQ ### How often is VIES actually down? About 1% of requests across the whole system; individual countries see up to 5% during their business peak. ### Will I be charged for degraded responses? No. Any response served from cache (`source_status: "cached"` or `"degraded"`) doesn't count against your monthly quota. Only `source_status: "live"` responses consume quota. ### Can I force a fresh call even during downtime? Add `cache=false` as a query parameter. This bypasses the 30-day cache and forces a fresh upstream lookup. When the upstream registry is down this will surface the underlying failure instead of quietly serving the cached value. Use sparingly. ### Does HMRC (UK) have the same downtime? HMRC is more reliable than VIES overall (99.9%+). Same degraded-cache pattern applies but triggers less often. ### What about Swiss BFS and Norwegian brreg? Both are very reliable (99.95%+). The degraded-cache pattern is there if needed but rarely kicks in. ## Related guides - [VIES API guide](/guides/vies-api-guide) - [HMRC VAT check API](/guides/hmrc-vat-check-api) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) Source: https://www.vatverify.dev/guides/handle-vies-downtime --- ## HMRC VAT check API: UK VAT validation explained How HMRC's VAT check API works. OAuth 2.0 client credentials, sandbox vs production, rate limits, and why vatverify handles all of it for you. ## TL;DR - HMRC is the UK's VAT registry, completely separate from VIES since Brexit. - It is a proper REST API (JSON in, JSON out), which is the good news. - The bad news: it requires OAuth 2.0 client credentials, token refresh every hour, and a ~10-day production application process. - vatverify handles the OAuth dance, token caching, and retries so you never touch any of it. ## Why UK VAT is different from EU Before Brexit, UK VAT numbers (GB prefix) were accessible through VIES just like any other EU member state. After 1 January 2021, Great Britain left the EU VAT area and HMRC became the sole authoritative registry for GB-prefixed numbers. Northern Ireland is a special case: XI-prefixed numbers remain in scope of EU VAT rules under the Windsor Framework and are still validated through VIES. So in practice: if the prefix is `GB`, query HMRC; if the prefix is `XI`, query VIES. ## HMRC REST vs VIES SOAP The HMRC API is genuinely pleasant to work with at the protocol level: REST, JSON, predictable status codes. The friction is entirely in the auth layer: you need OAuth 2.0 client credentials (not just an API key) and you need to renew the access token every 60 minutes. Compare that to VIES, which has no auth at all but forces you through SOAP/XML. Both registries have real operational complexity; they just put it in different places. ## UK VAT number format UK VAT numbers follow one of two patterns: - **Standard:** `GB` + 9 digits, e.g., `GB123456789` - **Branch code:** `GB` + 9 digits + 3-digit branch suffix, e.g., `GB123456789012` Some older references also show a 4-digit government department prefix (`GBGD` or `GBHA`) but these are rare and handled automatically by vatverify. The 9 core digits use the HMRC 97-55 checksum algorithm to detect transposition errors. ## HMRC 97-55 checksum The algorithm is named after the two valid modular remainders (42 and 55 are also valid, but 97 is the modulus, hence "97-55"). To verify a 9-digit VAT number: ```typescript title="hmrc-checksum.ts" function hmrc9755Checksum(digits: string): boolean { const nums = digits.split('').map(Number); const sum = nums.slice(0, 7).reduce((a, d, i) => a + d * (8 - i), 0); const expected = (sum + Number(digits.slice(7, 9))) % 97; return expected === 0 || expected === 42 || expected === 55; // ← the 97-55 name } ``` The weights for positions 1–7 are 8, 7, 6, 5, 4, 3, 2. You multiply each digit by its weight, sum the results, add the two-digit check suffix (digits 8–9), and check that the total is divisible by 97 with a remainder of 0, 42, or 55. The 42 and 55 variants were introduced to accommodate a specific range of registration numbers issued in a particular period. Any offline validation library worth using (including `@vatverify/vat-rates`) implements all three variants. ## OAuth 2.0 client credentials flow The flow in plain prose: 1. POST to `https://api.service.hmrc.gov.uk/oauth/token` with `grant_type=client_credentials`, your `client_id`, and `client_secret`. 2. HMRC returns a JSON body with `access_token`, `token_type: "bearer"`, and `expires_in: 14400` (4 hours for production, shorter in sandbox). 3. Include `Authorization: Bearer ` on every subsequent request. 4. Re-request a token before it expires. Best practice is to re-request at ~55 minutes (5-minute safety buffer) regardless of the `expires_in` value. The validation endpoint itself is straightforward once you have a token: ``` GET https://api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup/{targetVatNumber} ``` ## Sandbox vs production | | Sandbox | Production | |-|---------|------------| | Base URL | `https://test-api.service.hmrc.gov.uk` | `https://api.service.hmrc.gov.uk` | | Credentials | Any test credentials | Approved application required | | Response data | Canned fixture data only | Live HMRC registry | | Token TTL | Shorter (varies) | 4 hours | | Availability | Always open | Requires production approval | The sandbox is free and open. It returns a fixed set of test VAT numbers with predictable responses, useful for integration testing, but it will never return a "real" company name because no live data is exposed. ## Production access application To get production credentials, you register an application at [developer.service.hmrc.gov.uk](https://developer.service.hmrc.gov.uk), select the **VAT (MTD)** API, and request the `read:vat` scope (the scope for `check-vat-number` lookups). HMRC reviews applications manually; expect approximately 10 working days. During review they check your stated use case, your privacy policy, and that your callback URLs are reachable. Once approved, you receive production `client_id` and `client_secret` through the developer portal. Store them in environment variables, never in source code. ## Token caching Re-requesting a token on every API call is wasteful and slows your response times. Cache the token for 55 minutes (using a 5-minute safety buffer against the 60-minute TTL). Using a key-value store: ``` SET hmrc:token EX 3300 ``` 3300 seconds = 55 minutes. Before each HMRC request, check whether `hmrc:token` exists. If it does, use it. If it doesn't, run the OAuth token exchange, store the result, then proceed. vatverify handles all of this internally. The token lifecycle is invisible to you. ## MTD (Making Tax Digital) context HMRC is migrating all tax operations to API-first under the Making Tax Digital programme. VAT validation is the simplest endpoint in the MTD ecosystem, but understanding it gives you a foundation for MTD filing integrations. The `check-vat-number` scope is a read-only permission. It does not give you access to return submission or payment data. That is intentional: validation is separate from filing. ## Direct HMRC vs vatverify | Feature | Direct HMRC | vatverify | |---------|------------|-----------| | Protocol | REST/JSON | REST/JSON | | Auth | OAuth 2.0 (manual) | Handled internally | | Token lifecycle | You manage rotation | Automatic | | Caching | None (you build it) | 30-day automatic | | Retries | None (you build it) | Automatic with backoff | | Production approval | ~10-day process on your account | vatverify's production credentials | | Rate limit | ~100 req/sec | Unbounded (cached) | | Northern Ireland (XI) | Separate VIES call | Automatic routing | ## FAQ ### How do I get production credentials? Apply at [developer.service.hmrc.gov.uk](https://developer.service.hmrc.gov.uk). Select the VAT (MTD) API, request the `read:vat` scope, and submit your application. HMRC reviews manually. Budget approximately 10 working days. If you use vatverify, you skip this step entirely because the API calls go through vatverify's production credentials. ### Can I use the free sandbox forever? Yes. The HMRC sandbox has no expiry and no cost. The limitation is that it only returns canned fixture data: a fixed list of test VAT numbers with predetermined responses. It is not useful for validating real numbers or for any production scenario. ### Does HMRC return company names? Yes. The response includes a `target` object with a `name` field (the registered business name) and an `address` object with structured fields (`line1`, `line2`, `line3`, `line4`, `postcode`, `countryCode`). This is more structured than VIES, which returns a free-text address string for most countries. ### What's the rate limit? HMRC's published guidance suggests approximately 100 requests per second sustained from a single OAuth application. In practice the ceiling is higher, but 100 req/sec is a safe target if you are hitting HMRC directly. With vatverify, cached results do not count against any rate limit. Only cache misses result in upstream calls. ### What about Northern Ireland VAT? Northern Ireland VAT numbers use the `XI` prefix, not `GB`. These are validated through VIES, not HMRC, because Northern Ireland remains within the EU VAT area for goods under the Windsor Framework. vatverify routes automatically based on prefix: `GB` → HMRC, `XI` → VIES. You pass the number and let the API handle it. ## Related guides - [VIES API guide](/guides/vies-api-guide) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/hmrc-vat-check-api --- ## Mollie VAT validation: EU B2B checkout with reverse-charge Validate customer VAT before a Mollie payment. Add reverse-charge logic to EU B2B invoices using /v1/decide. ## TL;DR - Validate the buyer's VAT number with vatverify before creating the Mollie customer. - Create the Mollie payment with the validated company name and VAT metadata. - Call `/v1/decide` to get `invoice_note` and inject it into your PDF invoice footer. `@vatverify/node` is packaged and ships on npm at the API launch. Reading this before launch? The REST endpoints (`/v1/validate` and `/v1/decide`) are live today. The pattern in this guide works identically with a plain `fetch` call using the same `Authorization: Bearer` header. ## Why Mollie + vatverify? Mollie is a payment processor, not a tax engine. It handles the money movement reliably across iDEAL, SEPA, credit cards, and a dozen other methods, but has no concept of whether your customer is VAT-registered or what reverse-charge mechanism applies. For EU B2B sellers using Mollie, that gap is your problem. You need to verify the buyer's VAT number is real (not just well-formatted), determine whether you charge VAT or apply reverse-charge, and get the correct legal note onto the invoice. vatverify slots in before you touch the Mollie API at all. Validate first, then pay. ## Installation ```bash npm install @vatverify/node @mollie/api-client ``` ## Validating before payment The pattern mirrors the Stripe integration: validate VAT first, then create the Mollie customer and payment. ```typescript title="lib/mollie-b2b-payment.ts" import { Vatverify } from '@vatverify/node'; import { createMollieClient } from '@mollie/api-client'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const mollie = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! }); export async function createB2BPayment({ email, sellerVat, buyerVat, amountEur, description, redirectUrl, }: { email: string; sellerVat: string; buyerVat: string; amountEur: string; // e.g. "99.00" description: string; redirectUrl: string; }) { // Step 1: validate buyer VAT number const vatResult = await vat.validate({ vat_number: buyerVat }); if (!vatResult.data.valid) { throw new Error('Invalid VAT number'); } // Step 2: get tax decision const decision = await vat.decide({ seller_vat: sellerVat, buyer_vat: buyerVat, }); // Step 3: create Mollie customer with validated company data const customer = await mollie.customers.create({ name: vatResult.data.company?.name ?? vatResult.data.vat_number, email, metadata: { vatNumber: buyerVat, vatCountry: vatResult.data.country.code, vatMechanism: decision.data.mechanism, vatRate: String(decision.data.rate), invoiceNote: decision.data.invoice_note, }, }); // Step 4: create Mollie payment const payment = await mollie.payments.create({ amount: { currency: 'EUR', value: amountEur, }, description, redirectUrl, customerId: customer.id, metadata: { vatNumber: buyerVat, mechanism: decision.data.mechanism, invoiceNote: decision.data.invoice_note, }, }); return { payment, customer, decision }; } ``` ## Decide + invoice generation After calling `/v1/decide`, inject `decision.data.invoice_note` into your invoice PDF. The note arrives ready to print and satisfies Article 226 of the EU VAT Directive: ```typescript title="lib/mollie-invoice.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); interface InvoiceLineItem { description: string; unitPrice: number; quantity: number; } export async function buildMollieInvoice({ sellerVat, buyerVat, lineItems, }: { sellerVat: string; buyerVat: string; lineItems: InvoiceLineItem[]; }) { const decision = await vat.decide({ seller_vat: sellerVat, buyer_vat: buyerVat, }); const subtotal = lineItems.reduce( (sum, item) => sum + item.unitPrice * item.quantity, 0 ); const vatAmount = subtotal * (decision.data.rate / 100); const total = subtotal + vatAmount; return { lineItems, subtotal, vatRate: decision.data.rate, vatAmount, total, // Print this string verbatim in the invoice footer legalFooter: decision.data.invoice_note, mechanism: decision.data.mechanism, }; } ``` For a reverse-charge transaction, `decision.data.rate` is `0`, `vatAmount` is `0`, and `legalFooter` is `"Reverse charge — VAT to be accounted for by the recipient"` (paired with `decision.data.legal_basis: "EU VAT Directive Article 196"`). Ready to print. ## Recurring subscriptions For B2B subscriptions billed monthly, re-validate the VAT number on each invoice cycle. vatverify's 30-day cache means monthly re-checks are free for most active customers: ```typescript title="lib/subscription-renewal.ts" import { Vatverify } from '@vatverify/node'; import { createMollieClient } from '@mollie/api-client'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const mollie = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! }); export async function renewalCheck( mollieCustomerId: string, sellerVat: string, buyerVat: string ) { // Re-validate, returns from cache if checked within 30 days const vatResult = await vat.validate({ vat_number: buyerVat }); if (!vatResult.data.valid) { // VAT deregistered since last check: pause subscription, notify customer await mollie.customers.update(mollieCustomerId, { metadata: { vatStatus: 'deregistered', reviewRequired: 'true' }, }); throw new Error(`Customer VAT deregistered: ${buyerVat}`); } // Refresh the tax decision for this period const decision = await vat.decide({ seller_vat: sellerVat, buyer_vat: buyerVat, }); return decision; } ``` Monthly caching works because VIES registration status rarely changes mid-cycle. If `vatResult.meta.source_status` is `"degraded"` (returned during a VIES outage), the cached data is still usable and the check is free. ## Testing Combine Mollie's test API key with vatverify's `vtv_test_*` keys for fully isolated testing: ```typescript title="tests/mollie-payment.test.ts" import { Vatverify } from '@vatverify/node'; import { createMollieClient } from '@mollie/api-client'; // Both clients use test credentials; no real calls made const vat = new Vatverify('vtv_test_xxx'); const mollie = createMollieClient({ apiKey: 'test_xxx' }); test('valid VAT creates Mollie customer with reverse-charge metadata', async () => { const result = await vat.validate({ vat_number: 'FR40303265045' }); expect(result.data.valid).toBe(true); const decision = await vat.decide({ seller_vat: 'DE811569869', buyer_vat: 'FR40303265045', }); expect(decision.data.mechanism).toBe('reverse_charge'); expect(decision.data.rate).toBe(0); expect(decision.data.legal_basis).toBe('EU VAT Directive Article 196'); expect(decision.data.invoice_note).toContain('Reverse charge'); }); test('invalid VAT throws before touching Mollie', async () => { const result = await vat.validate({ vat_number: 'FR00000000000' }); expect(result.data.valid).toBe(false); }); ``` ## FAQ ### Does Mollie validate VAT numbers? No. Mollie is a payment processor. It moves money, not tax logic. VAT validation, registration checks, and reverse-charge decisions are entirely your responsibility. vatverify is the layer that fills this gap before you call the Mollie API. ### Can I add the VAT number as Mollie customer metadata? Yes. The `metadata` field on a Mollie customer accepts arbitrary key-value pairs. The convention in the example above is `metadata.vatNumber` and `metadata.vatCountry`. Keep the format consistent across your codebase so you can query it reliably. ### What about Mollie Connect for marketplaces? Same pattern. Just validate for each sub-merchant and buyer separately. In a marketplace, you may have multiple seller VAT numbers. Run `/v1/decide` once per (seller_vat, buyer_vat) combination. Mollie Connect's routing is orthogonal to VAT logic. ### How do I handle different currencies? Mollie supports EUR, GBP, USD, and others. vatverify's `/v1/decide` always returns `rate` as a percentage to apply to the invoice subtotal. The currency is irrelevant to the VAT mechanism. Apply `decision.data.rate / 100 * subtotalInYourCurrency` regardless of which currency the Mollie payment is in. ### Is SEPA direct debit affected differently? No. SEPA direct debit is a payment method, not a different billing model. The same VAT validation pattern applies: validate the VAT number before creating the Mollie mandate, get the tax decision, and inject the invoice note into the mandate's associated invoice. ## Related guides - [Stripe VAT validation](/guides/stripe-vat-validation) - [WooCommerce VAT validation](/guides/woocommerce-vat-validation) - [EU VAT reverse-charge API](/guides/eu-vat-reverse-charge-api) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Open-source VAT libraries](/libraries) - [Webhooks](/features/webhooks) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/mollie-vat-validation --- ## Norwegian VAT validation: how brreg and MVA work Norway's VAT (MVA) system, org-number format, MOD-11 checksum, and the public brreg REST API. No auth required, but watch the deprecation notices. ## TL;DR - Norwegian VAT is called MVA (merverdiavgift), standard rate 25%. - VAT numbers are the 9-digit org-number with an optional `MVA` suffix appended for display. - The Brønnøysundregistrene (brreg) REST API is fully public, no authentication required. - vatverify wraps brreg, normalizes the `MVA` suffix, and caches responses. `@vatverify/node` (used in the Node example below) ships on npm at the API launch. Reading this before launch? The REST endpoint is live today. Swap any `vat.validate({ vat_number })` call for `fetch('https://api.vatverify.dev/v1/validate?vat_number=...')` with an `Authorization: Bearer` header. The Python and cURL snippets on this page work today without any package install. ## What is MVA? MVA (merverdiavgift) is Norway's value-added tax. Standard rate is **25%**. Reduced rates apply to specific categories: | Category | Rate | |----------|------| | Food | 15% | | Passenger transport, cinema, hotels | 12% | | Certain cultural / voluntary sector | 0% | Norway is not an EU member, but it is in the European Economic Area (EEA). Norwegian VAT is entirely separate from VIES. There is no cross-registry lookup. You must query brreg directly (or via vatverify) for any `NO`-prefixed number. ## Org-number format Norwegian businesses are identified by a 9-digit **organisasjonsnummer** (org-number). The last digit is a MOD-11 check digit. For display purposes, a VAT-registered business appends ` MVA` (or `mva`) to the org-number, but this suffix carries no additional information. It just signals VAT registration status. Examples: - `NO123456789`: international format - `NO123456789MVA`: international format with VAT suffix - `123456789`: domestic format (no prefix) vatverify normalizes all three to the 9-digit core before the lookup. ## MOD-11 checksum (Norway variant) The Norwegian MOD-11 uses a specific weight sequence applied to the first 8 digits, with the 9th digit acting as the check digit: ```typescript title="no-mod11.ts" function noMod11(digits: string): boolean { if (digits.length !== 9) return false; const weights = [3, 2, 7, 6, 5, 4, 3, 2]; const sum = digits.slice(0, 8).split('').reduce((a, d, i) => a + Number(d) * weights[i], 0); const check = (11 - (sum % 11)) % 11; return check === Number(digits[8]); } ``` The weights cycle through `3, 2, 7, 6, 5, 4, 3, 2` for positions 1–8. You multiply each digit by its weight, sum the products, subtract the result from 11 modulo 11, and compare against the 9th digit. If the check remainder is 10, no valid check digit exists and the number is structurally invalid. `@vatverify/vat-rates` implements this algorithm for offline validation before any upstream request is made. ## brreg REST API The Brønnøysundregistrene provides a public REST API for the Entity Register (Enhetsregisteret): ``` https://data.brreg.no/enhetsregisteret/api/enheter/{org_nummer} ``` No authentication, no API key. It is an open government dataset. Responses are JSON. The key fields for VAT validation are: - `registrertIMvaregisteret`: boolean, `true` if the business is VAT-registered - `organisasjonsform.kode`: the entity type code (e.g., `AS` for limited company, `ENK` for sole proprietor) - `navn`: the registered business name Note: the brreg API response uses `registrertIMvaregisteret` (Norwegian spelling; `mvaregisteret` is one word meaning "the VAT register"). vatverify exposes this as `data.valid` in its unified response so you don't need to remember the Norwegian field name. ## Sample request/response ```typescript title="validate-norwegian.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const result = await vat.validate({ vat_number: 'NO123456789MVA' }); // result.data.valid → true // result.data.company?.name → "Example AS" // result.data.country.code → "NO" ``` ```python title="validate_norwegian.py" import os import httpx response = httpx.get( "https://api.vatverify.dev/v1/validate", params={"vat_number": "NO123456789MVA"}, headers={"Authorization": f"Bearer {os.environ['VATVERIFY_API_KEY']}"}, ) result = response.json() # result["data"]["valid"] → True # result["data"]["company"]["name"] → "Example AS" # result["data"]["country"]["code"] → "NO" ``` ```bash curl "https://api.vatverify.dev/v1/validate?vat_number=NO123456789MVA" \ -H "Authorization: Bearer $VATVERIFY_API_KEY" ``` Example response: ```json { "data": { "valid": true, "vat_number": "NO123456789", "country": { "code": "NO", "name": "Norway" }, "company": { "name": "Example AS", "address": "Exempelgata 1, 0001 Oslo" }, "verified_at": "2026-04-14T15:42:03.451Z" }, "meta": { "request_id": "0190f8ea-a5b2-7000-a123-000000000000", "cached": false, "source": "brreg", "source_status": "live", "latency_ms": 312 } } ``` ## VAT-registered vs registered business A business can be registered in the Entity Register (Enhetsregisteret) without being in the VAT Register (Merverdiavgiftsregisteret). Registration in the Entity Register is mandatory for all businesses above a minimal size; VAT registration only applies if annual turnover exceeds NOK 50,000. vatverify checks `registrertIMvaregisteret` specifically. A business that exists in brreg but has `registrertIMvaregisteret: false` returns `data.valid: false`. ## EORI adjacency Norwegian EORI numbers (Economic Operators Registration and Identification) are sometimes confused with MVA numbers because they share the same 9-digit org-number core. An EORI number for a Norwegian business looks like `NO123456789`, identical to the VAT number. The difference is context and registry: EORI is administered by Norwegian Customs (Tolldirektoratet), not brreg. If you need EORI verification, that is a separate lookup; vatverify's `validate` endpoint covers VAT (MVA) registration only. ## Rate limits brreg's official documentation states a limit of **60 requests per second**. Anecdotally the API tolerates bursts above 100 req/sec without throttling, but 60 req/sec is the safe planning assumption if you are hitting it directly. With vatverify, cached lookups never reach brreg. Only cache misses generate upstream requests. ## FAQ ### Do I need a Brønnøysundregistrene account? No. The brreg Enhetsregisteret API is a fully public open-government dataset. No registration, no API key, no rate-limit agreement required. You can hit it directly from any IP. vatverify still wraps it because caching, normalization, and the unified response schema are worth having even for a public endpoint. ### What's the difference between the 9-digit org-number and the MVA org-number? They are the same 9 digits. `MVA` is a display-only suffix that signals "this org-number is VAT-registered." It carries no additional digits or encoded information. Stripping `MVA` from `NO123456789MVA` gives you `NO123456789`, the same number, valid for both Entity Register and VAT Register lookups. ### Is brreg ever down? Rarely. Brønnøysundregistrene maintains ≥99.95% uptime on the public API and publishes a status page at [status.brreg.no](https://status.brreg.no). Planned maintenance windows are announced in advance and are infrequent. vatverify returns cached results with `meta.source_status: "degraded"` in the rare event of an outage. ### Does the API cover EORI? Partially. brreg can confirm that an org-number is registered, which is a prerequisite for EORI. But EORI validation itself (confirming the EORI number is active in the customs system) requires a separate lookup at Tolldirektoratet. EORI is not a VAT concern. It is a customs identification. vatverify's validate endpoint covers MVA (VAT) registration only. ### What about sole proprietors (ENK)? Sole proprietors (Enkeltpersonforetak, entity type code `ENK`) receive the same 9-digit org-number format as any other entity. Many sole proprietors are below the NOK 50,000 VAT registration threshold and are therefore not VAT-registered. If you validate an ENK org-number and the business has not crossed the threshold, you will get `data.valid: false`. The company exists, but is not a VAT taxpayer. ## Related guides - [VIES API guide](/guides/vies-api-guide) - [HMRC VAT check API](/guides/hmrc-vat-check-api) - [Swiss UID validation guide](/guides/swiss-uid-validation-guide) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) Source: https://www.vatverify.dev/guides/norwegian-vat-brreg --- ## Offline VAT validation: zero dependencies, real checksums Validate EU VAT numbers without a network call using @vatverify/vat-rates. 44-country rates data, real MOD-11 / MOD-97 / Luhn / HMRC 97-55 algorithms. ## TL;DR - `npm install @vatverify/vat-rates` or `pip install vatverify-rates`: zero runtime dependencies. - `validate('IE6388047V')` runs real checksum algorithms (MOD-11, MOD-97, Luhn, HMRC 97-55) in ~2μs. - Use offline for format + checksum; use the live API when you need registry confirmation. `@vatverify/vat-rates` (JavaScript) is on npm today, published and passing. `vatverify-rates` (Python) ships on PyPI alongside the API launch; v0.1.0 is packaged with 160 tests green. The hybrid examples below use `@vatverify/node` which also ships at launch; until then, substitute a plain `fetch` call to `/v1/validate`. ## When you want offline vs live | Use offline if… | Use live API if… | |---|---| | You need to reject obviously wrong VAT numbers at the form level | You need to confirm the company is actively registered | | Latency is critical (e.g., validating on every keystroke) | You need the company name or address | | You're processing a bulk CSV with millions of rows | You need an audit trail of when the number was verified | | You're running in a serverless environment with tight CPU budgets | You're issuing an invoice with reverse-charge treatment | | Network is unavailable (offline-first app, edge worker, test suite) | You need the `invoice_note` for Article 226 compliance | ## Installation ```bash # JavaScript / TypeScript npm install @vatverify/vat-rates # Python pip install vatverify-rates # or uv add vatverify-rates ``` ## Format + checksum in one call ```typescript title="validate.ts" import { validate } from '@vatverify/vat-rates'; // Valid format and checksum const good = validate('IE6388047V'); // { valid: true } // Invalid checksum (well-formed but wrong check digit) const bad = validate('IE6388047X'); // { valid: false, errors: ['invalid checksum'] } // Invalid format const ugly = validate('NOTAVAT'); // { valid: false, errors: ['invalid format'] } ``` ```python title="validate.py" from vatverify_rates import validate good = validate("IE6388047V") # ValidateResult(valid=True, errors=()) bad = validate("DE000000000") # ValidateResult(valid=False, errors=("invalid checksum",)) ``` ## Rate lookups Retrieve the standard VAT rate for any country: ```typescript title="rates.ts" import { getStandardRate, getRate } from '@vatverify/vat-rates'; // Standard rate as a number const rate = getStandardRate('DE'); // 19 // Full Rate object with all brackets const fullRate = getRate('DE'); // { // standard: 19, // reduced: [7], // super_reduced: null, // parking: null, // currency: 'EUR', // last_verified: '2026-04-01', // // ...plus country_code, country, flag, format, pattern, checksum, etc. // } ``` ## Country utilities ```typescript title="country-utils.ts" import { countryName, getFlag, isEUMember } from '@vatverify/vat-rates'; countryName('DE'); // 'Germany' getFlag('DE'); // '🇩🇪' isEUMember('DE'); // true isEUMember('NO'); // false (Norway: EEA but not EU) isEUMember('GB'); // false (UK: post-Brexit) ``` ## Which countries, which algorithms The library covers 44 countries. EU-27 is fully supported, plus UK (HMRC 97-55), Switzerland (CHE UID MOD-11), Liechtenstein (same UID endpoint), Norway (MOD-11), and a set of non-EU jurisdictions that publish a format and, in many cases, a real checksum. | Country | Prefix | Algorithm | Catches | |---------|--------|-----------|---------| | Austria | AT | MOD-11 variant | typos in any of the 8 digits | | Belgium | BE | MOD-97 | transposition errors | | Bulgaria | BG | Weighted MOD-11 (9 or 10 digits) | transposed digits | | Cyprus | CY | Sum-weighted check letter | wrong check letter | | Czechia | CZ | Weighted MOD-11 | single-digit errors | | Germany | DE | MOD-11 | transposition in 8 digits | | Denmark | DK | Weighted MOD-11 | any single-digit error | | Estonia | EE | Weighted MOD-10 | typos | | Greece | EL | Weighted MOD-11 | typos in 8 digits | | Spain | ES | Letter-position checksum (NIF / NIE / CIF) | mixed alphanum errors | | Finland | FI | Weighted MOD-11 | typos in 7 digits | | France | FR | MOD-97 of SIREN | SIREN transposition | | Croatia | HR | OIB MOD-11,10 | transposition | | Hungary | HU | Weighted MOD-10 | typos | | Ireland | IE | Weighted MOD-23 (letter check) | typos in 7 digits | | Italy | IT | Luhn (11 digits) | transposition | | Lithuania | LT | Weighted MOD-11 (9 or 12 digits) | typos | | Luxembourg | LU | MOD-89 | transposition | | Latvia | LV | MOD-3, weighted | typos | | Malta | MT | Weighted MOD-37 | typos | | Netherlands | NL | Weighted MOD-11 + fixed `B` | typos | | Poland | PL | Weighted MOD-11 | transposition | | Portugal | PT | Weighted MOD-11 | transposition | | Romania | RO | Weighted MOD-11 | typos | | Sweden | SE | Luhn on 10 digits + fixed `01` suffix | transposition | | Slovenia | SI | Weighted MOD-11 | typos | | Slovakia | SK | MOD-11 | typos | | UK | GB | HMRC 97-55 | 3-variant check (0, 42, 55) | | Northern Ireland | XI | HMRC 97-55 (same as GB) | same | | Switzerland | CHE | UID MOD-11 | typos in 8 digits | | Liechtenstein | LI | Routed to CHE UID | same | | Norway | NO | MOD-11 org-number | typos | Twelve additional non-EU European countries (AD, AL, BA, GE, IS, MD, ME, MK, RS, TR, UA, XK) are supported with format checks only. No authoritative checksum is published by the tax authority, so regex is what exists. What the real algorithms catch that regex can't: transposed digits (`DE123456798` vs `DE123456789`), single-digit typos, accidental letter substitution in mixed-alphanumeric formats (ES, IE), and the surprisingly common case of someone writing their own VAT number with the last digit zeroed out. On a checkout form with human-typed input, checksum rejection cuts "invalid VAT" support tickets by roughly half in our internal testing. ## Why it beats regex: benchmarks Regex catches structural errors only: wrong prefix, wrong digit count, missing separator. It cannot catch mathematical errors: a transposed digit, a typoed check character, a recycled number that fails its country's modular arithmetic. A mathematical checksum rejects thousands of numbers a regex would pass. | | @vatverify/vat-rates | live API call | hand-rolled regex | |---|---|---|---| | Latency per validation | **~2 μs** | ~200 ms (avg incl. VIES) | ~0.5 μs | | Catches format errors | yes | yes | yes | | Catches checksum errors | yes | yes | **no** | | Catches deregistered numbers | no | yes | no | | Network required | no | yes | no | | Bundle size | **< 50 kB** min+gz | ~150 kB (full SDK) | ~0 (inline regex) | | API key required | no | yes | no | Measured on an M1 MacBook Pro, Node 20, 10k iterations per run, median of 5 runs. The library is roughly 100,000× faster than a live round-trip for the overwhelming majority of inputs you need to reject (the ones that are structurally or mathematically wrong), and under a tenth the bundle size of a full SDK. Regex is faster still, but only at the cost of admitting numbers that aren't mathematically valid, which defeats the point. The pragmatic pattern is a two-stage filter: `@vatverify/vat-rates` locally to kill obvious garbage at ~2 μs, then the live API only for the numbers that pass format + checksum. On a bulk import of 10,000 customer records, this typically cuts live API calls by 15–30%. ## Combining with the live API The most efficient pattern: use offline validation to filter out obviously invalid numbers, then only call the live API for numbers that pass the format + checksum check: ```typescript title="hybrid-validate.ts" import { validate as offlineValidate } from '@vatverify/vat-rates'; import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function validateVat(vatNumber: string) { // Step 1: fast offline check, no network, no API quota consumed const offline = offlineValidate(vatNumber); if (!offline.valid) { return { valid: false, source: 'offline' as const, errors: offline.errors }; } // Step 2: live registry check, only reached if format + checksum pass const live = await vat.validate(vatNumber); return { ...live, source: 'live' as const }; } ``` This pattern saves API quota on bulk imports and reduces live API calls by rejecting malformed numbers before they hit the network. ```python title="hybrid_validate.py" import os import httpx from vatverify_rates import validate as offline_validate _client = httpx.Client( base_url="https://api.vatverify.dev", headers={"Authorization": f"Bearer {os.environ['VATVERIFY_API_KEY']}"}, timeout=30.0, ) def validate_vat(vat_number: str): # Step 1: fast offline check offline = offline_validate(vat_number) if not offline.valid: return {"valid": False, "source": "offline", "errors": list(offline.errors)} # Step 2: live registry check (REST API, no Python SDK yet) response = _client.get("/v1/validate", params={"vat_number": vat_number}) response.raise_for_status() return {"source": "live", **response.json()} ``` ## FAQ ### The docs say "44 countries" but VIES only covers 32. What's the difference? The 32-country live API coverage refers to registries we query in real time (EU-27 + UK, Northern Ireland (XI), Switzerland, Liechtenstein, Norway). The 44-country offline library adds twelve non-EU European jurisdictions where no live registry is publicly accessible but the official VAT number format is documented (Andorra, Albania, Bosnia, Georgia, Iceland, Moldova, Montenegro, North Macedonia, Serbia, Turkey, Ukraine, Kosovo). These are format-only checks. No authoritative checksum algorithm is published for them. ### How big is the bundle? `@vatverify/vat-rates` is under 50 kB minified + gzipped, including all country data and algorithm implementations. It has zero runtime dependencies. ### Is it tree-shakeable? Yes, all exports are named and the package ships with `"sideEffects": false`. If you only import `validate`, bundlers will strip the rate lookup and country utility code. ### Does it work in the browser? Yes, the library targets ES2020, has no Node.js-specific APIs, and works in any modern browser, Deno, Cloudflare Workers, and Vercel Edge Functions. ### Do TypeScript types ship with the package? Yes, the npm package includes full TypeScript declarations. `ValidationResult`, `Rate`, and all utility return types are exported. ## Related guides - [Open-source VAT libraries](/libraries): the full library lineup, npm + PyPI links - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in Next.js](/guides/validate-vat-number-nextjs) - [EU reverse-charge API](/guides/eu-vat-reverse-charge-api) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/offline-vat-validation-library --- ## OSS and IOSS implementation guide for cross-border B2C sellers How OSS and IOSS work for cross-border B2C sales to EU consumers, when each scheme applies, the €10,000 and €150 thresholds, and the integration patterns that hold up under audit. Builds on the validation flow. ## TL;DR - **OSS** (One-Stop-Shop) lets EU sellers report VAT on cross-border B2C sales of goods and services to other EU consumers through a single quarterly return, charged at the destination country's rate. - **IOSS** (Import One-Stop-Shop) is the parallel scheme for distance sales of imported goods up to €150 to EU consumers; the seller charges destination VAT at checkout instead of leaving customs to collect it at the border. - The B2B reverse-charge mechanism does not apply to either scheme. OSS and IOSS are strictly B2C. - The €10,000 EU-wide distance-sales threshold (for goods plus telecommunications, broadcasting, and electronic services) decides whether OSS applies. The €150 per-consignment intrinsic-value cap decides whether IOSS applies. ## What problem these schemes solve Before 2021, an EU seller crossing the per-country distance-selling threshold (€35,000 in most countries, €100,000 in some larger ones) was required to register for VAT in that destination country, file a domestic VAT return there, and remit the destination's VAT. A seller crossing several thresholds at once needed several VAT registrations, several local accountants, and several filing schedules. The 2021 reform replaced this fragmented model with two unified schemes: 1. **OSS (Union)**: a single registration in the seller's home member state covers all intra-EU B2C distance sales of goods and services. One quarterly return reports VAT for all destination countries; the seller's home tax authority distributes the collected VAT to each destination. 2. **IOSS**: a parallel registration for B2C distance sales of imported goods up to €150 per consignment. The seller charges destination VAT at checkout and reports through a single monthly IOSS return; parcels clear customs without further VAT collection if they reference the IOSS number. A third scheme, **Non-Union OSS**, applies to non-EU sellers supplying B2C services to EU consumers (a US SaaS company selling to EU consumers, for example). It rolled out of the 2015 MOSS scheme into OSS in 2021 with broader scope. Every modern cross-border EU e-commerce stack ships with at least one of these schemes wired into its tax engine; the question is which scheme, and the trigger conditions for switching between them. ## OSS: the €10,000 threshold A seller below the €10,000 EU-wide threshold charges VAT at the seller's domestic rate on intra-EU B2C distance sales. Above it, OSS applies and the destination country's rate is charged. Three details about the threshold worth knowing: 1. **EU-wide, not per-country.** The threshold sums across all destination countries. A seller with €4,000 of intra-EU B2C sales to France plus €7,000 to Germany has crossed the threshold; both flows go through OSS afterwards. 2. **Goods plus TBE services.** Telecommunications, broadcasting, and electronically supplied services (TBE) count toward the threshold, as do distance sales of goods. Other cross-border B2C services follow general place-of-supply rules independently. 3. **One-year carry-over.** Once a seller crosses the threshold in a given calendar year, OSS applies for the rest of that year and the entire following year regardless of subsequent volume. Under the threshold for two consecutive years, the seller can opt back to domestic-rate charging. The threshold counter is a domain object worth modeling explicitly: ```ts interface OssThresholdState { year: number; euB2cSalesYtd: number; thresholdCrossedAt: Date | null; ossActiveUntil: Date; // end of the year following the crossing } function shouldUseOss(state: OssThresholdState, now: Date): boolean { if (state.thresholdCrossedAt && now <= state.ossActiveUntil) return true; return state.euB2cSalesYtd >= 10_000; } ``` A common pitfall: tracking the threshold per-store rather than per-legal-entity. A seller operating multiple stores under one VAT number has one threshold counter across all stores combined. ## OSS: the per-country rate matrix When OSS applies, the destination country's standard or reduced rate determines what the seller charges. This means the seller's invoicing engine needs: 1. The destination country (derived from the buyer's address; see place-of-supply evidence requirements below). 2. The applicable rate matrix for that country. 3. The category-to-rate mapping for the goods or services being sold. Hardcoding the rate matrix as constants will go stale within 12 to 18 months on average. Several countries have changed rates in 2024-2025: Switzerland 8.1% (Jan 2024), Estonia 22% (Jan 2024) and 24% (Jul 2025), Finland 25.5% (Sep 2024), Czechia 12% reduced (Jan 2024), Slovakia 23% (2025). See the [EU VAT rates 2026 page](/eu-vat-rates) for the current rate table. vatverify exposes per-country rates through `/v1/rates/{country}` with date-aware lookups; that decouples the rate logic from your release cadence. ## OSS: place-of-supply evidence For B2C digital services, the consumer's country drives the place of supply, which drives the rate. EU rules require the seller to maintain at least two non-contradictory pieces of evidence of the consumer's location. Acceptable evidence types: - The buyer's billing address. - The IP address used to place the order. - The country of the payment-method issuer (BIN-derived for cards, IBAN-derived for SEPA). - The country code of the SIM card (for telecommunications). - The fixed land-line location of the service. Three matching signals out of three is the strongest defensible position; two-of-three is the regulatory minimum. Storing the evidence alongside the order record is what makes the audit-trail reconstructible. ## IOSS: the €150 threshold IOSS applies to distance sales of imported goods with an intrinsic value of €150 or less per consignment. Two details worth knowing: 1. **Intrinsic value, not invoice total.** Transport and insurance shown separately on the invoice are excluded from the threshold calculation. A €140 product with €15 shipping is in scope (intrinsic value €140); a €151 product is out. 2. **No per-customer counter.** Unlike the OSS €10,000 threshold, IOSS applies per-consignment. A single consumer ordering twenty €100 items in separate parcels stays within IOSS for each one. Above €150, the IOSS scheme is unavailable. Goods enter through the standard import process and customs collects VAT (and often a handling fee from the postal carrier) before releasing the parcel to the consumer. ## IOSS: the IM number An IOSS registration produces a 12-character identifier starting with `IM`, e.g. `IM1234567890`. The number is quoted on the customs declaration so the parcel clears without further VAT collection. Two integration points to handle correctly: 1. **The IOSS number must accompany the customs data.** Postal and courier carriers (DHL, UPS, FedEx, La Poste, Royal Mail) accept the IOSS number through their structured shipping APIs. Without the number on the declaration, the parcel falls back to standard import treatment and the consumer is charged again at the border. 2. **The IOSS number is not in VIES.** It is issued by a single member state of identification and validated through the EU's dedicated IOSS registry lookup (`ec.europa.eu/taxation_customs/dds2/eos/iosss_consultation.jsp`). vatverify currently does not validate IOSS numbers; for that flow the EU portal is the right tool. ## IOSS: who registers for it A non-EU seller registering for IOSS typically needs an EU-established intermediary. The intermediary holds the IOSS number on the seller's behalf and is jointly liable for VAT collection and reporting. Several specialist providers operate as IOSS intermediaries; many e-commerce platforms (Shopify Markets Pro, others) bundle intermediary services as part of their cross-border offering. EU-established sellers can register for IOSS directly without an intermediary. The single-MS-of-identification model means one IOSS registration covers all EU consumer destinations. ## When neither scheme applies Two cross-border B2C scenarios fall outside both OSS and IOSS: 1. **B2C sales below the €10,000 threshold.** Charge the seller's domestic VAT rate. Domestic VAT return reports the sale. 2. **B2C imports above €150.** Standard customs flow; no IOSS. Customs collects VAT at the border, the consumer pays it before release. Beyond those, B2B reverse-charge applies for B2B cross-border, and the seller's domestic rate applies for B2C domestic sales. There are no other primary cases inside the EU. ## Decision tree ``` Is the buyer a consumer (no VAT number)? ├─ No (B2B with valid VAT) → reverse charge (see /guides/reverse-charge-vat-explained) └─ Yes (B2C) └─ Are the goods imported from outside the EU? ├─ Yes │ └─ Is the consignment intrinsic value <= €150? │ ├─ Yes → IOSS: charge destination VAT at checkout │ └─ No → standard import: customs collects VAT at border └─ No (intra-EU) └─ Is the seller's EU-wide B2C YTD >= €10,000? ├─ Yes → OSS: charge destination VAT └─ No → charge seller's domestic VAT rate ``` ## Audit trail Both schemes require seller-side record retention. Specifics vary by member state of identification but the common floor is 10 years for OSS and IOSS records. Per-order, persist: - The decision tree branch that fired (OSS, IOSS, domestic, B2B reverse-charge). - The buyer location evidence (at least two non-contradictory signals for digital services). - The applicable rate and the rate's effective date. - For IOSS, the consignment intrinsic value and the IOSS number used. vatverify retains the validation-side records (VAT number lookups, registry responses, consultation numbers); the OSS and IOSS reporting records live in your accounting or tax system. ## What this guide does not cover - **Filing the OSS or IOSS return itself.** That is a tax-administration submission, not an API call. - **Choosing an IOSS intermediary.** Several specialist providers exist; selection is a procurement and pricing question outside vatverify's scope. - **Pre-2021 MOSS recordkeeping.** MOSS records remain in scope for periods up to 30 June 2021; corrections to those returns continue to flow through the original member state of identification. ## Related guides - [Reverse charge VAT explained](/guides/reverse-charge-vat-explained) - [What is a VAT number?](/guides/what-is-a-vat-number) - [EU VAT rates 2026](/eu-vat-rates) - [OSS glossary](/glossary/oss) - [IOSS glossary](/glossary/ioss) - [Place of supply glossary](/glossary/place-of-supply) Source: https://www.vatverify.dev/guides/oss-ioss-implementation-guide --- ## Reverse charge VAT explained: when it applies, what it does to your invoice, and how to evidence it Reverse charge shifts the VAT-accounting obligation from the seller to the buyer in cross-border B2B sales between EU member states. This guide covers when it applies, what changes on the invoice, the evidence requirements, and the domestic-reverse-charge variants that share the name. ## TL;DR - Reverse charge shifts the obligation to account for VAT from the seller to the buyer. - Triggered by a valid, registered VAT number on the buyer side in a cross-border B2B EU sale. - The seller invoices net of VAT with a "reverse charge" reference; the buyer self-accounts on their own return. - A separate set of domestic-reverse-charge rules apply to specific high-fraud sectors (construction, scrap metal, mobile phones, integrated circuits) between two businesses in the same country. ## What reverse charge does Under normal VAT, the seller charges VAT to the buyer at the seller's local rate, collects it, and remits it to the tax authority. Reverse charge inverts that flow: the seller invoices net of VAT, and the buyer reports the VAT in their own return, both as input VAT (deductible) and output VAT (payable). For a fully VAT-registered buyer, the two cancel out; the buyer pays nothing extra. The mechanism has two purposes: 1. **Avoids forcing the seller to register for VAT in the buyer's country.** Without reverse charge, a German seller making one-off sales to a French buyer would have to register for French VAT, file French VAT returns, and remit French VAT collected. Reverse charge eliminates that overhead by moving the reporting burden to the buyer, who is already registered locally. 2. **Reduces VAT-fraud exposure on cross-border supplies.** When the seller never collects the VAT, there is nothing for a fraudulent intermediary to disappear with. The buyer-side self-account flow is harder to abuse than a chain of intra-Community sellers each collecting and remitting. ## When reverse charge applies Three conditions must be met: 1. **B2B**: both parties are VAT-registered businesses. A consumer buyer falls under different rules (typically OSS for B2C distance sales). 2. **Cross-border**: seller and buyer are in different EU member states. A domestic sale between two German businesses is not reverse charge under the standard EU rule. 3. **Valid VAT number on the buyer side**: confirmed through VIES (or HMRC for the UK, BFS for Switzerland, BRREG for Norway). A VIES check that returns `valid: false` means reverse charge does not apply; the seller must charge their domestic VAT. Articles 138 (zero-rate on intra-Community supplies of goods) and 196 (recipient liable for VAT on B2B services) of the EU VAT Directive 2006/112/EC are the legal basis. ## What changes on the invoice Three things change when reverse charge applies: 1. **No VAT line item.** The seller invoices net of VAT. 2. **Mandatory invoice note.** EU rules require a reference such as "Reverse charge" or "VAT to be accounted for by the recipient" on the invoice. Article 226(11a) of the VAT Directive specifies the requirement, though the exact wording varies by member state. 3. **Both parties' VAT numbers.** The seller's VAT number and the buyer's VAT number must both appear on the invoice. Without the buyer's VAT number, the supply cannot be zero-rated. A typical reverse-charge B2B invoice line: ``` Subtotal: 1,000.00 EUR VAT (0%): 0.00 EUR Total: 1,000.00 EUR Reverse charge: VAT to be accounted for by the recipient (EU VAT Directive Article 196) ``` Some invoicing systems (Stripe Invoicing, QuickBooks, Xero, Sage) auto-generate the reverse-charge note when the buyer's tax-exempt flag is set. Others require manual configuration of the invoice template. ## How to evidence reverse charge A B2B audit asks two questions about a zero-rated invoice: 1. Was the buyer's VAT number valid at the time of supply? 2. Did you exercise due diligence in confirming validity? The audit-trail artifacts that answer both: - **A VIES `consultation_number`** (when present in the response) ties the lookup to a date and a result. EU member states recognize the consultation number as evidence of good-faith verification. See the [consultation number glossary](/glossary/consultation-number) for the mechanic. - **For German sellers, a BZSt qualified confirmation** (`/v1/confirm` in vatverify) is an alternative artifact that returns per-field match codes for name, street, postcode, and town. §18e UStG specifically requires this for VAT-free intra-EU supplies. See the [BZSt §18e guide](/guides/bzst-paragraph-18e-qualified-confirmation). - **The raw VIES or HMRC response payload** including the timestamp. Persist it with the invoice record, not just a derived "valid" boolean. Retention requirements vary by member state but most require 6 to 10 years for VAT records. The consultation number and underlying response should be retrievable for at least the longest applicable retention period. ## Domestic reverse charge: same name, different mechanism Several EU member states apply a domestic reverse-charge regime to specific high-fraud-risk sectors between two businesses within the same country. The mechanism is the same (buyer self-accounts) but the trigger is different (specific sector, not cross-border). Common sectors covered by domestic reverse-charge across EU member states: | Sector | Countries that apply domestic reverse-charge | |---|---| | Construction services | Austria, Belgium, Czechia, Germany, Hungary, Ireland, Italy, Latvia, Lithuania, Netherlands, Slovakia, Slovenia, Spain, Sweden | | Scrap metal | Most EU member states | | Mobile phones, integrated circuits | Most EU member states | | Gas and electricity (B2B) | Most EU member states | | Cereals, oilseeds | Bulgaria, Czechia, Hungary, Italy, Romania, Slovakia | | Construction subcontracting | Ireland (RCT regime), UK | The sector lists are not unified across the EU; each member state publishes its own list under its national VAT law. A B2B invoicing engine that handles only cross-border reverse-charge will under-apply the rule for domestic supplies in these sectors. The two regimes can compose. A Belgian construction service supplied to a French buyer is both: - **Cross-border intra-Community reverse-charge** (Belgian seller, French buyer, different member states) under Article 196. - **Domestic-reverse-charge in scope under Belgian rules** if the supply were domestic. The cross-border classification dominates. The Belgian seller invoices net of VAT, the French buyer self-accounts under Article 196. ## Reverse charge vs OSS A common confusion: a cross-border sale to an EU consumer (B2C) is not reverse charge. It falls under OSS (the One-Stop-Shop scheme) once the EU-wide €10,000 threshold is crossed. The mechanism is different: - **Reverse charge (B2B cross-border)**: seller invoices net of VAT, buyer self-accounts. - **OSS (B2C cross-border)**: seller charges destination-country VAT, reports through a single OSS return in their home member state. The decision tree at the seller's invoicing engine: ``` Is the buyer a business with a valid VAT number? ├─ Yes │ └─ Is the buyer in a different EU member state? │ ├─ Yes → reverse charge (zero-rate, "reverse charge" note) │ └─ No → standard rate (or domestic-reverse-charge if in scope) └─ No (consumer) └─ Are intra-EU B2C sales above the €10,000 threshold? ├─ Yes → OSS (charge destination rate, report through OSS) └─ No → standard rate (charge seller's domestic rate) ``` vatverify's `/v1/decide` endpoint encodes this decision tree for B2B sales (the top half of the diagram). For B2C / OSS the inputs and evidence requirements are different and live in a separate flow. ## Implementation patterns A minimal reverse-charge implementation in code looks like this: ```ts import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); interface SaleInput { sellerCountry: string; buyerVatNumber: string; } async function reverseChargeApplies(sale: SaleInput): Promise { const result = await vat.validate({ vat_number: sale.buyerVatNumber }); if (result.data?.valid !== true) return false; if (!result.data.country) return false; return result.data.country !== sale.sellerCountry; } ``` For full tax-decision logic including the legal-basis annotation and the human-readable invoice note, the `/v1/decide` endpoint returns a structured payload: ```ts const decision = await vat.decide({ seller_vat: 'IE6388047V', buyer_vat: 'DE811569869', }); // decision.data.charge_vat === false // decision.data.mechanism === 'reverse_charge' // decision.data.legal_basis === 'EU VAT Directive Article 196' // decision.data.invoice_note === 'Reverse charge: VAT to be accounted for by the recipient' ``` `/v1/decide` is currently on the Business plan; for stores with simpler needs the validate-then-branch pattern above works on every plan. ## Common pitfalls - **Treating an invalid VAT number as a "soft failure" and zero-rating anyway.** Every EU member state's tax authority can recover unpaid VAT (plus interest and penalties) from the seller if a zero-rated supply turns out to have an invalid buyer VAT number. The consultation number is the evidence that protects you; without it, the audit defaults to the seller's liability. - **Not re-validating across customer relationships.** A buyer's VAT number that was valid at onboarding may be invalid six months later. Re-validate B2B customers at least quarterly. - **Mixing up cross-border and domestic-reverse-charge.** Same name, different evidence requirements. Domestic-reverse-charge does not require VIES validation; it applies based on the supply's sector classification. - **Forgetting the invoice note.** A reverse-charge invoice without the legally-required note is a non-compliant invoice. Some member states penalize this independently of whether the underlying supply was correctly classified. ## Related guides - [EU VAT reverse charge API guide](/guides/eu-vat-reverse-charge-api) - [What is a VAT number?](/guides/what-is-a-vat-number) - [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) - [Reverse charge glossary](/glossary/reverse-charge) - [Consultation number glossary](/glossary/consultation-number) Source: https://www.vatverify.dev/guides/reverse-charge-vat-explained --- ## Shopify VAT validation: from B2B sign-up to tax-exempt checkout Add real VAT number validation to a Shopify store, mark cross-border B2B customers tax-exempt, and store the audit trail. Built around Shopify B2B, Customer Accounts, and the Admin GraphQL API. ## TL;DR - Capture the VAT number on customer registration or in a B2B account form. - Validate it server-side with vatverify, then set `Customer.taxExempt = true` on cross-border B2B customers with a valid VAT number. - Persist the validation result on a customer metafield so the audit trail survives a re-validation cycle. ## The Shopify VAT landscape Shopify has improved on B2B compliance significantly since 2023. Three things matter for VAT validation: 1. **Shopify B2B** is the merchant offering for wholesale and B2B stores. It supports company profiles, price lists, payment terms, and per-customer tax exemptions. Most of what follows assumes B2B is enabled, but the same pattern works for a single Shopify Plus store with a B2B section. 2. **Customer Accounts (new)** are the modern customer-account experience replacing the legacy account UI. They can carry custom fields and metaobjects. 3. **Shopify Tax** automatically calculates EU VAT at checkout based on the buyer's address, but it does not validate VAT numbers or decide reverse-charge. A tax-exempt flag has to come from somewhere; that's where vatverify slots in. If you're still on the legacy checkout, parts of this pattern (theme.liquid customizations, the deprecated Script Editor) work but are increasingly fragile. The modern path is a Shopify app with the Admin GraphQL API. ## Architecture overview The validation flow has four moving parts: 1. **A form**: collects the customer's company name, country, and VAT number. Lives on the customer registration page or a dedicated B2B onboarding flow. 2. **A Shopify app**: receives form submission via webhook or app proxy, calls vatverify's `/v1/validate` endpoint, and writes the result. 3. **Shopify Admin API**: updates `Customer.taxExempt` and customer metafields based on the validation result. 4. **A re-validation job**: on a schedule (monthly is a sensible default), re-checks each B2B customer's VAT number to catch deregistrations. Shopify charges Shopify Tax on every order by default. Setting `taxExempt = true` is what tells Shopify to skip VAT collection on that customer's orders. ## Capturing the VAT number For a Shopify B2B store, the cleanest place to capture the VAT number is the company-profile screen. For a non-B2B Shopify Plus store, add a custom field to the customer registration form using customer-accounts metaobjects. A minimal form field rendered in a Customer Account UI extension: ```tsx title="extensions/customer-accounts-vat/src/Checkout.tsx" import { reactExtension, TextField, BlockStack, Banner, useApi, } from '@shopify/ui-extensions-react/customer-account'; export default reactExtension('customer-account.profile.block.render', () => ); function VatField() { const { i18n } = useApi(); return ( We validate cross-border B2B VAT numbers against VIES, HMRC, BFS, and BRREG. ); } ``` The field submission triggers a customer-update webhook, which the validation app subscribes to. ## The validation app The app is a server-side Shopify app receiving the `customers/update` webhook. The handler: 1. Reads the new VAT number off the customer payload. 2. Calls vatverify's `/v1/validate` endpoint. 3. Writes the result to a customer metafield namespaced as `vat.validation`. 4. Sets `taxExempt = true` if the validation succeeds and the customer's country differs from the shop's home country (cross-border B2B is the reverse-charge case). ```ts title="app/routes/webhooks.customers-update.ts" import { authenticate } from '../shopify.server'; import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function action({ request }: { request: Request }) { const { shop, payload, admin } = await authenticate.webhook(request); const customer = payload as { id: string; email: string; metafields?: Array<{ namespace: string; key: string; value: string }> }; const vatNumber = readMetafield(customer.metafields, 'custom', 'vat_number'); 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 sellerCountry = process.env.SHOP_HOME_COUNTRY!; const isCrossBorderB2B = valid && buyerCountry && buyerCountry !== sellerCountry; await admin.graphql( `#graphql mutation customerUpdate($input: CustomerInput!) { customerUpdate(input: $input) { userErrors { field message } } }`, { variables: { input: { id: `gid://shopify/Customer/${customer.id}`, taxExempt: !!isCrossBorderB2B, metafields: [ { namespace: 'vat', key: 'validation', type: 'json', value: JSON.stringify({ vat_number: vatNumber, valid, country: buyerCountry, checked_at: new Date().toISOString(), consultation_number: result.data?.consultation_number ?? null, }), }, ], }, }, }, ); return new Response(); } function readMetafield( fields: Array<{ namespace: string; key: string; value: string }> | undefined, ns: string, key: string, ) { return fields?.find((f) => f.namespace === ns && f.key === key)?.value ?? null; } ``` Two design choices worth flagging: - **The metafield is the audit trail**, not just a UI hint. A B2B audit asks "was this customer's VAT number valid on the day of the supply?" A JSON metafield with `checked_at` and `consultation_number` answers that question without a separate database. vatverify also retains the same record on its own audit log on Pro and Business plans. - **`taxExempt` toggles silently**. The customer does not see a "tax-exempt" indicator anywhere unless the theme renders one. Consider adding a small badge on the customer-account profile screen so the customer knows their VAT was accepted. ## Re-validation and the deregistration problem A VAT number that was valid on the day of registration may be invalid six months later if the buyer deregistered (revenue dropped, business closed, restructured). Shopify will keep `taxExempt = true` until you flip it back. This is the silent failure mode of static B2B configuration. A scheduled job once a month is sufficient for most stores: ```ts title="app/jobs/revalidate-b2b-customers.ts" export async function revalidateAllB2bCustomers(admin: AdminClient) { const taxExemptCustomers = await admin.graphql(`#graphql query taxExemptCustomers($cursor: String) { customers(first: 100, after: $cursor, query: "tax_exempt:true") { edges { cursor node { id metafield(namespace: "vat", key: "validation") { value } } } pageInfo { hasNextPage } } } `); for (const edge of taxExemptCustomers.data.customers.edges) { const v = JSON.parse(edge.node.metafield?.value ?? 'null'); if (!v?.vat_number) continue; const fresh = await vat.validate({ vat_number: v.vat_number }); if (fresh.data?.valid !== true) { await admin.graphql(`#graphql mutation flipTaxExempt($id: ID!) { customerUpdate(input: { id: $id, taxExempt: false }) { userErrors { field message } } } `, { variables: { id: edge.node.id } }); } } } ``` Run this from a Shopify-app cron or an external scheduler hitting an authenticated app endpoint. ## Caching vatverify caches valid responses for 30 days and invalid ones for 24 hours by default. Shopify-side caching on top is unnecessary for the flow described: the customer-update webhook fires once per customer-profile change, not on every order. If you call validation from a checkout extension instead, add a per-customer in-memory cache so a single checkout does not re-validate on every keystroke. ## 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 `taxExempt = true` is correct. For a UK shop selling to an EU customer post-Brexit, the supply is a third-country export (zero-rated for the seller's UK VAT, with import VAT due in the EU destination country). The pattern is the same on the Shopify side: set `taxExempt = true`, but the legal basis is exports, not intra-Community reverse-charge. ## What this guide does not cover - **OSS and IOSS for B2C distance sales.** Different scheme, different evidence requirements. Shopify Tax handles part of this for cross-border B2C goods, but the OSS reporting itself is outside Shopify. - **Domestic-reverse-charge sectors** (construction, scrap metal, mobile phones, integrated circuits in several EU countries). Same-country B2B with these supply types follows the local domestic-reverse-charge rule, which Shopify Tax does not model. - **Northern Ireland XI numbers**. If you sell goods to Northern Ireland, the customer should provide their XI prefix number; vatverify routes XI through VIES. See the [Northern Ireland page](/check/northern-ireland) for the dual-number context. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [WooCommerce VAT validation](/guides/woocommerce-vat-validation) - [Stripe Tax + vatverify](/guides/stripe-vat-validation) - [EU VAT reverse charge API](/guides/eu-vat-reverse-charge-api) Source: https://www.vatverify.dev/guides/shopify-vat-validation --- ## Stripe VAT validation: validate customer VAT before charging Add EU VAT validation to a Stripe checkout flow. Validate before creating the Customer, set tax_id_data, and handle reverse-charge for B2B correctly. ## TL;DR - Validate the buyer's VAT number with vatverify before creating any Stripe customer. - Pass the validated VAT number as `tax_id_data` on `stripe.customers.create`. - Call `/v1/decide` to get the correct mechanism (`reverse_charge` or `standard`) and wire the result into your Checkout Session. `@vatverify/node` is packaged and ships on npm at the API launch. Reading this before launch? The REST endpoints are live today. The same pattern works with a plain `fetch` call using `Authorization: Bearer `. The Stripe integration logic stays identical. ## Why Stripe Tax isn't always enough Stripe Tax handles the easy case well: you enable it, attach an address, and Stripe calculates local rates for B2C sales. For many businesses that's fine. But B2B EU transactions have a different problem (the reverse-charge mechanism) and Stripe Tax does not make that decision for you. You still have to know whether your buyer holds a valid VAT registration, and whether that registration is in a different EU member state from you. Stripe can check that a VAT number *looks* correct (format), but it doesn't verify live registration against VIES. For B2B at scale, you also need to think about the 0.5% per-transaction fee that Stripe Tax adds. At €20k/month of EU B2B revenue that's €100/month extra just to do tax calculation that vatverify + `/v1/decide` handles for a fraction of that cost, with verified registration data. ## What Stripe does for you Stripe Tax automatically calculates sales tax, VAT, and GST based on the customer's billing address and your nexus settings. For B2C sales (consumers buying subscriptions or one-off products) this is genuinely great: you configure your product tax codes once, enable `automatic_tax`, and Stripe handles rate lookup and filing reminders. When a customer provides a VAT ID in Checkout, Stripe applies zero-rating automatically if the VAT number *passes format checks*. This is the key limitation for B2B work. ## What Stripe doesn't do well Stripe validates VAT number format only. It does not check: - Whether the number is currently registered in VIES - Whether the company behind it matches what your customer told you - Which specific mechanism applies to your seller country + buyer country pair For non-EU sellers (US, UK, AU) selling into the EU, Stripe Tax has further gaps. You may need to register under OSS or in individual member states, and Stripe's guidance on that is limited. For B2B reverse-charge, Stripe will zero-rate a customer with a VAT ID, but it relies on you to have confirmed that the ID is real. If the ID is invalid or deregistered and you zero-rate the sale, you are liable for the uncollected VAT. ## The integration pattern Three steps: validate, create customer, apply on checkout. ## Step 1: Validate before creating Customer ```typescript title="lib/b2b-customer.ts" import { Vatverify } from '@vatverify/node'; import Stripe from 'stripe'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); async function createB2BCustomer(email: string, vatNumber: string) { const result = await vat.validate({ vat_number: vatNumber }); if (!result.data.valid) { throw new Error(`Invalid VAT: ${result.data.vat_number} is not registered`); } const company = result.data.company; return stripe.customers.create({ email, name: company?.name ?? result.data.vat_number, address: { country: result.data.country.code, line1: company?.address ?? undefined }, tax_id_data: [{ type: 'eu_vat', value: vatNumber }], }); } ``` `result.data.company?.name` and `result.data.company?.address` come from VIES. They are the registered trading name and address, a stronger signal than whatever the customer typed into your form. `company` is nullable; some registries (notably Germany) do not publish company details. ## Step 2: Use `/v1/decide` to get the mechanism Once you have a valid VAT number, call `/v1/decide` to get the explicit tax decision for this seller + buyer combination: ```typescript title="lib/tax-decision.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function getTaxDecision({ sellerVat, buyerVat, }: { sellerVat: string; buyerVat: string; }) { const decision = await vat.decide({ seller_vat: sellerVat, buyer_vat: buyerVat, }); // decision.data.mechanism: 'standard' | 'reverse_charge' | 'zero_rated' | 'out_of_scope' // (EU B2B checkout typically sees 'standard' for same-country sales, // 'reverse_charge' for EU cross-border sales with a valid buyer VAT) // decision.data.rate: 0 for reverse_charge / zero_rated, local rate for standard // decision.data.invoice_note: ready-to-print legal string // decision.data.legal_basis: e.g. 'EU VAT Directive Article 196' // decision.data.charge_vat: boolean shorthand return decision; } ``` The response for a German seller to a French buyer looks like: ```json { "data": { "mechanism": "reverse_charge", "rate": 0, "charge_vat": false, "invoice_note": "Reverse charge — VAT to be accounted for by the recipient", "buyer_vat": { "valid": true, "country": { "code": "FR", "name": "France" } } } } ``` For a same-country B2B transaction (German seller, German buyer), `data.mechanism` is `standard` and `data.rate` is `19`. ## Step 3: Apply on Checkout Session Wire the decision into your Stripe Checkout Session: ```typescript title="lib/checkout-session.ts" import Stripe from 'stripe'; import { Vatverify } from '@vatverify/node'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function createB2BCheckoutSession({ email, sellerVat, buyerVat, priceId, }: { email: string; sellerVat: string; buyerVat: string; priceId: string; }) { // Validate buyer VAT const vatResult = await vat.validate({ vat_number: buyerVat }); if (!vatResult.data.valid) { throw new Error('VAT validation failed'); } // Get tax mechanism const decision = await vat.decide({ seller_vat: sellerVat, buyer_vat: buyerVat, }); // Create Stripe customer with verified data const company = vatResult.data.company; const customer = await stripe.customers.create({ email, name: company?.name ?? vatResult.data.vat_number, address: { country: vatResult.data.country.code, line1: company?.address ?? undefined, }, tax_id_data: [{ type: 'eu_vat', value: buyerVat }], metadata: { vatverify_mechanism: decision.data.mechanism, vatverify_rate: String(decision.data.rate), vatverify_invoice_note: decision.data.invoice_note, }, }); // For reverse_charge: automatic_tax handles zero-rating once tax_id_data is set // For standard: automatic_tax applies the correct local rate const session = await stripe.checkout.sessions.create({ customer: customer.id, mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], automatic_tax: { enabled: true }, tax_id_collection: { enabled: true }, success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`, cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout`, }); return { session, decision }; } ``` When `automatic_tax` is enabled and the customer has a valid `eu_vat` tax ID, Stripe applies zero-rating automatically for cross-border EU B2B. The `decision.data.mechanism` is your audit trail confirming this was intentional. ## Webhook validation Re-validate VAT on the `customer.tax_id.created` webhook. This catches cases where a customer adds or updates their VAT ID after the initial purchase: ```typescript title="app/api/stripe/webhook/route.ts" import { NextRequest, NextResponse } from 'next/server'; import Stripe from 'stripe'; import { Vatverify } from '@vatverify/node'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function POST(req: NextRequest) { const body = await req.text(); const sig = req.headers.get('stripe-signature')!; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, sig, process.env.STRIPE_WEBHOOK_SECRET! ); } catch { return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } if (event.type === 'customer.tax_id.created') { const taxId = event.data.object as Stripe.TaxId; if (taxId.type === 'eu_vat' && taxId.value) { const result = await vat.validate({ vat_number: taxId.value }); if (!result.data.valid) { // Flag for manual review: do not silently accept an unregistered VAT ID await stripe.customers.update(taxId.customer as string, { metadata: { vat_review_required: 'true' }, }); } } } return NextResponse.json({ received: true }); } ``` ## Reverse-charge invoice pattern Inject `decision.data.invoice_note` into the Stripe invoice footer so the legal text appears on every PDF invoice automatically: ```typescript title="lib/invoice-footer.ts" import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function setInvoiceFooter( customerId: string, invoiceNote: string ) { // Set on the customer so every future invoice inherits it await stripe.customers.update(customerId, { invoice_settings: { footer: invoiceNote, }, metadata: { vatverify_invoice_note: invoiceNote, }, }); } ``` The `invoice_note` from `/v1/decide` arrives ready to print and is paired with `legal_basis` (`"EU VAT Directive Article 196"`) so you can put both on the invoice to satisfy Article 226. A typical `invoice_note` for a cross-border B2B sale: `"Reverse charge — VAT to be accounted for by the recipient"`. ## Failure modes **vatverify is down**: Cached responses with `meta.source_status: "degraded"` keep working during VIES outages. If vatverify itself is unreachable (network error, 5xx), two safe options: fall back to a format check (via `@vatverify/vat-rates`) and flag the transaction for manual review, or block checkout and show a friendly retry message. Never silently zero-rate without a confirmed valid VAT. You carry the liability. **VAT is invalid**: Surface the error inline before checkout reaches Stripe. If `result.data.valid` is `false`, the buyer's number is not registered with the upstream registry. If the SDK threw a `ValidationError` with code `invalid_format`, the string never even reached the registry. Show the customer the normalised `vat_number` and ask them to re-enter it. **`/v1/decide` returns `buyer_vat_not_registered`**: Same treatment as invalid VAT. Stop checkout and prompt the customer to check their VAT number. A well-formatted VAT number that fails VIES means the registration has lapsed or the format is valid but the number was never issued. ## FAQ ### Can I do this entirely client-side? No. Use a server action (Next.js App Router) or a route handler. Your `VATVERIFY_API_KEY` must never reach the browser. A client-side validation call would expose your API key in network requests. The `validateVat` server action pattern in the [Node.js guide](/guides/validate-vat-number-nodejs) shows the correct approach. ### Stripe Tax is cheaper for small volumes. When does vatverify + `/decide` win? Stripe Tax charges 0.5% per transaction. At €20,000/month in EU B2B revenue that's €100/month. vatverify's paid plans start well below that, and you get confirmed live registration (not format-only). The crossover depends on your transaction volume and average order value, but EU-heavy B2B SaaS at scale almost always saves money with explicit VAT validation. The bigger advantage is accuracy: format-only validation can zero-rate a sale you should have taxed, leaving you liable. ### What about Stripe's built-in VAT ID validation? Stripe validates format only. It checks that the number matches the expected pattern for the country prefix. vatverify validates format, checksum (MOD-11, MOD-97, and country-specific algorithms), and live registration against VIES. A number can pass Stripe's format check and still be unregistered or belong to a deregistered company. ### Does `/v1/decide` handle OSS / IOSS? Not in v1. The endpoint is B2B only. OSS (One Stop Shop) and IOSS for B2C distance selling are tracked for v2. The request requires both `seller_vat` and `buyer_vat`; if no buyer VAT is available, the API returns a `b2c_not_supported` error. This is intentional, not a bug. ### Can I use Stripe Tax AND vatverify together? Yes, many customers run both. The typical pattern: use vatverify for VAT registration validity and `/v1/decide` for the reverse-charge decision; use Stripe Tax for rate calculation and filing reminders. Stripe Tax is good at the mechanical part of tax calculation. vatverify is good at telling you whether the buyer is actually VAT-registered and what mechanism applies. ### How do I handle downtime? vatverify returns cached responses automatically during VIES outages. `result.meta.source_status` will be `"degraded"` but the response is still usable. If vatverify itself is down, the safest path is to block checkout and show a retry message rather than silently accepting unvalidated VAT numbers. See [handle-vies-downtime](/guides/handle-vies-downtime) for detailed retry and fallback patterns. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Next.js](/guides/validate-vat-number-nextjs) - [Mollie VAT validation](/guides/mollie-vat-validation) - [EU VAT reverse-charge API](/guides/eu-vat-reverse-charge-api) - [Open-source VAT libraries](/libraries) - [Webhooks](/features/webhooks) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/stripe-vat-validation --- ## Swiss UID validation: understanding the CHE-xxxxxxxxx format How Switzerland's UID register works, CHE format, MWST/IVA/TVA suffixes, BFS UID register SOAP eCH-0097 schema, and Liechtenstein's dual coverage. ## TL;DR - Swiss UID is Switzerland's unified business identifier (think of it as a company registration number that optionally carries VAT status). - The format is `CHE` + 9 digits, commonly displayed with hyphens and dots: `CHE-123.456.789`. - The Federal Statistical Office (BFS) runs the registry via a SOAP endpoint using the eCH-0097 standard. - Liechtenstein uses the same CHE format and validates through the same endpoint. - vatverify normalizes all input variations and handles the SOAP layer for you. `@vatverify/node` (used in the Node example below) ships on npm at the API launch. Reading this before launch? The REST endpoint is live today. Swap any `vat.validate({ vat_number })` call for `fetch('https://api.vatverify.dev/v1/validate?vat_number=...')` with an `Authorization: Bearer` header. The Python and cURL snippets on this page work today without any package install. ## Swiss UID vs VAT: the distinction UID (Unternehmens-Identifikationsnummer) is the company's universal identifier, assigned at incorporation and used across all Swiss federal agencies. VAT registration (`MWST`/`TVA`/`IVA`) is a separate status that may or may not be active on that UID. A company can have a UID without being VAT-registered (below the CHF 100,000 turnover threshold, or operating in an exempt sector). When you validate a Swiss "VAT number," you are really asking: does this UID exist, and is the VAT flag active on it? ## The CHE-xxxxxxxxx format The canonical display format is `CHE-123.456.789`: the three-letter prefix followed by three groups of three digits separated by dots, with a hyphen between the prefix and the first group. In practice you will encounter all of these in the wild: - `CHE-123.456.789` - `CHE123456789` - `CHE 123 456 789` - `CHE-123.456.789 MWST` vatverify normalizes all of them silently before sending the upstream request. The 9-digit body uses a MOD-11 check digit as the final digit. ## The MWST / TVA / IVA suffixes Switzerland has three official languages, and the VAT suffix varies by language: | Language | Suffix | Meaning | |----------|--------|---------| | German | MWST | Mehrwertsteuer | | French | TVA | Taxe sur la valeur ajoutée | | Italian | IVA | Imposta sul valore aggiunto | All three refer to exactly the same VAT registration status. Which suffix appears in practice depends on the canton the company is registered in (and sometimes just on which language the person entering the number was using). vatverify strips all three variants before lookup. ## BFS UID register The UID register is operated by the **Federal Statistical Office** (Bundesamt für Statistik, abbreviated BFS). The BFS provides a public SOAP web service for querying UID records and checking VAT registration status. There is no official public REST API. It is SOAP only, similar in spirit to VIES. The BFS maintains a separate query interface for public lookups at [uid.admin.ch](https://uid.admin.ch), but programmatic access goes through the SOAP endpoint. ## SOAP eCH-0097 schema eCH-0097 is the Swiss e-government standard that defines the data schema for UID-related records. It specifies the XML element names, types, and validation rules for UID lookups. The standard is maintained by the eCH association (E-Government Standards Switzerland) and is publicly available at [ech.ch](https://www.ech.ch). A minimal UID lookup request against the BFS endpoint references the `uid:getUID` operation and wraps the `CHE` prefix and numeric body in the schema's typed elements. vatverify handles schema construction and response parsing internally. ## Liechtenstein (LI) dual coverage Liechtenstein is not Switzerland, but its VAT system is tightly integrated with the Swiss one under a customs and currency union. Liechtenstein VAT numbers use `LI` as the country prefix in an international context, but the underlying business identifier is a CHE-format UID validated through the same BFS endpoint. When you pass `LI` as the country prefix to vatverify, it routes to the BFS endpoint automatically. You do not need separate handling. ## Input normalization in vatverify vatverify strips the following before upstream lookup: - Hyphens (`-`) - Dots (`.`) - Spaces - MWST / TVA / IVA suffix (case-insensitive) - Leading/trailing whitespace So `CHE-115.932.342 MWST`, `CHE115932342`, and `che 115 932 342 tva` all resolve to the same lookup. ## Sample request/response ```typescript title="validate-swiss.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const result = await vat.validate({ vat_number: 'CHE-115.932.342 MWST' }); // result.data.valid → true // result.data.company?.name → "Example AG" // result.data.country.code → "CH" ``` ```python title="validate_swiss.py" import os import httpx response = httpx.get( "https://api.vatverify.dev/v1/validate", params={"vat_number": "CHE-115.932.342 MWST"}, headers={"Authorization": f"Bearer {os.environ['VATVERIFY_API_KEY']}"}, ) result = response.json() # result["data"]["valid"] → True # result["data"]["company"]["name"] → "Example AG" # result["data"]["country"]["code"] → "CH" ``` ```bash curl "https://api.vatverify.dev/v1/validate?vat_number=CHE-115.932.342%20MWST" \ -H "Authorization: Bearer $VATVERIFY_API_KEY" ``` Example response: ```json { "data": { "valid": true, "vat_number": "CHE115932342", "country": { "code": "CH", "name": "Switzerland" }, "company": { "name": "Example AG", "address": "Musterstrasse 1, 8001 Zürich" }, "verified_at": "2026-04-14T15:42:03.451Z" }, "meta": { "request_id": "0190f8ea-a5b2-7000-a123-000000000000", "cached": false, "source": "bfs", "source_status": "live", "latency_ms": 312 } } ``` If the business exists but is not VAT-registered, the upstream registry returns `data.valid: false` and `data.company: null`: ```json { "data": { "valid": false, "vat_number": "CHE115932342", "country": { "code": "CH", "name": "Switzerland" }, "company": null, "verified_at": "2026-04-14T15:42:03.451Z" }, "meta": { "source": "bfs", "source_status": "live" } } ``` ## Rate limits & uptime The BFS UID register has no published rate limit. Anecdotal testing suggests ~50 requests/second sustained is safe without triggering throttling. BFS uptime is consistently ≥99.9%. Swiss government infrastructure tends to be reliable. vatverify caches responses for 30 days, so repeated lookups of the same number hit the cache and never touch the BFS endpoint. ## FAQ ### Is UID the same as VAT? No. UID is the Swiss company identifier. Every registered business has one. VAT registration (MWST/TVA/IVA) is a separate flag that is only active if the company meets the registration threshold (currently CHF 100,000 annual turnover) or has voluntarily registered. A company can have a valid UID but `data.valid: false` in vatverify if it is not VAT-registered. ### What if the input is `CHE-115.932.342 MWST`? vatverify strips the hyphen, dots, spaces, and MWST suffix before sending the lookup. All of those display variants normalize to `CHE115932342` internally. You do not need to sanitize the input before passing it to the API. ### Does Liechtenstein need separate handling? No. Pass the number with the `LI` prefix (`LI00012345`) and vatverify routes it to the same BFS endpoint used for Swiss UIDs. Liechtenstein does not have a separate registry endpoint. The response includes `"country": "LI"` to confirm the routing. ### What if the business exists but isn't VAT-registered? The response has `data.valid: false`. The company name and address are not populated on a `valid: false` response; if you need the business-registered-but-not-VAT-registered signal, pair this with a separate UID lookup against BFS. ### Does UID work for sole proprietors? Yes. Individual sole proprietors (Einzelunternehmen) in Switzerland receive a CHE UID, the same as legal entities. Whether they are VAT-registered depends on whether they have crossed the registration threshold. The same validation endpoint and response format applies. If they are below threshold, `data.valid` is `false`. ## Related guides - [VIES API guide](/guides/vies-api-guide) - [HMRC VAT check API](/guides/hmrc-vat-check-api) - [Norwegian VAT validation: brreg and MVA](/guides/norwegian-vat-brreg) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Open-source VAT libraries](/libraries) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/swiss-uid-validation-guide --- ## Validate VAT numbers in C# / .NET: HttpClient, IMemoryCache, and Polly retry Validate EU and UK VAT numbers from a .NET service. IHttpClientFactory wiring, IMemoryCache or Redis cache, Polly retry policies, and the patterns that scale to ASP.NET checkout flows. ## TL;DR - Use `IHttpClientFactory` and a typed client to call vatverify; never `new HttpClient()` directly. - `IMemoryCache` or Redis (via `IDistributedCache`) handles the cache layer on top of vatverify's 30-day server-side cache. - Polly handles retry and circuit-breaker policies; configure it through the HTTP client factory. ## Typed client with `IHttpClientFactory` In .NET 8 and later, `IHttpClientFactory` is the right way to manage HttpClient lifetimes. Direct `new HttpClient()` leaks DNS state and exhausts sockets under load. ```csharp title="VatverifyClient.cs" public sealed class VatverifyClient(HttpClient http) { public async Task ValidateAsync( string vatNumber, CancellationToken ct = default) { var response = await http.PostAsJsonAsync( "/v1/validate", new { vat_number = vatNumber }, ct); if (!response.IsSuccessStatusCode) { var error = await response.Content.ReadFromJsonAsync(ct); throw new VatverifyException( $"vatverify {(int)response.StatusCode}: {error?.Error.Code ?? "unknown"}"); } var envelope = await response.Content.ReadFromJsonAsync>(ct); var data = envelope?.Data ?? throw new VatverifyException("vatverify returned empty data envelope"); return new ValidationResult( Valid: data.Valid, Country: data.Country, CompanyName: data.Company?.Name, ConsultationNumber: data.ConsultationNumber); } private sealed record DataEnvelope(T Data); private sealed record ValidationPayload( bool Valid, string? Country, CompanyPayload? Company, string? ConsultationNumber); private sealed record CompanyPayload(string? Name); private sealed record ErrorEnvelope(ErrorPayload Error); private sealed record ErrorPayload(string Code, string Message); } public sealed record ValidationResult( bool Valid, string? Country, string? CompanyName, string? ConsultationNumber); public sealed class VatverifyException(string message) : Exception(message); ``` The class uses C# 12 primary constructors and records. Drop in older syntax for .NET 6/7 if your service hasn't moved yet. ## Wiring the HTTP client Register the typed client in `Program.cs`: ```csharp title="Program.cs" builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://api.vatverify.dev"); client.Timeout = TimeSpan.FromSeconds(10); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", builder.Configuration["Vatverify:ApiKey"]); client.DefaultRequestHeaders.Accept.Add( new MediaTypeWithQualityHeaderValue("application/json")); }); ``` The factory recycles `HttpClient` instances every 2 minutes by default, which protects against stale DNS resolution. Inject `VatverifyClient` directly into controllers or services; never inject `HttpClient`. ## Caching with `IMemoryCache` For single-instance services, `IMemoryCache` is the simplest cache. Wrap the typed client: ```csharp title="CachedVatverifyClient.cs" public sealed class CachedVatverifyClient(VatverifyClient inner, IMemoryCache cache) { public Task ValidateAsync(string vatNumber, CancellationToken ct) { var key = $"vatverify:{vatNumber}"; return cache.GetOrCreateAsync(key, async entry => { var result = await inner.ValidateAsync(vatNumber, ct); entry.AbsoluteExpirationRelativeToNow = result.Valid ? TimeSpan.FromHours(24) : TimeSpan.FromHours(1); return result; })!; } } ``` For multi-instance services, swap to `IDistributedCache` backed by Redis using `Microsoft.Extensions.Caching.StackExchangeRedis`. The TTL pattern stays the same. ## Retry policies with Polly Add `Microsoft.Extensions.Http.Polly` for HTTP-client-level retries: ```csharp builder.Services.AddHttpClient(/* config as above */) .AddTransientHttpErrorPolicy(builder => builder.WaitAndRetryAsync( retryCount: 2, sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)) + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000)))) .AddTransientHttpErrorPolicy(builder => builder.CircuitBreakerAsync( handledEventsAllowedBeforeBreaking: 5, durationOfBreak: TimeSpan.FromSeconds(30))); ``` `AddTransientHttpErrorPolicy` triggers on HTTP 5xx and on `HttpRequestException`, which is exactly the set we want to retry. The circuit breaker opens after five consecutive failures, preventing your service from hammering vatverify (and its upstream registries) during an outage. For Polly v8 with the new ResiliencePipeline API, the configuration shape is similar but uses the builder pattern under `AddResilienceHandler`. ## ASP.NET Core checkout integration In an ASP.NET Core controller or Minimal API endpoint: ```csharp app.MapPost("/api/customers/{id:int}/vat", async ( int id, UpdateVatRequest request, CachedVatverifyClient vatverify, AppDbContext db, CancellationToken ct) => { var customer = await db.Customers.FindAsync(id, ct); if (customer is null) return Results.NotFound(); var result = await vatverify.ValidateAsync(request.VatNumber, ct); if (!result.Valid) { return Results.BadRequest(new { error = "invalid_vat" }); } var sellerCountry = builder.Configuration["Shop:HomeCountry"]; customer.VatNumber = request.VatNumber; customer.TaxExempt = result.Country is not null && result.Country != sellerCountry; customer.VatPayload = JsonSerializer.Serialize(result); customer.VatCheckedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); return Results.Ok(customer); }); ``` Persisting the full `VatPayload` plus `VatCheckedAt` gives you a reconstructible audit trail without a separate logging system. ## Reverse-charge decision For the full B2B decision, call `/v1/decide`: ```csharp public sealed record Decision( bool ChargeVat, string Mechanism, string LegalBasis, string InvoiceNote); public async Task DecideAsync( string sellerVat, string buyerVat, CancellationToken ct) { var response = await http.PostAsJsonAsync( "/v1/decide", new { seller_vat = sellerVat, buyer_vat = buyerVat }, ct); response.EnsureSuccessStatusCode(); var envelope = await response.Content.ReadFromJsonAsync>(ct); return envelope!.Data; } ``` `/v1/decide` is on the Business plan. ## Production patterns - **Use `CancellationToken` everywhere.** Pass the request's `CancellationToken` from the controller through the cache wrapper into the typed client. ASP.NET Core cancels the token when the client disconnects; without it, a slow vatverify response keeps the worker thread busy after the user has gone. - **Configure the timeout once on the typed client.** Don't sprinkle `Timeout` configuration in multiple places. - **Surface `meta.request_id` via `ILogger` scopes.** Add it to the scope on every call so it appears on every log line emitted under the validation flow. - **Don't serialize the full payload at Information level.** The validation response can include the buyer's company name and address. Treat it as PII; log it only at Debug or behind a feature flag. ## What this guide does not cover - **gRPC.** vatverify exposes REST only; if your service is gRPC-internal, a small REST adapter is the right wrapping layer. - **Generated SDKs.** vatverify does not currently ship a generated .NET SDK. The OpenAPI spec at `/docs/api` is suitable for NSwag or Kiota if you prefer generated types. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in Java / Spring Boot](/guides/validate-vat-number-java) - [Validate VAT in Go](/guides/validate-vat-number-go) - [VIES API guide](/guides/vies-api-guide) Source: https://www.vatverify.dev/guides/validate-vat-number-csharp-dotnet --- ## Validate VAT numbers in Django Add VAT validation to Django forms, admin, and DRF serializers by calling the vatverify REST API with httpx. Covers sync and async views. ## TL;DR - There is no official Python SDK yet. Call the REST API via `httpx` (or `requests`) from a small helper module. - Drop the helper into `clean_()`, DRF serializer validators, and admin actions. - Use `vatverify-rates` for offline format + checksum checks when you want to reject garbage before spending an API call. `vatverify-rates` ships on PyPI alongside the API launch (v0.1.0 packaged, 160 tests green). Reading this before launch? Every code sample below uses `httpx` against the REST API, which is live today. You can ship the helper, form, admin action, and DRF serializer without any vatverify package installed. The JavaScript sibling `@vatverify/vat-rates` is already on npm if you need offline checksum validation from another part of your stack today. ## Why framework-aware validation matters Dropping a raw HTTP call anywhere in your codebase works for a prototype. In a Django project, it creates problems fast: the same logic gets duplicated across forms, API endpoints, and admin actions; errors surface in the wrong place; there is no single hook to change behaviour across all entry-points at once. Wiring validation into Django's form and serializer lifecycle means the check runs at exactly the right moment (before `save()`) and raises the right exception type wherever the data originated. ## Installation ```bash pip install httpx # optional offline helper for format + checksum pip install vatverify-rates ``` ## Settings ```python title="settings.py" import os VATVERIFY_API_KEY = os.environ.get("VATVERIFY_API_KEY", "") VATVERIFY_BASE_URL = os.environ.get("VATVERIFY_BASE_URL", "https://api.vatverify.dev") ``` ## The shared client Put the HTTP client and a small helper in one place. Every form, serializer, admin action, and view imports from here. ```python title="myapp/vatverify_client.py" from __future__ import annotations import httpx from django.conf import settings class VatverifyError(Exception): """Raised when the API call fails for a transport or server reason.""" def __init__(self, message: str, status_code: int | None = None) -> None: super().__init__(message) self.status_code = status_code _client = httpx.Client( base_url=settings.VATVERIFY_BASE_URL, timeout=30.0, ) def validate(vat_number: str) -> dict: """Call GET /v1/validate and return the full {data, meta} envelope.""" response = _client.get( "/v1/validate", params={"vat_number": vat_number}, headers={"Authorization": f"Bearer {settings.VATVERIFY_API_KEY}"}, ) if response.status_code >= 400: body = response.json() if response.content else {} message = body.get("error", {}).get("message", f"HTTP {response.status_code}") raise VatverifyError(message, status_code=response.status_code) return response.json() ``` ## Form validation with ModelForm ```python title="myapp/forms.py" from django import forms from .models import Company from .vatverify_client import validate, VatverifyError class CompanyForm(forms.ModelForm): class Meta: model = Company fields = ["name", "vat_number", "country"] def clean_vat_number(self) -> str: value: str = self.cleaned_data.get("vat_number", "").strip() if not value: return value try: result = validate(value) except VatverifyError as exc: raise forms.ValidationError(f"VAT lookup failed: {exc}") from exc data = result["data"] if not data["valid"]: raise forms.ValidationError(f"{data['vat_number']} is not registered.") return value ``` The `VatverifyError` catch guards against transient network failures. The user sees a clear message instead of a 500. ## Admin integration Add a custom action to validate VAT numbers directly from the Django admin changelist. ```python title="myapp/admin.py" from django.contrib import admin, messages from django.http import HttpRequest from .models import Company from .vatverify_client import validate, VatverifyError @admin.action(description="Validate VAT number now") def validate_vat_number( modeladmin: admin.ModelAdmin, request: HttpRequest, queryset, ) -> None: for company in queryset.exclude(vat_number=""): try: result = validate(company.vat_number) except VatverifyError as exc: modeladmin.message_user( request, f"{company.name}: lookup failed: {exc}", level=messages.ERROR, ) continue data = result["data"] if data["valid"]: modeladmin.message_user( request, f"{company.name}: VAT {data['vat_number']} is valid.", level=messages.SUCCESS, ) else: modeladmin.message_user( request, f"{company.name}: {data['vat_number']} is not registered.", level=messages.WARNING, ) @admin.register(Company) class CompanyAdmin(admin.ModelAdmin): list_display = ["name", "vat_number", "country"] actions = [validate_vat_number] ``` ## DRF serializer Validation in a `ModelSerializer` follows the same `validate_` convention. ```python title="myapp/serializers.py" from rest_framework import serializers from .models import Company from .vatverify_client import validate, VatverifyError class CompanySerializer(serializers.ModelSerializer): class Meta: model = Company fields = ["id", "name", "vat_number", "country"] def validate_vat_number(self, value: str) -> str: if not value: return value try: result = validate(value) except VatverifyError as exc: raise serializers.ValidationError(f"VAT lookup failed: {exc}") from exc data = result["data"] if not data["valid"]: raise serializers.ValidationError(f"{data['vat_number']} is not registered.") return value ``` ## Async views (Django 5.0+) Django 5.0 ships native async view support. Use `httpx.AsyncClient` to avoid blocking the event loop. ```python title="myapp/views.py" import httpx from django.conf import settings from django.http import JsonResponse _async_client = httpx.AsyncClient( base_url=settings.VATVERIFY_BASE_URL, timeout=30.0, ) async def validate_vat_view(request): vat_number = request.GET.get("vat_number", "").strip() if not vat_number: return JsonResponse({"error": "vat_number is required"}, status=400) response = await _async_client.get( "/v1/validate", params={"vat_number": vat_number}, headers={"Authorization": f"Bearer {settings.VATVERIFY_API_KEY}"}, ) if response.status_code >= 400: return JsonResponse({"error": f"HTTP {response.status_code}"}, status=503) data = response.json()["data"] return JsonResponse( { "valid": data["valid"], "vat_number": data["vat_number"], "country": data["country"], "company": data.get("company"), } ) ``` ## Offline filter (optional) Install `vatverify-rates` to reject malformed numbers before spending an API call: ```bash pip install vatverify-rates ``` ```python title="myapp/vatverify_client.py (additions)" from vatverify_rates import validate as offline_validate def validate_hybrid(vat_number: str) -> dict: offline = offline_validate(vat_number) if not offline.valid: # Skip the network; offline check already rejected this number. return { "data": {"valid": False, "vat_number": vat_number, "company": None}, "meta": {"source_status": "offline"}, "offline_errors": list(offline.errors), } return validate(vat_number) ``` ## Testing with test-mode Use `@override_settings` to inject a test API key and the [magic VAT numbers](/docs/test-mode) that return deterministic responses. ```python title="myapp/tests/test_forms.py" from django.test import TestCase, override_settings from myapp.forms import CompanyForm @override_settings(VATVERIFY_API_KEY="vtv_test_xxx") class CompanyFormValidationTests(TestCase): def test_valid_vat_number_passes(self): form = CompanyForm( data={"name": "Acme Ltd", "vat_number": "IE6388047V", "country": "IE"} ) self.assertTrue(form.is_valid()) def test_invalid_vat_number_raises_error(self): form = CompanyForm( data={"name": "Acme Ltd", "vat_number": "NOTAVAT", "country": "IE"} ) self.assertFalse(form.is_valid()) self.assertIn("vat_number", form.errors) def test_empty_vat_number_skips_validation(self): form = CompanyForm( data={"name": "Acme Ltd", "vat_number": "", "country": "IE"} ) self.assertNotIn("vat_number", form.errors) ``` The `vtv_test_xxx` key never hits the live API, so tests run offline and are free. ## FAQ ### Does this work with Django 3 or Django 4? Yes. The sync helper works on Django 3.2 LTS and 4.x. The async view pattern requires Django 5.0+; on Django 4.2, stick to sync views or wrap the sync helper with `asgiref.sync.sync_to_async`. ### Is there an official Python SDK? Not yet. A first-class `vatverify` Python SDK is on the roadmap. Until it ships, the `httpx` helper shown in this guide is the supported integration path. ### What happens when VIES is down? The API returns the most recently cached result with `meta.source_status: "degraded"`. Your integration keeps working. See [Handle VIES downtime](/guides/handle-vies-downtime) for how to surface the degraded status to end-users. ### Can I use this inside a Celery task? Yes. `httpx.Client` is thread-safe enough for task workers when instantiated at module level. For async Celery tasks, use `httpx.AsyncClient` inside an async task body. ### How do I migrate from django-stdnum? `django-stdnum` performs format and checksum validation only. To migrate: start by running `vatverify-rates` (the offline library) as a drop-in replacement for format checks, then wire in the live API via the helper above to add real registry lookups. The `clean_vat_number` pattern shown above is a direct replacement for a `stdnum`-based validator. ## Related guides - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in Next.js](/guides/validate-vat-number-nextjs) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) - [EU reverse-charge API](/guides/eu-vat-reverse-charge-api) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/validate-vat-number-django --- ## Validate VAT numbers in Elixir and Phoenix: Req, Cachex, and Oban for re-validation Validate EU and UK VAT numbers from an Elixir or Phoenix application. Uses Req for HTTP, Cachex for in-process caching, and Oban for monthly customer-list re-validation jobs. ## TL;DR - `Req` is the modern Elixir HTTP client; one function call covers most vatverify integration needs. - `Cachex` handles the in-process cache; for distributed caching across nodes, swap to `Redix`. - `Oban` is the standard background-job library; one recurring job re-validates B2B customers monthly. ## Why Elixir for VAT validation Elixir / Phoenix is a niche but growing stack for B2B SaaS. The BEAM's concurrency model handles batch validation across thousands of VAT numbers without breaking a sweat, and Phoenix LiveView makes interactive validation forms (with live error feedback) trivial to build. The patterns below assume Elixir 1.16+ and Phoenix 1.7+. ## A small client module ```elixir title="lib/my_app/vatverify.ex" defmodule MyApp.Vatverify do @api_base "https://api.vatverify.dev" @type result :: %{ valid: boolean(), country: String.t() | nil, company_name: String.t() | nil, consultation_number: String.t() | nil } @spec validate(String.t()) :: {:ok, result()} | {:error, term()} def validate(vat_number) when is_binary(vat_number) do case Req.post( url: "#{@api_base}/v1/validate", headers: [ {"authorization", "Bearer #{api_key()}"}, {"accept", "application/json"} ], json: %{vat_number: vat_number}, connect_options: [timeout: 3_000], receive_timeout: 10_000 ) do {:ok, %Req.Response{status: 200, body: %{"data" => data}}} -> {:ok, normalize(data)} {:ok, %Req.Response{status: status, body: %{"error" => err}}} -> {:error, {:vatverify, status, err["code"]}} {:error, reason} -> {:error, {:transport, reason}} end end defp normalize(data) do %{ valid: data["valid"] == true, country: data["country"], company_name: data |> Map.get("company") |> get_in(["name"]), consultation_number: data["consultation_number"] } end defp api_key do Application.fetch_env!(:my_app, MyApp.Vatverify)[:api_key] end end ``` `Req` is the recommended HTTP client for new Elixir code; it ships with retries, JSON encoding, and good defaults. The pattern above uses `Req.post/1` directly; for higher reuse, build a `Req.new/1` request struct in `start/0` and reuse it. ## Configuration ```elixir title="config/runtime.exs" config :my_app, MyApp.Vatverify, api_key: System.fetch_env!("VATVERIFY_API_KEY") ``` Reading the API key at runtime (`runtime.exs`) is the right place; it lets you switch between live and test keys per environment without recompiling. ## Caching with Cachex For in-process caching, Cachex is the standard choice. Add to mix: ```elixir {:cachex, "~> 3.6"} ``` Start a cache instance in your application supervisor: ```elixir title="lib/my_app/application.ex" def start(_type, _args) do children = [ {Cachex, name: :vatverify_cache} # ... other children ] Supervisor.start_link(children, strategy: :one_for_one) end ``` Wrap the validate function: ```elixir title="lib/my_app/vatverify_cached.ex" defmodule MyApp.VatverifyCached do alias MyApp.Vatverify def validate(vat_number) do Cachex.fetch(:vatverify_cache, vat_number, fn _key -> case Vatverify.validate(vat_number) do {:ok, result} -> ttl = if result.valid, do: :timer.hours(24), else: :timer.hours(1) {:commit, result, ttl: ttl} {:error, _} = err -> {:ignore, err} end end) |> case do {:ok, result} -> {:ok, result} {:commit, result} -> {:ok, result} {:ignore, err} -> err end end end ``` `Cachex.fetch/3` with the `:commit` / `:ignore` return tuple gives you per-call TTLs without splitting into separate caches. For multi-node deployments, swap Cachex for `Redix` and store the same shape of payload. ## Phoenix controller integration ```elixir title="lib/my_app_web/controllers/customer_controller.ex" defmodule MyAppWeb.CustomerController do use MyAppWeb, :controller alias MyApp.{Customer, VatverifyCached} def update_vat(conn, %{"id" => id, "vat_number" => vat_number}) do customer = MyApp.Customers.get!(id) with {:ok, result} <- VatverifyCached.validate(vat_number), true <- result.valid do cross_border_b2b? = result.country && result.country != Application.get_env(:my_app, :seller_country) {:ok, customer} = Customer.changeset(customer, %{ vat_number: vat_number, tax_exempt: cross_border_b2b?, vat_payload: Jason.encode!(result), vat_checked_at: DateTime.utc_now() }) |> MyApp.Repo.update() conn |> json(customer) else false -> conn |> put_status(:unprocessable_entity) |> json(%{error: "invalid_vat"}) {:error, reason} -> conn |> put_status(:bad_gateway) |> json(%{error: "vatverify_failure", reason: inspect(reason)}) end end end ``` The `with` pipeline keeps the happy path linear and surfaces the two distinct failure modes (invalid VAT vs upstream service failure) as separate else-branches. ## Oban worker for re-validation For monthly re-validation of B2B customers, Oban is the standard background-job library. Add to mix: ```elixir {:oban, "~> 2.17"} ``` Schedule via Oban Cron: ```elixir title="config/config.exs" config :my_app, Oban, repo: MyApp.Repo, queues: [default: 10, vatverify: 4], plugins: [ {Oban.Plugins.Cron, crontab: [ {"0 3 1 * *", MyApp.Workers.RevalidateAllB2bCustomers} ]} ] ``` The fan-out worker: ```elixir title="lib/my_app/workers/revalidate_all_b2b_customers.ex" defmodule MyApp.Workers.RevalidateAllB2bCustomers do use Oban.Worker, queue: :vatverify alias MyApp.{Customer, Repo} @impl true def perform(_job) do Customer |> where([c], c.tax_exempt == true) |> Repo.all() |> Enum.each(fn c -> MyApp.Workers.RevalidateB2bCustomer.new(%{customer_id: c.id}) |> Oban.insert() end) :ok end end ``` The per-customer worker: ```elixir title="lib/my_app/workers/revalidate_b2b_customer.ex" defmodule MyApp.Workers.RevalidateB2bCustomer do use Oban.Worker, queue: :vatverify, max_attempts: 3 alias MyApp.{Customer, Repo, Vatverify} @impl true def perform(%Oban.Job{args: %{"customer_id" => id}}) do customer = Repo.get!(Customer, id) case Vatverify.validate(customer.vat_number) do {:ok, %{valid: false}} -> Customer.changeset(customer, %{ tax_exempt: false, vat_deregistered_at: DateTime.utc_now() }) |> Repo.update() :ok {:ok, _result} -> :ok {:error, reason} -> {:error, reason} end end end ``` Returning `{:error, reason}` triggers Oban's retry-with-backoff. The default schedule is reasonable; customize via `backoff/1` if needed. ## Production patterns - **Pin connection timeouts.** The `connect_options: [timeout: 3_000]` plus `receive_timeout: 10_000` in the Req call covers both phases. Without explicit timeouts a slow VIES round-trip can hold a Phoenix worker process indefinitely. - **Use Telemetry for observability.** Req emits Telemetry events on every request; attach a handler that logs `meta.request_id` from the response body alongside your own request ID for support escalation. - **Don't log full payloads in production.** The validation response can include the buyer's company name and address. Treat it as PII; log only at debug level or behind a feature flag. ## What this guide does not cover - **LiveView form integration.** A `Phoenix.LiveView` form can validate VAT numbers on the fly with `phx-change`; that's a UX pattern on top of the cached client above and not vatverify-specific. - **Distributed caching.** For multi-node clusters, `:pg` or `Redix` work as drop-in replacements for the Cachex layer. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in PHP](/guides/validate-vat-number-php) - [Validate VAT in Go](/guides/validate-vat-number-go) - [VIES API guide](/guides/vies-api-guide) Source: https://www.vatverify.dev/guides/validate-vat-number-elixir-phoenix --- ## Validate VAT numbers in Go: net/http, context cancellation, and structured concurrency Validate EU and UK VAT numbers from a Go service. Standard library net/http, context-aware cancellation, errgroup-based batch validation, and the patterns that hold up under concurrent traffic. ## TL;DR - Go's standard library is enough; no third-party HTTP client needed for vatverify integration. - Context-aware cancellation is what makes Go validation behave well under load. Pass `context.Context` through every call. - For batch flows, `golang.org/x/sync/errgroup` plus a bounded semaphore is the standard concurrency pattern. ## Why Go for VAT validation Go is common in compliance-adjacent infrastructure: gateways, ETL workers, monitoring services, and webhook receivers. A VAT validation client written in Go fits naturally into those settings. The standard library covers everything you need; the binary size and cold-start characteristics are favorable for serverless or edge deployments where Node.js or Ruby would be heavier. ## A small client ```go title="vatverify/client.go" package vatverify import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) const apiBase = "https://api.vatverify.dev" type Client struct { apiKey string http *http.Client } func New(apiKey string) *Client { return &Client{ apiKey: apiKey, http: &http.Client{ Timeout: 10 * time.Second, }, } } type ValidationResult struct { Valid bool `json:"valid"` Country *string `json:"country,omitempty"` CompanyName *string `json:"-"` ConsultationNumber *string `json:"consultation_number,omitempty"` } type apiEnvelope struct { Data struct { Valid bool `json:"valid"` Country *string `json:"country,omitempty"` Company *struct { Name *string `json:"name,omitempty"` } `json:"company,omitempty"` ConsultationNumber *string `json:"consultation_number,omitempty"` } `json:"data"` Error *struct { Code string `json:"code"` Message string `json:"message"` } `json:"error,omitempty"` Meta struct { RequestID string `json:"request_id"` LatencyMS int `json:"latency_ms"` } `json:"meta"` } func (c *Client) Validate(ctx context.Context, vatNumber string) (*ValidationResult, error) { body, err := json.Marshal(map[string]string{"vat_number": vatNumber}) if err != nil { return nil, fmt.Errorf("marshal: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBase+"/v1/validate", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("new request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("http: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read: %w", err) } var env apiEnvelope if err := json.Unmarshal(respBody, &env); err != nil { return nil, fmt.Errorf("unmarshal: %w", err) } if resp.StatusCode >= 400 { code := "unknown" if env.Error != nil { code = env.Error.Code } return nil, fmt.Errorf("vatverify %d: %s", resp.StatusCode, code) } result := &ValidationResult{ Valid: env.Data.Valid, Country: env.Data.Country, ConsultationNumber: env.Data.ConsultationNumber, } if env.Data.Company != nil { result.CompanyName = env.Data.Company.Name } return result, nil } ``` The client is a thin wrapper around `net/http`. The `apiEnvelope` type matches the response shape one-to-one; mapping into a domain `ValidationResult` keeps caller code simple. ## Context cancellation Always pass `ctx` from the caller. In an HTTP handler, that ctx already carries the deadline and the cancellation propagated by the Go HTTP server when the client disconnects. Without it, a slow vatverify response will outlive a cancelled request and waste resources: ```go func handleCheckout(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() result, err := client.Validate(ctx, r.URL.Query().Get("vat")) if err != nil { http.Error(w, "validation failed", http.StatusBadGateway) return } json.NewEncoder(w).Encode(result) } ``` The 5-second timeout is shorter than the client's default 10 seconds; the tighter ceiling matters in checkout where waiting too long degrades the user experience. ## Concurrency for batch flows For batch validation across a list of VAT numbers, a bounded `errgroup` is the standard pattern: ```go import "golang.org/x/sync/errgroup" func ValidateMany(ctx context.Context, c *Client, numbers []string) (map[string]*ValidationResult, error) { const concurrency = 8 results := make(map[string]*ValidationResult, len(numbers)) var mu sync.Mutex g, ctx := errgroup.WithContext(ctx) sem := make(chan struct{}, concurrency) for _, n := range numbers { n := n g.Go(func() error { select { case sem <- struct{}{}: case <-ctx.Done(): return ctx.Err() } defer func() { <-sem }() result, err := c.Validate(ctx, n) if err != nil { return err } mu.Lock() results[n] = result mu.Unlock() return nil }) } if err := g.Wait(); err != nil { return results, err } return results, nil } ``` The semaphore caps in-flight requests at 8. That stays well below the per-key rate limit (90 req/min on Pro, 180 on Business) and avoids hammering the upstream registries. For lists above ~50 numbers, prefer the dedicated batch endpoint; see the [batch validation tutorial](/guides/vat-batch-validation-tutorial). ## Caching with `groupcache` or `golang-lru` For high-traffic services, an in-process LRU on top of vatverify's server-side cache is worth adding: ```go import lru "github.com/hashicorp/golang-lru/v2" type CachedClient struct { inner *Client cache *lru.Cache[string, ValidationResult] } func NewCached(inner *Client, size int) *CachedClient { cache, _ := lru.New[string, ValidationResult](size) return &CachedClient{inner: inner, cache: cache} } func (c *CachedClient) Validate(ctx context.Context, n string) (*ValidationResult, error) { if hit, ok := c.cache.Get(n); ok { return &hit, nil } result, err := c.inner.Validate(ctx, n) if err != nil { return nil, err } c.cache.Add(n, *result) return result, nil } ``` A single-process LRU is fine for small services. For multi-replica deployments, swap for Redis with `go-redis` and the same TTL pattern (24h for valid, 1h for invalid). ## Production patterns - **Reuse the `*http.Client`**. Creating a new client per request leaks connections and defeats the keep-alive pool. The `New` constructor returns a shared client; treat it as a singleton. - **Tune `MaxIdleConns` and `MaxIdleConnsPerHost`**. The default `http.Transport` keeps 2 idle connections per host. For a service with sustained vatverify traffic, raise both: ```go transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 16, IdleConnTimeout: 90 * time.Second, } c.http = &http.Client{Transport: transport, Timeout: 10 * time.Second} ``` - **Retry only on 5xx**. A 4xx response means the request is malformed; retrying does not help. A 5xx is usually a transient upstream-registry issue; one retry after 30 seconds clears most of these. - **Log `meta.request_id`**. Including it in your structured logs makes support escalation trivial. ## What this guide does not cover - **gRPC.** vatverify exposes REST only; if your service is gRPC-internal, a small REST adapter is the right wrapping layer. - **Generated SDKs.** vatverify does not currently ship a generated Go SDK. The OpenAPI spec at `/docs/api` is suitable for `oapi-codegen` if you prefer generated types. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in PHP](/guides/validate-vat-number-php) - [Batch VAT validation tutorial](/guides/vat-batch-validation-tutorial) - [VIES API guide](/guides/vies-api-guide) Source: https://www.vatverify.dev/guides/validate-vat-number-go --- ## Validate VAT numbers in Java and Spring Boot: HttpClient, RestClient, and resilient retry Validate EU and UK VAT numbers from a Spring Boot or plain Java service. Includes Java 21 HttpClient, Spring's RestClient, Resilience4j retry, and Caffeine caching patterns. ## TL;DR - Use Java 21's built-in `HttpClient` for plain Java, or Spring's `RestClient` (Spring 6.1+) for Spring Boot integration. - Caffeine handles the in-process cache; Spring Cache abstracts it cleanly behind annotations. - Resilience4j adds circuit-breaker and retry policies for the upstream-registry transient failure cases. ## Plain Java with `HttpClient` The Java 21 standard library is enough for a clean vatverify client. No third-party HTTP library required. ```java title="src/main/java/com/example/vatverify/Vatverify.java" package com.example.vatverify; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; public final class Vatverify { private static final String API_URL = "https://api.vatverify.dev/v1/validate"; private final HttpClient http; private final ObjectMapper mapper; private final String apiKey; public Vatverify(String apiKey) { this.apiKey = apiKey; this.http = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); this.mapper = new ObjectMapper(); } public ValidationResult validate(String vatNumber) throws Exception { var body = mapper.writeValueAsString(java.util.Map.of("vat_number", vatNumber)); var request = HttpRequest.newBuilder() .uri(URI.create(API_URL)) .timeout(Duration.ofSeconds(10)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .header("Accept", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); var response = http.send(request, HttpResponse.BodyHandlers.ofString()); var json = mapper.readTree(response.body()); if (response.statusCode() >= 400) { var code = json.path("error").path("code").asText("unknown"); throw new VatverifyException("vatverify " + response.statusCode() + ": " + code); } return ValidationResult.fromJson(json.path("data")); } public record ValidationResult( boolean valid, String country, String companyName, String consultationNumber ) { static ValidationResult fromJson(JsonNode data) { return new ValidationResult( data.path("valid").asBoolean(false), data.path("country").asText(null), data.path("company").path("name").asText(null), data.path("consultation_number").asText(null) ); } } public static final class VatverifyException extends RuntimeException { public VatverifyException(String message) { super(message); } } } ``` Records and `var` keep the boilerplate minimal. The class has no Spring dependency; it works in any Java 21+ project. ## Spring Boot integration with `RestClient` For Spring Boot 3.2+ with Spring 6.1+, the new `RestClient` is the modern idiom. Configuration: ```java title="VatverifyConfig.java" @Configuration public class VatverifyConfig { @Bean public RestClient vatverifyClient(@Value("${vatverify.api-key}") String apiKey) { return RestClient.builder() .baseUrl("https://api.vatverify.dev") .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Accept", "application/json") .build(); } } ``` The service: ```java title="VatverifyService.java" @Service public class VatverifyService { private final RestClient http; public VatverifyService(RestClient vatverifyClient) { this.http = vatverifyClient; } public ValidationResult validate(String vatNumber) { return http.post() .uri("/v1/validate") .contentType(MediaType.APPLICATION_JSON) .body(Map.of("vat_number", vatNumber)) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> { throw new IllegalArgumentException("vatverify rejected: " + resp.getStatusCode()); }) .body(Envelope.class) .data(); } record Envelope(ValidationResult data) {} public record ValidationResult( boolean valid, String country, Company company, String consultationNumber ) {} public record Company(String name) {} } ``` `RestClient` is synchronous; for async use cases, swap for `WebClient` from `spring-webflux`. ## Caching with Caffeine For an in-process cache, Caffeine is the standard choice in Spring Boot: ```xml com.github.ben-manes.caffeine caffeine ``` ```java title="CacheConfig.java" @Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager() { var cacheManager = new CaffeineCacheManager("vatverify"); cacheManager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(Duration.ofHours(24)) .maximumSize(50_000)); return cacheManager; } } ``` Annotate the service method: ```java @Cacheable(value = "vatverify", key = "#vatNumber") public ValidationResult validate(String vatNumber) { // ... actual call as above } ``` The cache returns the same `ValidationResult` for identical input within the TTL. For invalid responses, a 1-hour TTL makes more sense than 24 hours; a `@CacheEvict` after invalid lookups handles that without splitting the cache. ## Resilient retry with Resilience4j Upstream registry transient failures (`MS_UNAVAILABLE` from VIES, intermittent HMRC outages) are the right place for retry logic. Resilience4j has a clean Spring Boot integration: ```xml io.github.resilience4j resilience4j-spring-boot3 ``` ```yaml title="application.yml" resilience4j: retry: instances: vatverify: max-attempts: 3 wait-duration: 5s retry-exceptions: - org.springframework.web.client.HttpServerErrorException - java.net.ConnectException - java.net.SocketTimeoutException ignore-exceptions: - org.springframework.web.client.HttpClientErrorException ``` ```java @Retry(name = "vatverify") @Cacheable(value = "vatverify", key = "#vatNumber") public ValidationResult validate(String vatNumber) { /* ... */ } ``` The configuration retries on 5xx and connection-level failures, and explicitly ignores 4xx (request was malformed; retrying will not help). ## Reverse-charge decision For the full B2B reverse-charge decision, call `/v1/decide`: ```java public record Decision( boolean chargeVat, String mechanism, String legalBasis, String invoiceNote ) {} public Decision decide(String sellerVat, String buyerVat) { return http.post() .uri("/v1/decide") .contentType(MediaType.APPLICATION_JSON) .body(Map.of("seller_vat", sellerVat, "buyer_vat", buyerVat)) .retrieve() .body(new ParameterizedTypeReference>() {}) .get("data"); } ``` `/v1/decide` is on the Business plan; for stores with simpler needs the validate-then-branch pattern works on every plan. ## Production patterns - **Configure the `HttpClient` once.** Whether you use plain Java's `HttpClient` or Spring's `RestClient`, instantiate it as a singleton bean. Re-creating per request defeats the connection pool. - **Set both connect and read timeouts.** Plain `HttpClient.newBuilder().connectTimeout(...)` covers the connect side; the read timeout goes on the request via `.timeout(...)`. Spring's `RestClient` accepts both via `ClientHttpRequestFactory`. - **Return typed results, not maps.** `ValidationResult` and `Decision` records are easier to refactor than `Map` and protect downstream code from API-shape changes. - **Surface `request_id` to MDC.** Every vatverify response includes `meta.request_id`; logging it next to your own request ID makes support escalation trivial. SLF4J's `MDC.put("vatverify_request_id", id)` is the typical place. ## What this guide does not cover - **Generated SDKs.** vatverify does not currently ship a generated Java SDK; the OpenAPI spec at `/docs/api` is suitable for `openapi-generator-maven-plugin` if you prefer generated types. - **Reactive (Mono/Flux) flows.** Swap `RestClient` for `WebClient` and the same patterns apply. The synchronous version above is enough for most service-to-service calls. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in PHP](/guides/validate-vat-number-php) - [Validate VAT in Go](/guides/validate-vat-number-go) - [VIES API guide](/guides/vies-api-guide) Source: https://www.vatverify.dev/guides/validate-vat-number-java --- ## Validate VAT numbers in Laravel Add EU VAT validation to a Laravel app with a custom validation rule, service container binding, and Livewire integration. ## TL;DR - No PHP SDK yet. Call the REST API directly via Laravel's `Http` facade. - Register a `Vatverify` service class as a singleton in `AppServiceProvider`. - Inject the service into a Form Request validation rule and use it anywhere in the app with zero duplication. ## Why a centralised VAT service beats copy-pasted fetch calls Copying an `Http::post()` call into every controller, job, and observer that needs VAT validation seems quick. In practice it creates a maintenance burden the moment the endpoint, auth scheme, or error-handling logic changes. You're updating three places instead of one. Laravel's service container was built for exactly this pattern: bind once, resolve everywhere, swap the implementation in tests with a single `$this->swap()` call. ## Installation note There is no official `vatverify-php` SDK yet. A PHP SDK is on the roadmap. Until it ships, the recommended approach is to call the REST API directly using Laravel's built-in `Http` facade. No Composer packages required. Add your credentials to `.env`: ```bash VATVERIFY_API_KEY=vtv_live_xxxxxxxxxxxx ``` Then register them in `config/services.php`: ```php title="config/services.php" 'vatverify' => [ 'key' => env('VATVERIFY_API_KEY'), 'base_url' => env('VATVERIFY_BASE_URL', 'https://api.vatverify.dev'), ], ``` ## Service class Create `app/Services/Vatverify.php`. This class owns all HTTP communication with the API. ```php title="app/Services/Vatverify.php" apiKey) ->acceptJson() ->get("{$this->baseUrl}/v1/validate", [ 'vat_number' => $vatNumber, ]); $response->throw(); return $response->json(); } catch (RequestException $e) { $status = $e->response->status(); $body = $e->response->json(); $message = $body['error']['message'] ?? 'vatverify API error'; throw new \RuntimeException("[vatverify {$status}] {$message}", $status, $e); } } } ``` ## Service container binding Bind the service as a singleton in `AppServiceProvider` so the same instance (and its underlying HTTP connection pool) is reused for the lifetime of each request. ```php title="app/Providers/AppServiceProvider.php" app->singleton(Vatverify::class, function () { return new Vatverify( apiKey: config('services.vatverify.key'), baseUrl: config('services.vatverify.base_url'), ); }); } } ``` ## Form Request validation rule ### The Rule class For Laravel 10+, implement `Illuminate\Contracts\Validation\ValidationRule`: ```php title="app/Rules/ValidVatNumber.php" vatverify->validate($value); } catch (\RuntimeException $e) { $fail('VAT number lookup failed. Please try again.'); return; } if (! ($result['data']['valid'] ?? false)) { $number = $result['data']['vat_number'] ?? $value; $fail("The VAT number {$number} is not registered."); } } } ``` For Laravel 9, implement `Illuminate\Contracts\Validation\Rule` and use `passes(string $attribute, mixed $value): bool` + a `message(): string` method instead. ### Wiring it into a Form Request ```php title="app/Http/Requests/StoreCompanyRequest.php" ['required', 'string', 'max:255'], 'vat_number' => ['required', 'string', new ValidVatNumber($vatverify)], 'country' => ['required', 'string', 'size:2'], ]; } } ``` Laravel resolves `Vatverify` from the container automatically when it calls `rules()`. ## Eloquent casting for invoices (optional) Store VAT numbers encrypted at rest and fire validation through a model observer before saving. ```php title="app/Models/Invoice.php" 'encrypted:string', ]; } ``` ```php title="app/Observers/InvoiceObserver.php" vat_number)) { return; } $result = $this->vatverify->validate($invoice->vat_number); if (! ($result['data']['valid'] ?? false)) { throw ValidationException::withMessages([ 'vat_number' => 'VAT number is not registered.', ]); } } } ``` Register the observer in `AppServiceProvider::boot()`: ```php use App\Models\Invoice; use App\Observers\InvoiceObserver; Invoice::observe(InvoiceObserver::class); ``` ## Livewire integration Use `wire:blur` to validate the VAT field as the user leaves it, without a page reload. ```php title="app/Livewire/VatInput.php" error = null; $this->company = null; if (strlen($this->vatNumber) < 4) { return; } try { $result = $vatverify->validate($this->vatNumber); } catch (\RuntimeException) { $this->error = 'Lookup failed. Please try again.'; return; } if ($result['data']['valid'] ?? false) { $this->company = $result['data']['company']['name'] ?? null; } else { $this->error = 'VAT number is not registered.'; } } public function render() { return view('livewire.vat-input'); } } ``` ```blade title="resources/views/livewire/vat-input.blade.php"
@if ($company)

✓ {{ $company }}

@elseif ($error)

{{ $error }}

@endif
``` ## Queue job pattern Dispatch a job on customer creation to validate (or re-validate) VAT numbers in the background. ```bash php artisan make:job ValidateVatJob ``` ```php title="app/Jobs/ValidateVatJob.php" company->vat_number)) { return; } $result = $vatverify->validate($this->company->vat_number); $this->company->update([ 'vat_valid' => $result['data']['valid'] ?? false, 'vat_verified_at' => now(), ]); } } ``` Dispatch from a controller or event listener: ```php ValidateVatJob::dispatch($company)->onQueue('default'); ``` ## Testing Use `Http::fake()` to mock API responses without hitting the network. ```php title="tests/Feature/ValidVatNumberRuleTest.php" Http::response([ 'data' => [ 'valid' => true, 'vat_number' => 'IE6388047V', 'country' => ['code' => 'IE', 'name' => 'Ireland'], 'company' => ['name' => 'Acme Ltd', 'address' => '1 Example St'], 'verified_at' => '2026-04-14T15:42:03.451Z', ], 'meta' => [ 'request_id' => '0190f8ea-a5b2-7000-a123-000000000000', 'cached' => false, 'source' => 'vies', 'source_status' => 'live', 'latency_ms' => 312, ], ], 200), ]); $rule = new ValidVatNumber(app(Vatverify::class)); $failed = false; $rule->validate('vat_number', 'IE6388047V', function () use (&$failed) { $failed = true; }); $this->assertFalse($failed); } public function test_invalid_vat_number_calls_fail_closure(): void { Http::fake([ '*/v1/validate*' => Http::response([ 'data' => [ 'valid' => false, 'vat_number' => 'DE000000000', 'country' => ['code' => 'DE', 'name' => 'Germany'], 'company' => null, 'verified_at' => '2026-04-14T15:42:03.451Z', ], 'meta' => [ 'request_id' => '0190f8ea-a5b2-7000-a123-000000000000', 'cached' => false, 'source' => 'vies', 'source_status' => 'live', 'latency_ms' => 312, ], ], 200), ]); $rule = new ValidVatNumber(app(Vatverify::class)); $message = null; $rule->validate('vat_number', 'DE000000000', function (string $msg) use (&$message) { $message = $msg; }); $this->assertStringContainsString('not registered', $message); } } ``` If you prefer to test against the real API, use a `vtv_test_xxx` key in your `.env.testing` file. It returns deterministic responses and never bills. ## FAQ ### Do I need Laravel 10+? The `Http` facade and singleton binding pattern work on Laravel 9+. The `ValidationRule` interface (used in `ValidVatNumber` above) was introduced in Laravel 10.0. If you are on Laravel 9, implement `Illuminate\Contracts\Validation\Rule` with `passes()` and `message()` methods instead. It is functionally identical. ### When will a PHP SDK land? A first-class `vatverify-php` SDK is on the roadmap. Follow [vatverify.dev/changelog](https://vatverify.dev/changelog) for release announcements. The `Http` facade pattern shown in this guide will remain supported after the SDK ships. ### How do I handle rate-limiting? The API returns HTTP 429 when you exceed your plan limit. Laravel's built-in `RateLimiter` middleware can pre-emptively protect high-traffic endpoints. For job-queue scenarios, set `public int $backoff = 30` (seconds) and `public int $tries = 3` on your job class. Laravel's queue worker handles the retry with exponential backoff automatically. ### Can I cache results in Laravel's cache driver? Yes, but our API already caches results for up to 30 days at the infrastructure level. Double-caching in Laravel is only worth it if you need sub-millisecond latency and are hitting the same VAT number extremely frequently (for example, on every page render). If you do cache, use `Cache::remember('vat:'.$vatNumber, now()->addDay(), fn () => $vatverify->validate($vatNumber))`. ### What about Jetstream or Breeze scaffolding? The `ValidVatNumber` rule and the `StoreCompanyRequest` Form Request pattern work identically in Jetstream and Breeze projects. Drop `new ValidVatNumber($vatverify)` into the `rules()` array of any Form Request. The service container resolves the dependency the same way regardless of which auth scaffolding you used. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in Next.js](/guides/validate-vat-number-nextjs) - [Stripe VAT validation](/guides/stripe-vat-validation) - [EU reverse-charge API](/guides/eu-vat-reverse-charge-api) Source: https://www.vatverify.dev/guides/validate-vat-number-laravel --- ## Validate VAT numbers in Next.js (App Router) Add VAT validation to a Next.js 15 app using server actions, route handlers, and @vatverify/node. Includes form binding and error states. ## TL;DR - Add a `'use server'` action, call `vat.validate({ vat_number: vatNumber })`, return the result. - Your API key never touches the browser. - Works with `useActionState` for pending and error states. `@vatverify/node` is packaged and ships on npm at the API launch. Reading this before launch? The REST endpoint is live today. Inside the server action, swap `vat.validate({ vat_number })` for a `fetch('https://api.vatverify.dev/v1/validate?vat_number=...')` call with an `Authorization: Bearer` header. The action wrapper, form binding, and error handling stay identical. ## Why server actions beat client-side fetches for VAT validation If VAT validation runs in a client component, your API key is exposed. Full stop. It appears as a header on every outgoing network request in the browser's DevTools, and any `NEXT_PUBLIC_*` env var is inlined into the client bundle at build time. A scraped key can be used by anyone to burn through your quota, trigger rate-limit bans on your account, or worse, if your key has write scopes elsewhere. A server action with `'use server'` runs exclusively on the server. The function body, the API key, and the network call to `api.vatverify.dev` all stay behind your Next.js process. The browser only ever sees a plain form submission and the typed response. No key, no risk. The secondary benefit is co-location: the validation logic lives next to the form it belongs to, not in a separate API route you have to keep in sync. Less indirection, less state to reason about, easier to test. ## Installation ```bash npm install @vatverify/node ``` Add your API key to `.env.local`: ```bash title=".env.local" VATVERIFY_API_KEY=vtv_live_xxxxxxxxxxxx ``` ## Server action pattern The simplest approach: co-locate the validation logic with your form as a server action. ```typescript title="app/actions/validate-vat.ts" 'use server'; import { Vatverify, VatverifyError } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export type ValidateVatResult = | { success: true; companyName: string; country: string } | { success: false; error: string }; export async function validateVat(vatNumber: string): Promise { if (!vatNumber.trim()) { return { success: false, error: 'VAT number is required.' }; } try { const result = await vat.validate({ vat_number: vatNumber.trim().toUpperCase() }); if (!result.data.valid) { return { success: false, error: `${result.data.vat_number} is not registered.` }; } return { success: true, companyName: result.data.company?.name ?? result.data.vat_number, country: result.data.country.code, }; } catch (err) { if (err instanceof VatverifyError) { if (err.code === 'rate_limited') { return { success: false, error: 'Too many requests. Please try again shortly.' }; } } return { success: false, error: 'Validation service unavailable.' }; } } ``` ## Route handler pattern Use this when you need a REST endpoint, for example to validate from a mobile app, a third-party webhook, or a non-Next.js frontend: ```typescript title="app/api/validate-vat/route.ts" import { NextRequest, NextResponse } from 'next/server'; import { Vatverify, VatverifyError } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function POST(req: NextRequest) { const body = await req.json().catch(() => null); const vatNumber = body?.vatNumber; if (typeof vatNumber !== 'string' || !vatNumber.trim()) { return NextResponse.json({ error: 'vatNumber is required' }, { status: 400 }); } try { const result = await vat.validate({ vat_number: vatNumber.trim().toUpperCase() }); return NextResponse.json({ valid: result.data.valid, vat_number: result.data.vat_number, country: result.data.country, company: result.data.company, }); } catch (err) { if (err instanceof VatverifyError) { const status = err.code === 'rate_limited' ? 429 : 502; return NextResponse.json({ error: err.message }, { status }); } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } ``` ## Form with useActionState A client component that wires the server action to a form, with pending, error, and success states: ```tsx title="app/checkout/vat-form.tsx" 'use client'; import { useActionState } from 'react'; import { validateVat, ValidateVatResult } from '@/app/actions/validate-vat'; const initialState: ValidateVatResult | null = null; export function VatForm() { const [state, action, isPending] = useActionState( async (_prev: ValidateVatResult | null, formData: FormData) => { const vatNumber = formData.get('vatNumber') as string; return validateVat(vatNumber); }, initialState ); return (
{state?.success === false && (

{state.error}

)} {state?.success === true && (

✓ {state.companyName} ({state.country})

)}
); } ``` ## Edge vs Node runtime The `@vatverify/node` SDK works on both the Node.js and Edge runtimes. If you need Node-only APIs (file system, crypto, etc.) alongside VAT validation, pin the route to Node explicitly: ```typescript title="app/api/validate-vat/route.ts" export const runtime = 'nodejs'; ``` Otherwise the default Edge runtime is fine and gives you lower cold-start latency on Vercel. ## Streaming / optimistic UI For a snappier feel, combine `useOptimistic` with `useActionState` to render a provisional result the moment the user submits, before the server action has returned. While the request is in flight, the UI shows a "Checking `IE6388047V`…" skeleton keyed on the entered number; when the real response lands, React swaps the optimistic state for the actual result. Pairs naturally with the `isPending` state already exposed by `useActionState`: ```tsx title="app/checkout/vat-form-optimistic.tsx" 'use client'; import { useActionState, useOptimistic } from 'react'; import { validateVat, ValidateVatResult } from '@/app/actions/validate-vat'; export function VatForm() { const [state, action, isPending] = useActionState( async (_prev: ValidateVatResult | null, formData: FormData) => validateVat(formData.get('vatNumber') as string), null, ); const [optimistic, addOptimistic] = useOptimistic<{ vat: string } | null>(null); return (
{ addOptimistic({ vat: formData.get('vatNumber') as string }); await action(formData); }} > {isPending && optimistic &&

Checking {optimistic.vat}…

} {!isPending && state?.success &&

✓ {state.companyName}

}
); } ``` The optimistic state is automatically discarded once `useActionState` commits the real response. No cleanup needed. ## Testing with Playwright Smoke test that the form validates a known VAT number end-to-end: ```typescript title="tests/vat-form.spec.ts" import { test, expect } from '@playwright/test'; test('validates a known Irish VAT number', async ({ page }) => { await page.goto('/checkout'); await page.fill('[name="vatNumber"]', 'IE6388047V'); await page.click('button[type="submit"]'); // The action uses vtv_test_* in CI via VATVERIFY_API_KEY env var await expect(page.locator('text=✓')).toBeVisible({ timeout: 5000 }); }); ``` Set `VATVERIFY_API_KEY=vtv_test_xxx` in your CI environment to get deterministic fixture responses. ## FAQ ### Does this work with the Pages Router? Yes, use an API route (`pages/api/validate-vat.ts`) and fetch from the client, or use `getServerSideProps`. Server actions are App Router only, but the `@vatverify/node` SDK works in both. ### Is the Edge runtime fully supported? Yes. The SDK makes standard `fetch` calls and has no Node-only dependencies, so it runs on the Edge runtime without any configuration. ### Should I use middleware or a route handler to validate VAT on every request? Neither. VAT validation belongs at the point of user input (checkout form, billing settings), not in middleware. Middleware runs on every request and adding a network call there will hurt your TTFB significantly. ### Can I cache VAT validation results in Next.js? The API already returns 30-day cached results for registered numbers. If you want an additional in-process cache, use `unstable_cache` from `next/cache` keyed on the normalised VAT number string. ### Where should I put VATVERIFY_API_KEY? In `.env.local` for development, and in your hosting provider's environment variable settings for production. Never prefix it with `NEXT_PUBLIC_`. That would expose it to the browser. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [EU reverse-charge API](/guides/eu-vat-reverse-charge-api) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) - [Webhooks](/features/webhooks) - [Stripe VAT validation](/guides/stripe-vat-validation) Source: https://www.vatverify.dev/guides/validate-vat-number-nextjs --- ## How to validate VAT numbers in Node.js Validate EU, UK, Swiss, and Norwegian VAT numbers in Node.js with real checksum algorithms and a free live API. ## TL;DR - Install `@vatverify/node`, paste your API key, call `vat.validate()`. - Free up to 500 requests per month. - Handles format + checksum + live registry lookup in one call. `@vatverify/node` is packaged and ships on npm at the API launch. Reading this before launch? The REST endpoint is live today. Swap any `vat.validate({ vat_number })` call for `fetch('https://api.vatverify.dev/v1/validate?vat_number=...')` with an `Authorization: Bearer` header. The SDK is a typed wrapper around the same thing. ## Why not just use a regex? Regex can tell you the *shape* of a VAT number: the country prefix, the digit count, whether there's a `U` where a `U` belongs. It cannot tell you whether the mandatory check digit is mathematically correct, and it cannot tell you whether the number is currently registered in VIES or HMRC. Every EU country bakes a checksum algorithm into its VAT number format: Germany uses a MOD-11 variant, France combines a MOD-97 key with the SIREN-derived body, the Netherlands uses a weighted mod-11, the UK uses HMRC's 97-55 algorithm, Norway and Switzerland use their own MOD-11 schemes. A regex will happily accept `DE000000000`: nine digits after `DE`. The checksum says it's invalid. The registry says nobody holds it. The inverse is also common: a perfectly well-formed and mathematically valid VAT number can still be unregistered (deregistered business, typo for a real number) or have been assigned to a different entity than what your customer claims. Only a live lookup settles that, which is exactly what the [live validator](/features/validate) does. ## Installation ```bash npm install @vatverify/node ``` ## Basic validation ```typescript title="validate.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const result = await vat.validate({ vat_number: 'IE6388047V' }); if (result.data.valid) { console.log(`Valid: ${result.data.company?.name}`); } ``` ## Handling errors ```typescript title="validate-with-errors.ts" import { Vatverify, VatverifyError } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); try { const result = await vat.validate({ vat_number: 'DE123456789' }); if (!result.data.valid) { console.log(`Invalid: ${result.data.vat_number} is not registered.`); } } catch (err) { if (err instanceof VatverifyError) { if (err.code === 'rate_limited') { // back off based on err.retry_after (seconds until you can retry) } } throw err; } ``` ## Offline-only validation (no API call) If you only need format + checksum (no live registry lookup), use `@vatverify/vat-rates`, a zero-dependency offline library. It runs the same real checksum algorithms the live API uses before it hits VIES, so you can reject obviously broken VAT numbers at the form level without spending an API call. Under 50 kB minified + gzipped, works in Node, the browser, Vercel Edge, and Cloudflare Workers: ```bash npm install @vatverify/vat-rates ``` ```typescript import { validate } from '@vatverify/vat-rates'; const result = validate('IE6388047V'); // { valid: true } ``` This runs instantly, no network, no API key. ## Next.js server action For App Router projects the cleanest wiring is a server action. The `'use server'` directive ensures the code never ships to the browser, so your API key stays on the server without any extra configuration. Server actions can be called directly from a form's `action` prop (`
`) or via `useActionState` for pending + error state. Full App Router patterns (forms, `useActionState`, optimistic UI) live in the [Next.js guide](/guides/validate-vat-number-nextjs). ```typescript title="app/actions/validate-vat.ts" 'use server'; import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); export async function validateVat(vatNumber: string) { const result = await vat.validate({ vat_number: vatNumber }); return result; } ``` ## Testing with test-mode keys Use `vtv_test_*` keys for deterministic responses. See [Test mode](/docs/test-mode). ```typescript title="tests/vat.test.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify('vtv_test_xxx'); test('valid Irish VAT returns company name', async () => { const result = await vat.validate({ vat_number: 'IE6388047V' }); expect(result.data.valid).toBe(true); }); ``` ## FAQ ### Does this work with TypeScript? Yes, full type definitions ship with `@vatverify/node`. Types auto-complete in any TypeScript 5+ project. ### Can I validate without an API key? Use `@vatverify/vat-rates` for offline format + checksum validation. The live registry check requires an API key. ### Does Node.js 18 work? Yes, Node 18 LTS and newer are supported. For best performance, use Node 20 LTS or 22 LTS. ### How do I handle VIES downtime? See [handle-vies-downtime](/guides/handle-vies-downtime). We serve the most recent cached response with `meta.source_status: "degraded"` when the upstream registry is unavailable. ### What happens on an invalid VAT number? `result.data.valid` is `false` when the registry responds but the number is not registered. Obvious format and checksum problems return a `VatverifyError` with `code: "invalid_format"` instead; catch it alongside the other SDK errors. ## Related guides - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in Next.js](/guides/validate-vat-number-nextjs) - [EU reverse-charge API](/guides/eu-vat-reverse-charge-api) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) - [Webhooks](/features/webhooks) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/validate-vat-number-nodejs --- ## Validate VAT numbers in PHP: Guzzle, Symfony HTTP Client, and curl patterns Validate EU and UK VAT numbers in raw PHP without WordPress or a framework dependency. Includes Guzzle, Symfony HTTP Client, and curl examples plus a small caching layer suitable for any PHP application. ## TL;DR - Call vatverify's REST API from PHP using Guzzle, Symfony HTTP Client, or plain curl. No framework required. - Cache valid responses for 24 hours in APCu or Redis on top of vatverify's 30-day server-side cache for sub-millisecond hot-path lookups. - For Laravel and WordPress, see the dedicated guides: [Laravel](/guides/validate-vat-number-laravel), [WooCommerce](/guides/woocommerce-vat-validation). ## Why raw PHP A lot of EU B2B invoicing still runs on framework-light PHP: small WordPress plugins, classic Symfony bundles, or single-file Slim applications. Bringing in a heavy framework dependency to call one HTTP endpoint is overkill. The patterns below assume PHP 8.1 or later and either Guzzle, Symfony HTTP Client, or just curl. ## Install one HTTP client Either of the two common PHP HTTP clients works. Guzzle is the most widely used; Symfony HTTP Client is lighter-weight and ships with Symfony. ```bash # Guzzle composer require guzzlehttp/guzzle # Or Symfony HTTP Client composer require symfony/http-client ``` If you cannot add a Composer dependency for any reason, the curl example near the bottom of this guide is a one-file fallback. ## Single-number validation with Guzzle ```php title="Vatverify.php" http->post('https://api.vatverify.dev/v1/validate', [ 'headers' => [ 'Authorization' => "Bearer {$this->apiKey}", 'Accept' => 'application/json', ], 'json' => ['vat_number' => $vatNumber], 'timeout' => 10, 'connect_timeout' => 3, ]); $body = json_decode((string) $response->getBody(), true); $data = $body['data'] ?? null; return new ValidationResult( valid: $data['valid'] ?? false, country: $data['country'] ?? null, companyName: $data['company']['name'] ?? null, consultationNumber: $data['consultation_number'] ?? null, ); } catch (RequestException $e) { $body = $e->hasResponse() ? json_decode((string) $e->getResponse()->getBody(), true) : null; $code = $body['error']['code'] ?? 'unknown'; throw new VatverifyException("VAT validation failed: {$code}", previous: $e); } } } final class ValidationResult { public function __construct( public readonly bool $valid, public readonly ?string $country, public readonly ?string $companyName, public readonly ?string $consultationNumber, ) {} } final class VatverifyException extends \RuntimeException {} ``` The class is intentionally small. Nothing in it is framework-specific; it works equally well in a Slim application, a WordPress plugin, or a long-running Swoole worker. ## Symfony HTTP Client variant Symfony's HTTP Client supports the same shape of call with a different idiom: ```php use Symfony\Contracts\HttpClient\HttpClientInterface; final class VatverifySymfony { public function __construct( private readonly HttpClientInterface $http, private readonly string $apiKey, ) {} public function validate(string $vatNumber): array { $response = $this->http->request('POST', 'https://api.vatverify.dev/v1/validate', [ 'auth_bearer' => $this->apiKey, 'json' => ['vat_number' => $vatNumber], 'timeout' => 10, ]); return $response->toArray()['data'] ?? []; } } ``` Symfony's `auth_bearer` option handles the `Authorization: Bearer ...` header automatically. The `toArray()` call doubles as a JSON-decode and a status-code check (it throws on 4xx/5xx unless told not to). ## curl fallback for environments without Composer For shared hosting or one-file scripts where Composer isn't available: ```php function vatverify_validate(string $vatNumber, string $apiKey): ?array { $ch = curl_init('https://api.vatverify.dev/v1/validate'); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer {$apiKey}", 'Content-Type: application/json', 'Accept: application/json', ], CURLOPT_POSTFIELDS => json_encode(['vat_number' => $vatNumber]), CURLOPT_TIMEOUT => 10, CURLOPT_CONNECTTIMEOUT => 3, ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status !== 200 || $body === false) return null; return json_decode($body, true)['data'] ?? null; } ``` This is the smallest dependency-free version. It works on PHP 7.4 onward. ## Caching with APCu vatverify caches valid responses for 30 days server-side, but every PHP request still incurs a network round-trip to the vatverify API. For high-traffic checkouts, an in-process or Redis cache eliminates the round-trip on cache hits. ```php function vatverify_cached(string $vatNumber, string $apiKey): ?array { $key = 'vatverify:' . hash('sha256', $vatNumber); if (function_exists('apcu_fetch')) { $hit = apcu_fetch($key, $found); if ($found) return $hit; } $result = vatverify_validate($vatNumber, $apiKey); if ($result !== null && function_exists('apcu_store')) { $ttl = ($result['valid'] ?? false) ? 86400 : 3600; apcu_store($key, $result, $ttl); } return $result; } ``` Two TTLs: - **24 hours for valid** matches the typical registration-change cadence at the upstream registries. - **1 hour for invalid** lets a customer fix a typo or finish their registration without waiting a full day for the local cache to expire. For multi-server deployments, swap APCu for Redis (`predis/predis` or `phpredis`). ## Reverse-charge decision For the full B2B reverse-charge decision (not just buyer-side validation), call `/v1/decide` instead. It returns a structured decision with the legal basis and an invoice note: ```php $response = $http->post('https://api.vatverify.dev/v1/decide', [ 'headers' => ['Authorization' => "Bearer {$apiKey}"], 'json' => [ 'seller_vat' => 'IE6388047V', 'buyer_vat' => 'DE811569869', ], ]); $decision = json_decode((string) $response->getBody(), true)['data']; // $decision['charge_vat'] === false // $decision['mechanism'] === 'reverse_charge' // $decision['legal_basis'] === 'EU VAT Directive Article 196' // $decision['invoice_note'] === 'Reverse charge: VAT to be accounted for by the recipient' ``` `/v1/decide` is on the Business plan; for stores with simpler needs the validate-then-branch pattern works on every plan. ## Production patterns A few patterns worth using in real PHP applications: - **Set a request timeout**. The default Guzzle timeout is unset; that means a slow VIES response can hang a checkout indefinitely. The 10-second timeout above is generous; for checkout flows, 5 seconds plus a fallback to "validation pending" is often better. - **Retry once on 5xx, not on 4xx**. A 5xx from vatverify usually means an upstream registry is having a brief outage; one retry after a short delay clears most of these. A 4xx means the request itself was malformed; retrying does not help. - **Surface `request_id` to logs**. Every vatverify response includes `meta.request_id`. Logging it next to your own request ID makes support escalation trivial. - **Don't log the full payload at INFO level**. The validation response can include the buyer's company name and address. Treat it as PII; log it only at DEBUG and behind a feature flag. ## What this guide does not cover - **WordPress plugin scaffolding.** See the dedicated [WooCommerce VAT validation](/guides/woocommerce-vat-validation) guide for the WordPress hook integration. - **Laravel-specific DI patterns.** See the [Laravel guide](/guides/validate-vat-number-laravel). - **The CLI tool for one-off lookups.** vatverify does not currently ship a PHP CLI; the curl helper above doubles as one for ad-hoc checks. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in Laravel](/guides/validate-vat-number-laravel) - [WooCommerce VAT validation](/guides/woocommerce-vat-validation) - [VIES API guide](/guides/vies-api-guide) Source: https://www.vatverify.dev/guides/validate-vat-number-php --- ## How to validate VAT numbers in Python Validate EU, UK, Swiss, and Norwegian VAT numbers from Python by calling the vatverify REST API directly with httpx or requests. Sync, async, and offline modes. ## TL;DR - There is no official Python SDK yet (on the roadmap). For now, call the REST API directly with `httpx` or `requests`. - `GET https://api.vatverify.dev/v1/validate?vat_number=IE6388047V` with an `Authorization: Bearer` header. - For offline format + checksum validation, install `vatverify-rates` (`pip install vatverify-rates`). `vatverify-rates` ships on PyPI alongside the API launch (v0.1.0 packaged, 160 tests green). Reading this before launch? The REST API is live today. The `httpx` / `requests` examples below work without any package install. The JavaScript sibling `@vatverify/vat-rates` is already on npm if you need offline checksum validation today. ## Why format checking isn't enough A regex can confirm that `DE123456789` *looks* like a German VAT number (two letters, nine digits, correct prefix). It cannot confirm the final check digit is mathematically correct, and it cannot confirm the business is actively registered. Every EU country embeds a checksum algorithm into its VAT number format: Germany and the Netherlands use MOD-11 variants, France layers a MOD-97 key onto the SIREN, Italy uses a Luhn-style check, the UK uses HMRC's 97-55 algorithm, Norway and Switzerland run their own MOD-11 schemes. `python-stdnum` and similar libraries implement these, but format + checksum still only tells you a number is *well-formed*, not that it belongs to a live, VAT-registered business. `DE000000000` passes a format regex. It fails the German MOD-11 check. Even a number that passes both can be deregistered, recycled, or typoed into a different company's number. Only a live lookup against the national registry settles that, which is what the [live validator](/features/validate) does. ## Calling the REST API from Python The public API is plain REST with JSON responses. Any HTTP client works. Examples below use `httpx` (supports both sync and async) and the popular `requests` library. ### Sync with `httpx` ```python title="validate.py" import os import httpx API_KEY = os.environ["VATVERIFY_API_KEY"] BASE_URL = "https://api.vatverify.dev" def validate_vat(vat_number: str) -> dict: response = httpx.get( f"{BASE_URL}/v1/validate", params={"vat_number": vat_number}, headers={"Authorization": f"Bearer {API_KEY}"}, timeout=30.0, ) response.raise_for_status() return response.json() result = validate_vat("IE6388047V") data = result["data"] if data["valid"]: company = data.get("company") or {} print(f"Valid: {company.get('name', data['vat_number'])}") else: print(f"{data['vat_number']} is not registered.") ``` ### Sync with `requests` ```python title="validate_requests.py" import os import requests API_KEY = os.environ["VATVERIFY_API_KEY"] response = requests.get( "https://api.vatverify.dev/v1/validate", params={"vat_number": "IE6388047V"}, headers={"Authorization": f"Bearer {API_KEY}"}, timeout=30, ) response.raise_for_status() result = response.json() print(result["data"]["valid"], result["data"]["country"]["code"]) ``` ### Async with `httpx.AsyncClient` Use this shape inside FastAPI, Starlette, or any async framework. Share one `AsyncClient` across the process to reuse the connection pool. ```python title="validate_async.py" import asyncio import os import httpx API_KEY = os.environ["VATVERIFY_API_KEY"] client = httpx.AsyncClient( base_url="https://api.vatverify.dev", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=30.0, ) async def validate_vat(vat_number: str) -> dict: response = await client.get("/v1/validate", params={"vat_number": vat_number}) response.raise_for_status() return response.json() async def main() -> None: result = await validate_vat("IE6388047V") print(result["data"]["valid"]) asyncio.run(main()) ``` ## Handling rate limits (HTTP 429) When you exceed your plan limit, the API returns `429` with a `Retry-After` header (in seconds). Honour it: it's the single source of truth for when you can retry. ```python title="rate_limits.py" import time import httpx def validate_with_retry(vat_number: str, client: httpx.Client) -> dict: for _ in range(3): response = client.get("/v1/validate", params={"vat_number": vat_number}) if response.status_code == 429: wait = int(response.headers.get("Retry-After", "1")) time.sleep(wait) continue response.raise_for_status() return response.json() raise RuntimeError("rate limited after 3 attempts") ``` ## Offline format + checksum with `vatverify-rates` For format + checksum validation without a network call, install the offline package. Zero runtime dependencies, works in lambdas and containers without any credentials: ```bash pip install vatverify-rates ``` ```python title="offline.py" from vatverify_rates import validate, get_rate, get_standard_rate result = validate("IE6388047V") # ValidateResult(valid=True) bad = validate("DE000000000") # ValidateResult(valid=False, errors=("invalid checksum",)) rate = get_rate("DE") # Rate(standard=19.0, reduced=(7.0,), super_reduced=None, ...) print(get_standard_rate("DE")) # 19.0 ``` `ValidateResult` has two shapes: `valid=True` (no `errors`) or `valid=False` with an `errors` tuple. No API key, no network, no latency. ### Hybrid pattern: offline filter then live lookup The efficient pattern is to reject obvious garbage offline (zero cost), then call the API only for numbers that pass format + checksum: ```python title="hybrid.py" import os import httpx from vatverify_rates import validate as offline_validate API_KEY = os.environ["VATVERIFY_API_KEY"] client = httpx.Client( base_url="https://api.vatverify.dev", headers={"Authorization": f"Bearer {API_KEY}"}, timeout=30.0, ) def validate_vat(vat_number: str) -> dict: offline = offline_validate(vat_number) if not offline.valid: return {"valid": False, "source": "offline", "errors": list(offline.errors)} response = client.get("/v1/validate", params={"vat_number": vat_number}) response.raise_for_status() return {"source": "live", **response.json()} ``` ## Django and FastAPI patterns For framework-specific guides, see the dedicated pages: [Validate VAT in Django](/guides/validate-vat-number-django) for forms, admin actions, DRF serializers, and async views. For FastAPI, wrap the `httpx.AsyncClient` in a dependency: ```python title="dependencies.py" from fastapi import Depends, HTTPException, Query import httpx _client = httpx.AsyncClient( base_url="https://api.vatverify.dev", timeout=30.0, ) async def require_valid_vat(vat_number: str = Query(...)) -> dict: import os response = await _client.get( "/v1/validate", params={"vat_number": vat_number}, headers={"Authorization": f"Bearer {os.environ['VATVERIFY_API_KEY']}"}, ) if response.status_code == 429: raise HTTPException(status_code=429, detail="rate limited") response.raise_for_status() data = response.json()["data"] if not data["valid"]: raise HTTPException(status_code=422, detail=f"{data['vat_number']} is not registered") return data ``` ## Testing with test-mode keys Use `vtv_test_*` keys for deterministic fixture responses. See [Test mode](/docs/test-mode). ```python title="tests/test_vat.py" import httpx def test_valid_irish_vat_returns_company_name(): response = httpx.get( "https://api.vatverify.dev/v1/validate", params={"vat_number": "IE6388047V"}, headers={"Authorization": "Bearer vtv_test_xxx"}, ) response.raise_for_status() data = response.json()["data"] assert data["valid"] is True assert data["country"]["code"] == "IE" ``` ## FAQ ### Is there an official Python SDK? Not yet. A first-class Python SDK is on the roadmap. In the meantime, `httpx` or `requests` against the REST API is the supported integration path. ### Can I use `vatverify-rates` offline in a lambda or container? Yes, `vatverify-rates` has zero runtime dependencies and bundles its own country data. The installed wheel is small and works fine on cold-start-sensitive runtimes. ### How does VIES downtime affect my integration? When VIES is unavailable, the API returns the most recent cached result with `meta.source_status: "degraded"`. See [handle-vies-downtime](/guides/handle-vies-downtime) for retry guidance. ### What does an invalid VAT number response look like? When the registry responds but the number is not registered, `data.valid` is `false` and `data.company` is `null`. Obvious format errors return a structured 400 response with an `error.code` like `invalid_format` or `country_unsupported`. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Django](/guides/validate-vat-number-django) - [Validate VAT in Next.js](/guides/validate-vat-number-nextjs) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/validate-vat-number-python --- ## Validate VAT numbers in Ruby on Rails: HTTP, ActiveJob, and idempotent caching Validate EU and UK VAT numbers from a Rails application. Includes a service object, an ActiveJob worker for re-validation cadence, and a Rails.cache layer suitable for production. ## TL;DR - Use Ruby's standard `Net::HTTP` or the `httparty` gem to call vatverify from a Rails service object. - Cache valid responses with `Rails.cache` for 24 hours; vatverify also caches server-side for 30 days. - An ActiveJob worker can re-validate B2B customer lists on a monthly cadence and react to deregistrations. ## Choosing an HTTP client Three reasonable choices in the Ruby ecosystem: - **`Net::HTTP`** in the standard library. Zero dependencies. Verbose but reliable. - **`httparty`** as a small wrapper around `Net::HTTP` with a friendlier API. - **`faraday`** with adapters, useful when you already use it for other APIs in the same Rails app. The examples below use `Net::HTTP` because it works without any gem additions. The patterns translate directly to `httparty` or `faraday` if you prefer. ## A small service object ```ruby title="app/services/vatverify.rb" require 'net/http' require 'uri' require 'json' class Vatverify class Error < StandardError; end API = URI('https://api.vatverify.dev/v1/validate') def initialize(api_key:) @api_key = api_key end def validate(vat_number) request = Net::HTTP::Post.new(API) request['Authorization'] = "Bearer #{@api_key}" request['Content-Type'] = 'application/json' request['Accept'] = 'application/json' request.body = { vat_number: vat_number }.to_json response = Net::HTTP.start(API.host, API.port, use_ssl: true, open_timeout: 3, read_timeout: 10) do |http| http.request(request) end body = JSON.parse(response.body) if response.code.to_i >= 400 raise Error, "vatverify returned #{response.code}: #{body.dig('error', 'code')}" end Result.from_payload(body['data']) end Result = Struct.new(:valid, :country, :company_name, :consultation_number, keyword_init: true) do def self.from_payload(data) new( valid: data['valid'] == true, country: data['country'], company_name: data.dig('company', 'name'), consultation_number: data['consultation_number'], ) end end end ``` The service object holds the API key explicitly; do not pull it from `ENV` inside the class. Inject it from `Rails.application.credentials` or your DI container so the class is testable in isolation. ## Wiring it up in Rails Register a singleton in an initializer so application code can call `Vatverify.client.validate(...)` without re-instantiating: ```ruby title="config/initializers/vatverify.rb" class Vatverify cattr_accessor :client self.client = Vatverify.new( api_key: Rails.application.credentials.dig(:vatverify, :api_key), ) end ``` Or use a proper DI gem like `dry-system` if your application already does. The point is to keep the API key out of business logic. ## Caching with Rails.cache ```ruby title="app/services/vatverify_cached.rb" class VatverifyCached TTL_VALID = 24.hours TTL_INVALID = 1.hour def self.validate(vat_number) Rails.cache.fetch(cache_key(vat_number), expires_in: ttl_for(vat_number)) do result = Vatverify.client.validate(vat_number) result.to_h end end def self.cache_key(vat_number) "vatverify:#{Digest::SHA256.hexdigest(vat_number)}" end def self.ttl_for(vat_number) cached = Rails.cache.read(cache_key(vat_number)) cached&.dig(:valid) ? TTL_VALID : TTL_INVALID end end ``` Two TTLs: - **24 hours for valid** matches the typical registration-change cadence at the upstream registries. - **1 hour for invalid** lets a customer fix a typo without waiting a day. The `to_h` call is intentional: serializing a Struct to a Hash before storing keeps the cache backend (Redis, Memcached, file) language-agnostic and avoids deserialization quirks. ## Live validation in a controller ```ruby title="app/controllers/api/customers_controller.rb" class Api::CustomersController < ApplicationController def update customer = Customer.find(params[:id]) new_vat = params[:vat_number] if customer.vat_number != new_vat && new_vat.present? result = VatverifyCached.validate(new_vat) if result[:valid] customer.update!( vat_number: new_vat, tax_exempt: cross_border_b2b?(result), vat_validation_payload: result.to_json, vat_checked_at: Time.current, ) else return render json: { error: 'invalid_vat' }, status: :unprocessable_entity end end render json: customer end private def cross_border_b2b?(result) result[:valid] && result[:country] && result[:country] != ENV['SELLER_COUNTRY'] end end ``` The `vat_validation_payload` column is the audit trail. Persisting the full result alongside the timestamp gives you a reconstructible record without a separate logging system. ## ActiveJob worker for re-validation ```ruby title="app/jobs/revalidate_b2b_customer_job.rb" class RevalidateB2bCustomerJob < ApplicationJob queue_as :default def perform(customer_id) customer = Customer.find(customer_id) return unless customer.vat_number.present? fresh = Vatverify.client.validate(customer.vat_number) if !fresh.valid && customer.tax_exempt customer.update!( tax_exempt: false, vat_deregistered_at: Time.current, ) Notifier.deregistration_detected(customer).deliver_later end rescue Vatverify::Error => e Rails.logger.warn("vatverify revalidation failed for #{customer_id}: #{e.message}") raise end end ``` Schedule it from a cron-style worker (sidekiq-cron, solid-queue with cron support, or Rails' built-in recurring jobs in Rails 8): ```ruby # config/recurring.yml (Rails 8) production: revalidate_b2b_customers: class: RevalidateAllB2bCustomersJob schedule: every month ``` The fan-out worker: ```ruby class RevalidateAllB2bCustomersJob < ApplicationJob def perform Customer.where(tax_exempt: true).find_each do |c| RevalidateB2bCustomerJob.perform_later(c.id) end end end ``` ## Production patterns - **Set explicit timeouts.** `open_timeout: 3, read_timeout: 10` covers the typical VIES round-trip plus margin. Without timeouts a slow upstream registry can hang a Rails request indefinitely. - **Retry only on 5xx**. A `Vatverify::Error` raised from a 5xx response is worth one retry after a short delay; a 4xx means the request is malformed and retrying will not help. ActiveJob's `retry_on` can encode this: ```ruby retry_on Vatverify::Error, wait: 30.seconds, attempts: 2 ``` - **Don't log full payloads at `info`**. The validation response can include the buyer's company name and address. Treat it as PII; log only at `debug` or behind a flag. - **Surface `request_id` to logs**. Every vatverify response includes `meta.request_id`. Logging it next to the Rails request ID makes support escalation trivial. ## What this guide does not cover - **OSS and IOSS** for cross-border B2C. Different scheme, different evidence requirements. See [OSS and IOSS implementation guide](/guides/oss-ioss-implementation-guide). - **Domestic-reverse-charge sectors** in the EU member states that operate one. The cross-border check above does not handle the construction-sector domestic rule. - **Stripe Tax integration**. See [Stripe VAT validation](/guides/stripe-vat-validation) for the wiring between vatverify and Stripe Tax / Stripe Customer Tax IDs. ## Related guides - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Validate VAT in PHP](/guides/validate-vat-number-php) - [VIES API guide](/guides/vies-api-guide) - [Reverse charge VAT explained](/guides/reverse-charge-vat-explained) Source: https://www.vatverify.dev/guides/validate-vat-number-ruby-rails --- ## Batch VAT validation: validate up to 50 numbers per request Validate hundreds or thousands of VAT numbers at once with the batch endpoint. Concurrency, retries, partial-failure handling, and the patterns that scale to weekly customer-list audits. ## TL;DR - `/v1/validate/batch` accepts up to 50 VAT numbers in a single request and returns one result per number. - Each number is validated against its country's registry independently, so partial failures are normal and explicit in the response shape. - For larger lists (thousands of customers), batch the input client-side, parallelise across batches, and back off on `rate_limited` responses. The pattern below does all three. ## When to use batch instead of single-number calls Single-number `/v1/validate` is the right call for live-checkout, customer-onboarding, and any flow where one VAT number resolves at a time. Batch is right when: - You audit your full B2B customer list once a month and want to spot deregistrations. - You import a CSV from a CRM or accounting tool and need to clean up VAT numbers before they hit Stripe Tax or your invoicing system. - You build a one-off compliance report for the finance team across an entire customer base. Batch is gated to the **Pro and Business plans**. The Free and Starter plans receive `plan_required` on `/v1/validate/batch`. Test-mode keys (`vtv_test_`) bypass plan gating and are the right choice for local development. ## The request and response shape A batch request takes an array of objects, each with a `vat_number` and an optional `tag` you can use to correlate results with your own input row. ```bash curl https://api.vatverify.dev/v1/validate/batch \ -H "Authorization: Bearer $VATVERIFY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "vat_number": "DE123456789", "tag": "row-1" }, { "vat_number": "FR12345678901", "tag": "row-2" }, { "vat_number": "GB123456789", "tag": "row-3" } ] }' ``` The response includes one entry per input, in the same order: ```json { "data": { "items": [ { "tag": "row-1", "valid": true, "country": "DE", "company": null, "consultation_number": "WAPIAAAA1234..." }, { "tag": "row-2", "valid": true, "country": "FR", "company": { "name": "..." }, "consultation_number": "..." }, { "tag": "row-3", "valid": false, "country": "GB", "reason": "deregistered" } ], "summary": { "total": 3, "valid": 2, "invalid": 1, "errored": 0 } }, "meta": { "request_id": "...", "latency_ms": 1842 } } ``` Two fields worth noting: - **`tag` round-trips unchanged.** Use it to map a batch result back to your input row without re-parsing the VAT number. - **`summary` is computed server-side.** It splits results into valid, invalid, and errored. Errored rows are ones where the upstream registry returned a transient failure (`registry_unavailable`, `MS_UNAVAILABLE`); they should be retried. ## A scaling pattern for thousands of numbers Most use cases need to validate more than 50 numbers at once. The basic pattern: split the input into batches of 50, run a small concurrency pool, and retry errored items. ```ts title="batch-validate.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const BATCH_SIZE = 50; const CONCURRENCY = 4; interface InputRow { id: string; vat_number: string; } interface ResultRow extends InputRow { valid: boolean | null; country: string | null; reason: string | null; consultation_number: string | null; } export async function validateAll(rows: InputRow[]): Promise { const batches: InputRow[][] = []; for (let i = 0; i < rows.length; i += BATCH_SIZE) { batches.push(rows.slice(i, i + BATCH_SIZE)); } const results: ResultRow[] = []; for (let i = 0; i < batches.length; i += CONCURRENCY) { const window = batches.slice(i, i + CONCURRENCY); const settled = await Promise.all(window.map(runBatch)); results.push(...settled.flat()); } return results; } async function runBatch(batch: InputRow[]): Promise { const items = batch.map((r) => ({ vat_number: r.vat_number, tag: r.id })); const res = await vat.validateBatch({ items }); return res.data.items.map((item, i) => ({ id: batch[i]!.id, vat_number: batch[i]!.vat_number, valid: typeof item.valid === 'boolean' ? item.valid : null, country: item.country ?? null, reason: item.reason ?? null, consultation_number: item.consultation_number ?? null, })); } ``` The concurrency cap of 4 is a starting point. Each batch consumes one HTTP request and (under the hood) up to 50 upstream registry calls. Pushing concurrency higher risks tripping the per-key RPM limit (90 req/min on Pro, 180 on Business) and is rarely necessary because individual batches are slower than the rate budget anyway. ## Handling partial failures A batch with one bad number does not fail the whole request. The response item carries either `valid: true/false` or an error code: ```json { "tag": "row-7", "valid": null, "error": { "code": "registry_unavailable", "message": "FR registry returned MS_UNAVAILABLE" } } ``` Three error codes you will see most often: - **`invalid_format`**: the input did not match the country's VAT format. No registry call was made; no retry will help. Surface to the user as a data-quality problem. - **`registry_unavailable`**: the upstream country registry returned a transient fault (typically `MS_UNAVAILABLE` from VIES). Retry after a short delay; most of these resolve within minutes. - **`country_unsupported`**: the VAT number's country is not in the supported set. No retry will help; the caller has to handle the unsupported jurisdiction differently. The retry pattern that works: ```ts async function validateAllWithRetry(rows: InputRow[]) { const first = await validateAll(rows); const transientFails = first.filter((r) => r.reason === 'registry_unavailable'); if (transientFails.length === 0) return first; await sleep(60_000); const retried = await validateAll(transientFails); return mergeResults(first, retried); } ``` A 60-second delay before retry is enough for the typical VIES `MS_UNAVAILABLE` window. For more persistent country-specific outages, surface the error to the caller and let them decide; do not retry indefinitely. ## Caching and quota economics Cached responses do not count against batch quota in the same way as live calls. If your customer list contains a number that vatverify validated for any caller within the last 30 days (the default valid TTL), the response comes from cache and uses zero registry-side budget. In practice, a monthly customer-list audit hits cache heavily on the second run because most numbers have not changed. A 5,000-customer list might use only a few hundred live calls on the second pass instead of 5,000. ## Storing the audit trail Every batch response carries enough information to be the audit-trail record. The fields that matter: - `vat_number` and `tag` (your input row identifier) - `valid` - `country` - `consultation_number` (when present, the proof-of-query reference issued by VIES) - The `meta.fetched_at` timestamp from the response wrapper vatverify retains its own audit log on Pro and Business plans; the Pro plan retains 30 days, Business retains 90. If you run an annual audit, persist the response payload yourself in your accounting system as well; that gives you a reconstructible record beyond the platform retention. ## What this tutorial does not cover - **Webhooks**. The batch endpoint is synchronous; there is no async-job pattern. For very large lists where the response time exceeds your timeout budget, split the work into smaller jobs and use webhooks to receive per-job summaries. See the [VAT webhooks guide](/guides/vat-webhooks-tutorial) for that pattern. - **`/v1/decide` in batch.** The tax-rules engine is currently single-call only. For B2B reverse-charge decisions across a large customer list, validate first with batch, then call `/v1/decide` per cross-border B2B row. ## Related guides - [VIES API guide](/guides/vies-api-guide) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Handle VIES downtime](/guides/handle-vies-downtime) - [VAT webhooks tutorial](/guides/vat-webhooks-tutorial) Source: https://www.vatverify.dev/guides/vat-batch-validation-tutorial --- ## VAT compliance evidence: what tax auditors actually accept (and how to produce it) A field guide to the four tiers of VAT validation evidence, what auditors check for, and what changes under ViDA from 2026 onward. Written for finance and compliance teams at EU sellers. ## The audit moment A Finanzamt audit letter arrives. The auditor wants every cross-border B2B invoice you issued in the last seven years, with proof that the buyer's VAT number was valid at the time you invoiced and that you took reasonable steps to confirm the buyer was who they claimed to be. You have fourteen days to respond. For each invoice, the auditor expects three things in your file: - A timestamped record of when the validation actually happened. - The source registry that returned the answer, with its identifier for the consultation. - For German-resident sellers making VAT-free intra-EU supplies, per-field evidence under §18e UStG that the buyer's name and address matched what the foreign member state has on file. If you have been validating with VIES alone and storing nothing, the answer to the audit is "we checked at signup," which is not the answer the auditor asked for. This guide walks through what auditors actually accept, the four tiers of evidence you can produce, and how to retain it correctly. ## What VAT validation evidence means under EU law EU VAT validation has two layers. The first is *existence*: confirming the number is registered. The second is *qualified confirmation*: confirming the number is registered AND that the buyer's company details match the records held by the issuing member state. The existence layer is established by Article 31 of [Council Regulation (EU) No 904/2010](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32010R0904), which obliges each member state to allow electronic confirmation by another member state's tax authority that a given VAT identification number is valid. This is the foundation of [VIES, the VAT Information Exchange System](/guides/vies-api-guide). The qualified-confirmation layer is German law. [§18e UStG](https://www.gesetze-im-internet.de/ustg_1980/__18e.html) requires sellers making VAT-free intra-EU supplies to obtain a *qualifizierte Bestätigungsmitteilung* (qualified confirmation) from the Bundeszentralamt für Steuern (BZSt) confirming both validity AND a per-field match on the buyer's name, street, postcode, and town. The qualified confirmation is the legal evidence that protects the seller's good-faith position if the buyer's VAT is later found to be fraudulent. Retention is set by domestic tax law. [§147 AO](https://www.gesetze-im-internet.de/ao_1977/__147.html) mandates ten years for German tax records. Most other EU member states require six to ten years; the longest applicable period applies to a seller doing business in multiple jurisdictions. The regulatory direction of travel is toward more frequent reference of this evidence, not less. The Council of the EU adopted the [VAT in the Digital Age (ViDA) package](https://www.consilium.europa.eu/en/press/press-releases/2025/03/11/taxation-council-adopts-vat-in-the-digital-age-package/) on 11 March 2025, with entry into force on 14 April 2025 and a phased rollout through January 2035. ViDA shifts cross-border B2B reporting from the current quarterly recapitulative statement toward near-real-time digital reporting, which means the validation evidence underlying each invoice is referenced directly by the reporting layer rather than reconstructed years later under audit pressure. VIES alone proves a number existed at the time of the call. The audit-grade story is VIES plus a retained record plus, for German sellers, the qualified confirmation. The rest of this guide covers what each layer looks like and how to produce it. ## The four tiers of VAT evidence Tax authorities accept four tiers of VAT validation evidence, escalating in rigour from no validation at all to per-field qualified confirmation. | Tier | What it is | What it proves | Accepted by | When you need it | |------|------------|----------------|-------------|------------------| | 0 | No validation | Nothing. The seller invoiced without checking the buyer's VAT number was real. | No tax authority. | Never acceptable for B2B intra-EU supplies. | | 1 | VIES check | The number existed in VIES at the time of the call. | Most EU tax authorities for low-risk B2B. | Default for any cross-border B2B invoice without elevated audit risk. | | 2 | VIES + retained audit log | Tier 1 plus an immutable record of the request, the response, the timestamp, and the source. | All EU tax authorities. Required when reconstructing past invoices for an audit. | Any seller who will face an audit covering past periods, which is every seller. | | 3 | BZSt §18e qualified confirmation | The number existed AND the buyer's company name, street, postcode, and town matched what the foreign member state has on file. | German tax authorities for §18e UStG good-faith protection. | German-resident sellers shipping or invoicing intra-EU without charging VAT. | Tier 1 is the floor and the most common default. [VIES is asynchronous, the SOAP service can be down for hours](/guides/handle-vies-downtime), and a *valid* response with no retained record proves nothing six months later. VIES results carry an official consultation number only when the seller's own VAT is supplied in the request (the `requester_vat_number` parameter on `/v1/validate`). Without that parameter, the call is anonymous and produces no consultation reference for the audit file. Tier 2 is what auditors actually want. The retained record contains the request payload, the full response body, the registry source (VIES, HMRC, BFS UID, Brønnøysund, BZSt), the timestamp at which the registry returned the answer, the latency, and the HTTP status. Retention runs for as long as domestic tax law requires, which in Germany is ten years. This tier maps to vatverify's `audit_log`. Tier 3 is German-specific. The BZSt qualified confirmation returns A/B/C/D match codes for each of name, street, postcode, and town. `A` means the field matches the foreign member state's records. `B` means it does not match. `C` means the field was not requested. `D` means the foreign member state does not return that field. A confirmation where every requested field returned `A` is the strongest available evidence under §18e UStG. The deep technical reference for this flow lives in the [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation). ## What tax auditors actually ask for An auditor reconstructing your past invoices will ask for every one of the following per validated VAT number. 1. **An immutable timestamp.** The exact ISO-8601 datetime when the registry returned its answer. Not the invoice date, not the order date, the validation moment. 2. **The source registry.** VIES, HMRC, BFS UID, Brønnøysund, or BZSt. An auditor reviewing a 2024 transaction needs to know which registry was queried in 2024, because registry schemas and behaviour change over time. 3. **A consultation or request identifier.** For VIES with a requester VAT, this is the `verify_id` returned by the registry. For BZSt this is the `bzst_id` returned in the qualified confirmation response. For HMRC, the consultation reference. The identifier proves the registry acknowledged a specific query. 4. **The exact request payload.** What VAT number was queried, with what country prefix, on what date. Reconstructing the request after the fact is harder than retaining it. 5. **Per-field match codes when §18e applies.** For German sellers, the A/B/C/D grid for name, street, postcode, town. A blanket `valid: true` boolean is insufficient evidence under §18e; the auditor wants the per-field grid. 6. **The full response body.** Not a summary. The auditor wants to see the company name, registered address, and validity dates exactly as the registry returned them. 7. **Retention duration matching local tax law.** For Germany, §147 AO mandates ten years. For most EU member states, six to ten years. Truncating logs after thirty days is a finding. 8. **Reproducibility.** If asked to re-query the same VAT number today, the result might differ because the company might have deregistered or moved. The retained record is the canonical evidence; live re-validation is supplementary. ## How vatverify produces each evidence layer vatverify produces each evidence tier through a specific endpoint or stored artifact. The full chain looks like this. ### `/v1/validate` produces tier 1 evidence A call to `/v1/validate` returns validity, the company name and registered address, the source registry, and a `verified_at` timestamp. When you supply your own VAT number as `requester_vat_number`, VIES upgrades the consultation to a *named* lookup and returns an official `verify_id` accepted by tax auditors as proof of a specific query. ```bash curl "https://api.vatverify.dev/v1/validate?vat_number=IE6388047V&requester_vat_number=DE100000001" \ -H "Authorization: Bearer $VATVERIFY_API_KEY" ``` The response includes `data.valid`, `data.company`, `data.country`, `data.verify_id`, `data.verified_at`, and a `meta.source` of `vies`, `hmrc`, `bfs`, `brreg`, or `bzst`. ### `audit_log` retention promotes tier 1 to tier 2 Every call to `/v1/validate`, `/v1/validate/batch`, and `/v1/decide` writes an immutable row to your audit log containing the request body, the response body, the registry source, the timestamp, the latency, and the HTTP status. The retrieval endpoint is `GET /v1/audits` with cursor pagination and filters on date range, endpoint, and the `valid` flag. Audit-log retention scales with plan: seven days on Starter, thirty days on Pro, ninety days on Business. For longer retention windows, archive the audit response into your own storage at the time of the call. The `request_id` returned in every response is the foreign key for cross-referencing. ### `/v1/confirm` produces tier 3 evidence for German sellers `POST /v1/confirm` is the German-specific endpoint. It applies to sellers established in [Germany](/check/germany) making VAT-free intra-EU supplies. The endpoint wraps BZSt's eVatR service and returns a *qualifizierte Bestätigungsmitteilung* with per-field A/B/C/D match codes for name, street, postcode, and town. Confirmations are retained for ten years independently of plan tier, matching the §147 AO retention requirement for §18e UStG legal evidence. The record remains retrievable by `confirmation_id` even after a plan downgrade. ```bash curl -X POST "https://api.vatverify.dev/v1/confirm" \ -H "Authorization: Bearer $VATVERIFY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 00000000-0000-4000-8000-000000000001" \ -d '{ "vat_number": "FR44732829320", "company": { "name": "Airbus SAS", "street": "2 Rond-Point Emile Dewoitine", "postcode": "31700", "town": "Blagnac" } }' ``` The response includes `data.qualified` (true when every requested field returned `A`), `data.matches` (the per-field grid), `data.confirmation_id`, and `meta.bzst_id` (BZSt's own request identifier, independent of vatverify's `request_id`). ### Retrieving evidence years later Two endpoints retrieve evidence after the fact. `GET /v1/audits` returns the audit-log trail filterable by date range and endpoint, scoped to the API key that produced the records. `GET /v1/confirmations/:id` returns a specific BZSt qualified confirmation by its `confirmation_id`. Confirmations remain readable for ten years from creation; audit-log entries remain readable for the plan-tier retention window from creation. ## ViDA: what changes from 2026 onward The Council of the EU adopted the [VAT in the Digital Age (ViDA) package](https://www.consilium.europa.eu/en/press/press-releases/2025/03/11/taxation-council-adopts-vat-in-the-digital-age-package/) on 11 March 2025. Entry into force was 14 April 2025, with a phased rollout through January 2035. The package has three parts. ### Digital reporting and e-invoicing Mandatory structured e-invoicing for cross-border B2B transactions in the EU. Upon entry into force on 14 April 2025, member states are able to introduce domestic mandatory e-invoicing under specific conditions without prior Commission authorisation. Cross-border digital reporting requirements take effect later in the rollout, ultimately replacing the current quarterly recapitulative statement with near-real-time data submission. For sellers, the implication is that the validation evidence underlying each invoice is referenced more directly by the reporting layer. Truncated audit logs that worked under quarterly reconciliations no longer work when the reporting cadence shrinks to per-invoice. ### Platform economy From 1 July 2028, platforms providing short-term accommodation rental and passenger transport services become deemed suppliers for VAT purposes in many cases, meaning the platform charges and remits VAT rather than the underlying provider. For platforms onboarding EU-resident providers, this shifts VAT validation from a provider responsibility to a platform responsibility. Validation at signup AND at each invoice becomes the operational default. ### Single VAT registration From 1 January 2027, an extended scope of the [One Stop Shop (OSS) and Import One Stop Shop (IOSS) schemes](/guides/oss-ioss-implementation-guide) reduces the need for foreign VAT registrations across multiple member states. Legislative clarifications for OSS and IOSS users take effect on the same date. The validation requirements themselves do not change with single VAT registration. More transactions flow through OSS reporting, which means more retained validation evidence is referenced by the reporting layer rather than less. Pre-built audit infrastructure with immutable timestamps, retained request bodies, source registry per call, and retrievability years later is what ViDA's reporting cadence assumes you have. Retrofitting under regulator pressure is harder than building it once at the right layer. ## Common mistakes that fail an audit Five mistakes show up repeatedly in VAT audit findings. Each is preventable with retained validation evidence. 1. **VIES-only validation for German-resident sellers.** §18e UStG requires a qualified confirmation from BZSt for VAT-free intra-EU supplies. A VIES-only check is insufficient evidence; the auditor will assess your good-faith protection on a per-customer basis without it. 2. **Not retaining the consultation number.** VIES returns a `verify_id` only when the seller's VAT is supplied in the request. Without that identifier retained alongside the response, you cannot prove which exact consultation the registry acknowledged. 3. **Truncating logs after thirty days.** §147 AO mandates ten years for German tax records. Most other EU member states require six to ten years. A thirty-day log retention period is below the floor for a German audit and well below it for cross-border activity. 4. **No per-field match capture for §18e cases.** The qualified confirmation returns A/B/C/D match codes per field. A `valid: true` boolean alone is insufficient evidence under §18e; the auditor wants the per-field grid. 5. **Validating once at signup, not per invoice.** A VAT number can be deregistered between signup and the next invoice. Validation evidence applies to the date of the transaction, not the date of the customer relationship. ## Audit-readiness checklist The shortest version of this guide. Save this list. - Validate every VAT number at the time of the transaction, not at signup. - Retain the request, response, timestamp, source registry, and consultation identifier. - Retain for ten years for German records. Six to ten years for most other EU member states. The longest applicable period applies. - For German-resident sellers making VAT-free intra-EU supplies, use BZSt §18e qualified confirmation (`POST /v1/confirm`), not VIES alone. - Capture per-field A/B/C/D match codes for every §18e call. - Test that you can retrieve a specific past validation by `request_id` or `confirmation_id`. If you cannot, the retention is theoretical. - Document your retention policy and link it from your customer-facing terms. - Re-validate when a customer's invoice details change. ## FAQ Quick answers to the questions that come up most often when teams start documenting VAT compliance evidence. ### How long do I need to retain VAT validation records? For German records, §147 AO mandates ten years. Most other EU member states require six to ten years. The longest applicable retention period applies; if you do business in multiple member states, retain to the longest. ### What is the difference between VIES and BZSt qualified confirmation? VIES tells you a VAT number exists. BZSt's qualified confirmation tells you the number exists AND the buyer's company name, street, postcode, and town match what the foreign member state has on file. Only BZSt returns the per-field A/B/C/D match grid required for §18e UStG good-faith protection. ### Does my SaaS company need §18e qualified confirmation? Only if you are a German-resident seller making VAT-free intra-EU supplies. If you are not in Germany, VIES validation through `/v1/validate` is sufficient. If you are in Germany and you invoice EU customers without charging VAT, §18e applies. ### Is a screenshot of VIES enough for a tax audit? No. Screenshots are not immutable, are not timestamped at the protocol level, and do not contain the consultation number. A retained API response with a `verify_id` and timestamp is the correct evidence form. ### What is a VAT consultation number? The official identifier VIES returns when you query with your own VAT number supplied as the requester. It uniquely identifies the consultation in the registry's records. Tax auditors accept it as proof of a specific query. ### When does ViDA take effect? The ViDA package was adopted by the Council on 11 March 2025 and entered into force on 14 April 2025, with phased rollout through January 2035. Specific provisions take effect on different dates: legislative clarifications for OSS and IOSS users on 1 January 2027, platform-economy rules for short-term accommodation and passenger transport on 1 July 2028. ### What is a `bzst_id` and why does it matter? It is the request identifier BZSt returns in every qualified confirmation response. Independent of vatverify's `request_id` and `confirmation_id`. Three immutable references attached to a single confirmation event strengthen the evidence chain. ### Can I produce audit evidence retroactively? Only if you retained the original validation. Re-querying VIES today does not prove what the registry returned six months ago, because companies deregister and addresses change. Retain at the time of the call. ### Does vatverify retain my validation history? Yes. Every call to `/v1/validate`, `/v1/validate/batch`, and `/v1/decide` is written to an immutable audit log with the request, response, timestamp, source registry, and HTTP status. Audit-log retention scales with plan up to ninety days. BZSt qualified confirmations created via `/v1/confirm` are retained for ten years independently of plan tier. ### Which countries require qualified confirmation? Germany, via §18e UStG, has a codified qualified-confirmation requirement and a registry endpoint (BZSt) that returns per-field match codes. Other member states accept VIES as the standard. For German-resident sellers making VAT-free B2B supplies, qualified confirmation is the legal evidence. ## Next steps Three places to go from here: - **Building?** See the [`/v1/confirm` API reference](/docs/api/confirmations/confirm) for BZSt §18e qualified confirmation, including request envelope, idempotency, and the four match codes. - **German seller?** The [§18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) walks through the request envelope, the per-field A/B/C/D codes, and the retry semantics for transient BZSt failures. - **Ready to start?** The [Business plan](/pricing) includes ten-year retention on `/v1/confirm` records and access to BZSt qualified confirmation. `/v1/validate` is free up to 500 requests per month for everything else. Source: https://www.vatverify.dev/guides/vat-compliance-evidence --- ## VAT validation webhooks: react to deregistrations and registry-status changes Wire up vatverify webhooks to catch B2B customer deregistrations, registry outages, and audit log events without polling. Includes signature verification and idempotency patterns. ## TL;DR - vatverify emits webhooks for `validation.completed`, `customer.deregistered`, `registry.status_changed`, and a few related events. - Each webhook is signed with HMAC-SHA256 over the raw body. Verify the signature before processing the event. - Idempotency: every event has a stable `id`. Persist processed IDs and skip duplicates; vatverify will retry on non-2xx responses. ## When webhooks earn their keep Polling `/v1/validate` once a month against a customer list catches deregistrations on a delay of up to a month. Webhooks reduce that delay to minutes or hours, which matters in two cases: - **High-value B2B**: a customer with a six-figure annual order volume deregistering between monthly audits creates audit risk for any zero-rated invoice issued in the gap. - **Registry outage handling**: the upstream country registry going `MS_UNAVAILABLE` is something your invoicing flow may need to react to in near-real-time, for example by pausing automatic invoice issuance to avoid retry storms. Webhooks are gated to the **Pro and Business plans**. Free and Starter receive `plan_required` on the webhook registration endpoint. ## Registering an endpoint Register your endpoint with a single POST. Each API key supports up to 5 active webhook endpoints; see [`webhook_limit_reached`](/errors/webhook-limit-reached) for the cap. ```bash curl https://api.vatverify.dev/v1/webhooks \ -H "Authorization: Bearer $VATVERIFY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://app.example.com/webhooks/vatverify", "events": [ "validation.completed", "customer.deregistered", "registry.status_changed" ] }' ``` The response includes a `signing_secret`. Store it. You will need it to verify incoming requests. ```json { "data": { "id": "wh_01H...", "url": "https://app.example.com/webhooks/vatverify", "events": ["validation.completed", "customer.deregistered", "registry.status_changed"], "signing_secret": "whsec_..." } } ``` ## Signature verification Every delivery includes two headers: - `Vatverify-Signature`: an HMAC-SHA256 hex digest over the request body, keyed by your endpoint's signing secret. - `Vatverify-Timestamp`: the Unix timestamp at delivery time. Reject requests where the timestamp is more than 5 minutes off. ```ts title="app/routes/webhooks.vatverify.ts" import crypto from 'node:crypto'; export async function POST(req: Request) { const signature = req.headers.get('vatverify-signature'); const timestamp = req.headers.get('vatverify-timestamp'); const body = await req.text(); if (!signature || !timestamp) { return new Response('missing signature', { status: 400 }); } const skew = Math.abs(Date.now() / 1000 - Number(timestamp)); if (skew > 300) { return new Response('timestamp skew', { status: 400 }); } const expected = crypto .createHmac('sha256', process.env.VATVERIFY_WEBHOOK_SECRET!) .update(`${timestamp}.${body}`) .digest('hex'); if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) { return new Response('bad signature', { status: 401 }); } const event = JSON.parse(body); await handleEvent(event); return new Response(); } ``` The signature covers `${timestamp}.${body}`, not just the body. This prevents replay attacks where an attacker re-sends an old signed payload with a fresh timestamp. ## Idempotency vatverify retries non-2xx responses on an exponential schedule for up to 24 hours. The same `event.id` may arrive multiple times. The simplest idempotency layer is a small table of processed IDs: ```ts async function handleEvent(event: { id: string; type: string; data: unknown }) { const seen = await db.processedWebhooks.findUnique({ where: { id: event.id } }); if (seen) return; await db.$transaction(async (tx) => { await tx.processedWebhooks.create({ data: { id: event.id, type: event.type } }); await routeEvent(tx, event); }); } ``` Wrap the dedup write and the actual side effect in a single transaction. Without a transaction you can crash between writing the dedup row and applying the side effect, leading to silently dropped events on the retry. ## Event shapes ### `validation.completed` Fires when a single-number `/v1/validate` call completes. Useful for stores that validate from the front-end (where you don't want to wait for the round-trip) and react to results server-side. ```json { "id": "evt_...", "type": "validation.completed", "created_at": "2026-04-28T10:15:32Z", "data": { "request_id": "req_...", "vat_number": "DE123456789", "valid": true, "country": "DE", "consultation_number": "WAPIAAAA..." } } ``` ### `customer.deregistered` Fires when vatverify's monitor detects that a previously-valid VAT number now returns invalid. This is the high-value event for ongoing B2B compliance: it means a customer's audit-trail position changed. ```json { "id": "evt_...", "type": "customer.deregistered", "created_at": "2026-04-28T10:15:32Z", "data": { "vat_number": "FR12345678901", "country": "FR", "previous_valid_at": "2026-03-15T00:00:00Z", "current_invalid_at": "2026-04-28T10:15:32Z", "reason": "deregistered" } } ``` The right reaction in your code is usually: 1. Flip the customer's tax-exempt flag back to `false`. 2. Notify the operations or finance team. 3. Pause automatic zero-rated invoice generation for that customer until manually reviewed. ### `registry.status_changed` Fires when an upstream country registry transitions between `available` and `degraded` (typically `MS_UNAVAILABLE` returns from VIES, or HMRC API failures). Useful for pausing high-volume validation flows during outages. ```json { "id": "evt_...", "type": "registry.status_changed", "created_at": "2026-04-28T10:15:32Z", "data": { "registry": "VIES", "country": "FR", "previous_status": "available", "current_status": "degraded", "since": "2026-04-28T10:14:55Z" } } ``` ## Local development Test deliveries in development with a tunnel (ngrok, Cloudflare Tunnel, or `vercel dev` with a public URL). Register a separate webhook endpoint for the test environment using a `vtv_test_` key; test-mode webhooks fire on test-mode events only and do not pollute the live event stream. A useful trick: vatverify exposes a `/v1/webhooks/{id}/replay` endpoint that re-delivers the last 100 events to the registered URL. Useful when you change your handler logic and want to re-process recent events without waiting for natural traffic. ## What this tutorial does not cover - **Outbound webhook from your own app to vatverify.** The webhook flow described here is vatverify pushing to you, not the other way around. Your app calls vatverify via REST when it needs validation. - **Async batch processing.** The batch endpoint (`/v1/validate/batch`) is synchronous. There is currently no batch-completed webhook; the batch returns its full result inline. ## Related guides - [Batch VAT validation tutorial](/guides/vat-batch-validation-tutorial) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Handle VIES downtime](/guides/handle-vies-downtime) - [`webhook_limit_reached` error reference](/errors/webhook-limit-reached) Source: https://www.vatverify.dev/guides/vat-webhooks-tutorial --- ## The VIES API guide: how EU VAT validation actually works How VIES (VAT Information Exchange System) works under the hood. SOAP/XML, rate limits, downtime patterns, and why wrapping it with vatverify is 10× simpler than hitting it directly. ## TL;DR - VIES is the authoritative EU VAT registry, operated by the European Commission. - It speaks SOAP/XML only. There is no REST endpoint. - 27 EU member states plus Northern Ireland (XI prefix) = 28 distinct registries behind one aggregator. - vatverify wraps all of it: REST in, structured JSON out, retries handled, downtime cached. `@vatverify/node` (used in the Node example below) ships on npm at the API launch. Reading this before launch? The REST endpoint is live today. Swap any `vat.validate({ vat_number })` call for `fetch('https://api.vatverify.dev/v1/validate?vat_number=...')` with an `Authorization: Bearer` header. The Python and cURL snippets on this page work today without any package install. ## What is VIES? VIES has been the Commission's public-facing gateway to member-state VAT registrations since the early 1990s. It was built to support intra-Community trade, specifically to let a seller confirm that a buyer's VAT number is valid before applying zero-rate (reverse-charge) treatment on a B2B cross-border invoice. Getting that confirmation wrong is an audit risk: if you zero-rate an invoice for a number that wasn't registered at the time, the VAT liability can land back on you. ## How VIES is structured VIES is not a database. It is an aggregator. When you send a `checkVat` request, the VIES gateway proxies the query to the national registry for that country code and relays the response. There is no unified schema: each country controls the format of its own response. Germany returns the company name and address as separate fields; Ireland returns a single combined address string; some countries return no name at all on certain number types. That inconsistency is one of the main reasons wrapping VIES is non-trivial. ## The SOAP/XML protocol VIES exposes a single SOAP 1.1 endpoint: ``` https://ec.europa.eu/taxation_customs/vies/services/checkVatService ``` A minimal `checkVat` request envelope looks like this: ```xml IE 6388047V ``` You POST this with `Content-Type: text/xml; charset=utf-8` and a `SOAPAction: ""` header. The response is also XML. You then parse the SOAP envelope, navigate to the `checkVatResponse` element, and extract `valid`, `name`, `address`, and `requestDate`. That is four steps before you have a usable value in your application. ## The response A successful response for the IE example above looks like: ```xml IE 6388047V 2026-04-27+01:00 true GOOGLE IRELAND LIMITED
3RD FLOOR, GORDON HOUSE, BARROW STREET, DUBLIN 4
``` Note that Ireland returns `name` and `address` as two distinct fields, but the address is a single concatenated string. Germany, by contrast, returns structured fields (`street`, `postcode`, `city`) in the address element. You need country-aware parsing logic to handle this correctly. ## Country coverage VIES covers all 27 EU member states: AT, BE, BG, CY, CZ, DE, DK, EE, EL (Greece), ES, FI, FR, HR, HU, IE, IT, LT, LU, LV, MT, NL, PL, PT, RO, SE, SI, SK Plus **Northern Ireland (XI)** under the post-Brexit Windsor Framework protocol. XI numbers are validated through VIES even though the rest of the UK (GB prefix) routes through the HMRC REST API. ## Rate limits VIES has no published rate limit in its documentation. In practice, the gateway starts returning HTTP 503 errors after roughly 60 concurrent requests from a single IP. The recommended sustained ceiling is **10 requests/second** per IP if you are hitting VIES directly. Every response should be cached. VIES data changes rarely. A company's registration status does not flip day to day. A 30-day cache TTL is conservative enough to stay accurate and aggressive enough to dramatically reduce your request volume. ## Downtime patterns VIES has two classes of downtime: **Scheduled maintenance:** Mondays between 03:00–05:00 UTC. The gateway returns a `SERVICE_UNAVAILABLE` fault during this window. Plan your batch jobs around it. **Country-specific outages:** Individual member-state registries go down independently of the VIES gateway itself. Spain (ES), Germany (DE), and France (FR) have historically had the worst per-country uptime. When a country registry is unavailable, VIES returns a `MS_UNAVAILABLE` fault code rather than a proper `checkVatResponse`. vatverify handles both: if the upstream registry is degraded, the API returns `meta.source_status: "degraded"` alongside a cached result, so your application still gets a usable response instead of an exception. ## Ireland gotchas Ireland's VIES responses have a couple of quirks worth knowing: - The `address` field is a single string (street, city, and postcode concatenated), not structured sub-fields. - Legal names containing special characters (é, ß, Ø, ü) come through in UTF-8 and must be handled as such. If your XML parser defaults to ISO-8859-1, you will get mojibake. vatverify normalizes address structure and character encoding before returning results. ## VAT number format per country Each country uses a different prefix and format. Here is a sample of the most common ones: | Country | Prefix | Format | Regex | |---------|--------|--------|-------| | Austria | AT | U + 8 alphanumeric | `^ATU[0-9]{8}$` | | Belgium | BE | 10 digits | `^BE[01][0-9]{9}$` | | Germany | DE | 9 digits | `^DE[0-9]{9}$` | | France | FR | 2 alphanum + 9 digits | `^FR[A-Z0-9]{2}[0-9]{9}$` | | Spain | ES | 9 alphanumeric | `^ES[A-Z0-9][0-9]{7}[A-Z0-9]$` | | Italy | IT | 11 digits | `^IT[0-9]{11}$` | | Netherlands | NL | 12 alphanumeric | `^NL[0-9]{9}B[0-9]{2}$` | | Poland | PL | 10 digits | `^PL[0-9]{10}$` | Full format reference including all 28 VIES countries is at [/docs/api](/docs/api). ## Wrapping VIES with vatverify Handling VIES directly means: writing an XML serializer, a SOAP client, response parser, retry logic, exponential backoff, a cache layer, and monitoring. The vatverify equivalent is: ```typescript title="validate.ts" import { Vatverify } from '@vatverify/node'; const vat = new Vatverify(process.env.VATVERIFY_API_KEY!); const result = await vat.validate({ vat_number: 'IE6388047V' }); console.log(result.data.valid, result.data.company?.name); ``` That is it. The SOAP envelope, parsing, retries, and caching happen inside the SDK. ```python title="validate.py" import os import httpx response = httpx.get( "https://api.vatverify.dev/v1/validate", params={"vat_number": "IE6388047V"}, headers={"Authorization": f"Bearer {os.environ['VATVERIFY_API_KEY']}"}, ) result = response.json() print(result["data"]["valid"], (result["data"].get("company") or {}).get("name")) ``` Or with cURL if you just need a quick check: ```bash curl "https://api.vatverify.dev/v1/validate?vat_number=IE6388047V" \ -H "Authorization: Bearer $VATVERIFY_API_KEY" ``` ## Direct VIES vs vatverify | Feature | Direct VIES | vatverify | |---------|------------|-----------| | Protocol | SOAP/XML | REST/JSON | | Caching | None (you build it) | 30-day automatic | | Retries | None (you build it) | Automatic with backoff | | Error codes | SOAP faults (string) | Structured JSON errors | | Sustained throughput | ~60 req/sec before 503 | Unbounded (cached) | | Downtime handling | Your problem | `meta.source_status: "degraded"` fallback | | Monitoring | None | Vercel observability dashboard | | Response schema | Country-inconsistent XML | Normalized JSON every time | ## FAQ ### Can I call VIES directly without vatverify? Yes. The endpoint is public. But you take on the SOAP client, XML parsing, retry logic, rate-limit handling, cache layer, monitoring, and country-specific response normalization yourself. For a prototype that is fine; for production that is a non-trivial ops surface. ### Does VIES validate check-digits? No. VIES only verifies that a number is in the national registry. It does not run checksum algorithms. A structurally valid number that fails its country's checksum algorithm will still return `valid: true` from VIES if it happens to exist in the registry. Use `@vatverify/vat-rates` for offline format and checksum validation before you send requests upstream. ### Is VIES free? Yes. The European Commission provides VIES at no cost. You pay in engineering time to handle the protocol, reliability, and normalization, not in money. ### How often is VIES data updated? It depends on the member state. Some countries push real-time changes; others batch-update overnight (T+1). Most countries are within 24 hours of the source of truth. France and Germany tend to be close to real-time; some smaller registries update once a day. ### What happens on `MS_UNAVAILABLE`? The country registry is down. VIES returns a SOAP fault with code `MS_UNAVAILABLE`. vatverify intercepts this, serves the last cached result with `meta.source_status: "degraded"` if one exists, and otherwise returns a clean HTTP 502 with a `registry_unavailable` error code rather than an unhandled exception. ## Related guides - [HMRC VAT check API](/guides/hmrc-vat-check-api) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Validate VAT in Python](/guides/validate-vat-number-python) - [Offline VAT validation library](/guides/offline-vat-validation-library) - [Open-source VAT libraries](/libraries) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/vies-api-guide --- ## What is a VAT number? A practical guide for developers and businesses A VAT number is the identifier a national tax authority issues to a business once it is registered for VAT. Inside the EU it is country-prefixed and validatable through VIES. Includes format details, country examples, and how to validate one programmatically. ## TL;DR - A VAT number is a country-specific identifier issued to a business that is registered for value-added tax. - Inside the EU, VAT numbers carry an ISO 3166 country prefix (with two intentional exceptions: `EL` for Greece, `XI` for Northern Ireland) and are validatable through VIES. - Outside the EU, each country has its own registry: HMRC for the UK, BFS for Switzerland and Liechtenstein, BRREG for Norway. - Format varies dramatically by country. A regex that hardcodes a single length is wrong for several jurisdictions. ## What a VAT number actually is A VAT number is the identifier a national tax authority assigns to a business when that business registers for value-added tax. The number is the legal handle for the VAT account. It is what appears on invoices, what tax authorities exchange when validating cross-border supplies, and what an audit trail references when challenging the VAT treatment of a transaction. VAT numbers serve three practical functions in modern e-commerce and B2B systems: 1. **Invoice compliance.** EU rules require both the supplier's and the buyer's VAT number to appear on a B2B invoice (Article 226 of the VAT Directive 2006/112/EC). Without the buyer's VAT number, the supplier cannot zero-rate an intra-Community supply and must charge local VAT instead. 2. **Place-of-supply determination.** For B2B services in the EU, place of supply follows the buyer. The buyer's VAT number is the evidence that the buyer is a taxable person, which is what unlocks the place-of-supply rule. 3. **Reverse-charge eligibility.** Cross-border B2B sales between EU member states use the reverse-charge mechanism: the seller invoices net of VAT and the buyer self-accounts. A valid, registered buyer VAT number is the trigger that qualifies the sale. A VAT number is not a company registration number. It is a separate identifier issued by a different authority (in most countries the tax administration, not the corporate registry). The two often look similar, sometimes share digits, but they serve different purposes. ## EU format conventions Inside the EU, every VAT number has a 2-character country prefix followed by a country-specific body. The prefix follows ISO 3166-1 alpha-2 with two intentional deviations: - **EL** for Greece (not `GR`). Derived from "Hellas". - **XI** for Northern Ireland (not part of ISO 3166). Created post-Brexit for Northern Ireland goods trade with the EU under the Windsor Framework. A validator that recognizes only ISO 3166 codes will reject correctly-formatted Greek and Northern Irish numbers. The body of the number varies dramatically. A few examples to make the variation concrete: | Country | Format | Example | Notes | |---|---|---|---| | Germany | `DE` + 9 digits | `DE123456789` | Distinct from the domestic *Steuernummer* | | France | `FR` + 2 alphanumeric + 9 digits | `FRK7123456789` | The 2-character key can include letters | | Italy | `IT` + 11 digits | `IT12345678901` | Partita IVA, distinct from Codice Fiscale | | Netherlands | `NL` + 9 digits + `B` + 2 digits | `NL123456789B01` | The literal `B` is fixed | | Cyprus | `CY` + 8 digits + 1 letter | `CY12345678X` | The trailing letter is part of the canonical form | | Czechia | `CZ` + 8, 9, or 10 digits | `CZ12345678` | Length varies by registrant type | A regex that hardcodes a single length will reject correctly-formed numbers from several member states. See the [country pages](/check) for the format and structural rules per country. ## Outside the EU Three European jurisdictions outside the EU have their own VAT registries: - **United Kingdom (`GB`)**: 9 or 12 digits, validated against HMRC. The 12-digit form is a branch trader number (9-digit root plus 3-digit branch suffix). Post-Brexit, GB numbers are no longer queryable through VIES. - **Switzerland (`CHE-`)**: validated against the Swiss Federal Statistical Office (BFS) UID register. The format is `CHE-xxx.xxx.xxx` with dots; an optional `MWST` / `IVA` / `TVA` suffix indicates linguistic region. - **Liechtenstein**: shares the Swiss `CHE-` prefix and BFS register under a 1923 customs and VAT union. Liechtenstein businesses are structurally indistinguishable from Swiss ones at the number level; the jurisdiction is inferred from the registered business address. - **Norway (`NO`)**: 9 digits validated against the Brønnøysund Register (BRREG), with an optional `MVA` suffix indicating VAT-registered status. VAT numbers from non-EU European countries are never in VIES. A VIES-only validator returns "unknown country" for every UK, Swiss, Liechtenstein, or Norwegian number. ## VAT number vs other tax identifiers The most common source of confusion in B2B onboarding flows: customers supplying the wrong identifier when asked for their VAT number. A few examples: - A German customer often supplies their *Steuernummer* (domestic income-tax identifier) instead of the EU-facing *USt-IdNr.* See the [Steuernummer glossary](/glossary/steuernummer) for the difference. - An Italian customer may supply their *Codice Fiscale* (16-character alphanumeric identifier for income tax and social administration) instead of their *Partita IVA*. The two are different formats. - A French customer may supply their *SIREN* (9-digit corporate identifier) instead of the full TVA intracommunautaire (`FR{key}{SIREN}`). The SIREN is part of the VAT number but is not itself the VAT number. A robust onboarding flow validates the format first. A structurally-malformed input is almost always one of these adjacent identifiers; rejecting at the format check produces a clearer error message than letting it round-trip to VIES and come back as `invalid`. ## How VAT number validation works Validation has two layers: 1. **Format validation** runs offline against a regex and (for most countries) a check-digit algorithm. This catches typos and misplaced identifiers without any registry call. It is fast (microseconds) and free. 2. **Registration validation** queries the issuing country's registry to confirm that the number is currently registered. This requires a network call, takes seconds, and the registries have outages. For most use cases, the structurally-valid-but-not-registered case is the interesting one. A number that is structurally valid means the buyer probably gave you something real; whether it is currently registered is what changes by the day. vatverify's `/v1/validate` does both checks in a single call. A typical response: ```json { "data": { "vat_number": "DE123456789", "valid": true, "country": "DE", "company": null, "consultation_number": "WAPIAAAA1234..." }, "meta": { "request_id": "req_...", "fetched_at": "2026-04-28T10:15:32Z", "source": "vies" } } ``` The `consultation_number` is a VIES-issued reference identifier that proves the lookup happened. It is the audit-trail artifact that protects a seller's good-faith position if a tax authority later challenges a zero-rated invoice. See the [consultation number glossary](/glossary/consultation-number) for the audit-trail mechanic. ## Three identifiers worth knowing Beyond the basic VAT number, three related identifiers come up in EU compliance: - **OSS ID**: a single-stop-shop registration for B2C distance sales of goods and digital services across the EU. Format follows the home member state's normal registration scheme; reported through OSS returns rather than per-country VAT returns. - **IOSS ID**: a 12-character identifier starting with `IM` (e.g. `IM1234567890`). Used for distance sales of imported goods up to €150 to EU consumers. Issued separately from a regular VAT number. - **EORI number**: the EU's economic operator registration identifier for customs purposes. Different from VAT but often appears on the same B2B forms. OSS and IOSS IDs are not validated through VIES. EORI validation is a separate EU service (`ec.europa.eu/taxation_customs/dds2/eos/`). ## Common questions ### Is a VAT number the same as a tax ID? Loosely yes, strictly no. A "tax ID" is a generic term that covers any identifier a tax authority issues. A VAT number specifically refers to the VAT registration. A business may have a VAT number and a separate income-tax-only identifier; the two are not interchangeable. ### Can I derive a VAT number from a company registration number? Sometimes yes, sometimes no, depending on country. In Czechia, the legal-entity DIČ is `CZ` plus the IČO (corporate registration number). In Belgium, the VAT number and the CBE/KBO number share the same 10-digit body. In France, the SIREN is embedded in the TVA number with a 2-character key prepended. In Germany, the USt-IdNr. and the Handelsregister number are unrelated. The general rule: do not infer a VAT number from a corporate registration number programmatically. Ask the customer for the VAT number explicitly. ### How often does a VAT number change? Rarely. A business's VAT number is stable across most of its lifetime. A number can become invalid for three reasons: deregistration (revenue dropped below threshold, business closed or restructured), administrative deregistration (tax authority pulled registration for compliance reasons), or a major restructuring that issues a new entity. Annual or quarterly re-validation of B2B customer lists is the typical cadence. ### What happens at checkout if the VAT number is invalid? For a cross-border B2B sale, an invalid VAT number means you cannot apply reverse-charge. The seller has to either reject the order or charge their domestic VAT rate on it. Many B2B checkouts simply block the order and surface the error to the customer, who can then correct the number or contact support. ## Related guides - [VIES API guide](/guides/vies-api-guide) - [HMRC VAT check API](/guides/hmrc-vat-check-api) - [EU VAT reverse charge API](/guides/eu-vat-reverse-charge-api) - [Validate VAT in Node.js](/guides/validate-vat-number-nodejs) - [Country pages: 32 VAT systems explained](/check) - [EU VAT rates 2026](/eu-vat-rates) Source: https://www.vatverify.dev/guides/what-is-a-vat-number --- ## WooCommerce VAT validation: a custom plugin pattern Add proper VAT validation to a WooCommerce EU store. Hook into woocommerce_after_checkout_validation, validate via vatverify, reject invalid B2B VAT. ## TL;DR - A small WordPress plugin hooks `woocommerce_after_checkout_validation` and calls vatverify on the submitted VAT number. - Invalid VAT numbers get an inline WooCommerce error before the order is created. - A WP transients layer caches results locally for 24 hours, on top of vatverify's 30-day VIES cache. ## The WooCommerce EU VAT landscape WooCommerce has a handful of plugins aimed at EU VAT: EU VAT Assistant, YITH WooCommerce EU VAT, and a few others. They're useful for collecting VAT numbers at checkout and applying zero-rating for B2B customers. The gap is validation quality: most of them check format only, or call VIES directly (which is slow and fails during the regular VIES outages). Rolling a small custom plugin (or extending an existing one with a vatverify call) gives you live registration validation with caching, without coupling your checkout to VIES reliability. With OSS and IOSS in play for EU B2C digital sales, you also need to be precise about when reverse-charge applies (cross-border B2B) versus when you collect destination VAT (B2C). This plugin pattern focuses on the B2B side. ## Why vatverify over existing WP plugins Most existing WooCommerce VAT plugins share the same weakness: they either validate format only (EU VAT Assistant does regex + basic checksum) or they make a direct VIES SOAP call during checkout. A direct VIES call adds 1–3 seconds of latency to checkout on a good day, and blocks entirely when VIES is down (which happens regularly, especially around month-end). vatverify proxies VIES with a 30-day cache: fast, resilient, and returns structured data (company name, address, registration status) that you can store on the order. ## Plugin skeleton Create `wp-content/plugins/vatverify-for-woo/vatverify-for-woo.php`: ```php 'string', 'sanitize_callback' => 'sanitize_text_field', ] ); } public function add_settings_page(): void { add_options_page( 'VAT Verify Settings', 'VAT Verify', 'manage_options', 'vatverify-settings', [ $this, 'render_settings_page' ] ); } public function render_settings_page(): void { ?>

VAT Verify Settings

Get your key at vatverify.dev.

call_vatverify( $vat_number ); if ( is_wp_error( $result ) ) { // vatverify unreachable: fail open with a flag for manual review // or change to wc_add_notice( ..., 'error' ) to fail closed wc_get_logger()->warning( 'vatverify unreachable: ' . $result->get_error_message(), [ 'source' => 'vatverify-for-woo' ] ); return; } if ( ! $result['valid'] ) { $error_detail = implode( ', ', $result['errors'] ?? [ 'not registered' ] ); $errors->add( 'vat_invalid', sprintf( /* translators: %s: error detail */ __( 'VAT number is not valid: %s. Please check and try again.', 'vatverify-for-woo' ), esc_html( $error_detail ) ) ); } } private function call_vatverify( string $vat_number ): array|\WP_Error { $api_key = get_option( 'vatverify_api_key', '' ); if ( empty( $api_key ) ) { return new \WP_Error( 'no_api_key', 'vatverify API key not configured.' ); } // Check WP transient cache first (24-hour local cache) $cache_key = 'vatverify_' . md5( $vat_number ); $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } $response = wp_remote_get( add_query_arg( [ 'vat_number' => $vat_number ], 'https://api.vatverify.dev/v1/validate' ), [ 'timeout' => 5, 'headers' => [ 'Authorization' => 'Bearer ' . $api_key, 'Accept' => 'application/json', ], ] ); if ( is_wp_error( $response ) ) { return $response; } $status = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status !== 200 || ! is_array( $body ) ) { return new \WP_Error( 'api_error', "vatverify returned HTTP $status" ); } // Cache for 24 hours (vatverify itself caches for 30 days) set_transient( $cache_key, $body, DAY_IN_SECONDS ); return $body; } } new Vatverify_For_WooCommerce(); ``` ## OSS / IOSS notes If you have OSS (One Stop Shop) registration, EU B2C digital sales still require you to collect and remit VAT at the destination country's rate. The plugin's EU-country check correctly skips validation for orders without a VAT number (B2C), letting your OSS-aware tax plugin handle those. B2B reverse-charge still applies for cross-border transactions where `billing_vat_number` is present and valid. The plugin does not currently make a `/v1/decide` call. It validates only. If you need reverse-charge logic baked into the WooCommerce flow, extend `validate_vat_number` to also call `https://api.vatverify.dev/v1/decide` and store the mechanism as order meta. ## Testing via WP-CLI / Playwright Smoke-test the plugin with WP-CLI to trigger the hook directly: ```bash # Install and activate wp plugin activate vatverify-for-woo # Set the API key (test-mode key) wp option update vatverify_api_key vtv_test_xxx # Verify the option was saved wp option get vatverify_api_key ``` For end-to-end checkout testing with Playwright: ```typescript title="tests/checkout-vat.spec.ts" import { test, expect } from '@playwright/test'; test('invalid VAT shows inline error at checkout', async ({ page }) => { await page.goto('/checkout'); await page.fill('#billing_vat_number', 'DE000000000'); await page.fill('#billing_country', 'DE'); await page.click('button[name="woocommerce_checkout_place_order"]'); await expect( page.locator('.woocommerce-error li') ).toContainText('VAT number is not valid'); }); test('valid VAT passes checkout validation', async ({ page }) => { await page.goto('/checkout'); await page.fill('#billing_vat_number', 'DE811805056'); await page.fill('#billing_country', 'DE'); // No VAT error should appear await expect( page.locator('.woocommerce-error') ).not.toBeVisible(); }); ``` ## Caching The plugin sets a 24-hour WP transient on each validated VAT number. vatverify's own VIES cache is 30 days. Within a 24-hour window, repeat checkouts by the same customer hit your local transient, and after 24 hours they hit vatverify's cache, both free and fast. The only live VIES call happens when a VAT number is seen for the first time and vatverify's cache is also cold. ```php // Cache key is an MD5 of the VAT number, predictable but not guessable $cache_key = 'vatverify_' . md5( $vat_number ); // Read from transient $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; // Cache hit: zero API calls } // ... call API ... // Store for 24 hours set_transient( $cache_key, $body, DAY_IN_SECONDS ); ``` For high-traffic stores, you can reduce transient churn by extending to 48 hours (`2 * DAY_IN_SECONDS`). vatverify's 30-day cache ensures the underlying data stays accurate. ## FAQ ### Do I need a PHP SDK? No. The plugin uses `wp_remote_post`, which is available in every WordPress installation. A vatverify PHP SDK is on the roadmap, but until then the raw HTTP approach shown above is the idiomatic WordPress way. It respects the WordPress HTTP API, timeout settings, and proxy configuration. ### What about existing plugins like EU VAT Assistant? EU VAT Assistant and similar plugins do format validation. They check the country prefix and basic structure of the VAT number. They do not verify live registration against VIES. You can combine them with vatverify: let the existing plugin handle the checkout field UI and zero-rating logic, and add a vatverify hook for the live registration check. The `woocommerce_after_checkout_validation` hook fires independently of what other plugins do with the field. ### Will this work with Divi / Elementor checkout overrides? Yes. The `woocommerce_after_checkout_validation` hook is a WooCommerce core hook that fires during order submission processing, not during UI rendering. Page builder checkout overrides change what the form looks like, not how WooCommerce processes the submitted data. As long as the VAT field has the `billing_vat_number` name attribute, the hook fires. ### How do I store the valid-VAT flag on the order? Add it as order meta in the same hook, after validation passes: ```php // Inside validate_vat_number(), after confirming $result['valid'] is true: add_action( 'woocommerce_checkout_create_order', function( $order ) use ( $vat_number, $result ) { $order->update_meta_data( '_vat_number', $vat_number ); $order->update_meta_data( '_vat_valid', '1' ); $order->update_meta_data( '_vat_company_name', $result['company']['name'] ?? '' ); } ); ``` For classic (non-HPOS) orders you can also use `update_post_meta( $order_id, '_vat_valid', '1' )` in `woocommerce_checkout_order_created`. ### What about High-Performance Order Storage (HPOS)? With HPOS enabled, orders are stored in custom tables rather than `wp_posts`. Use `$order->update_meta_data( '_vat_valid', '1' )` followed by `$order->save()` instead of `update_post_meta`. The `woocommerce_checkout_create_order` hook passes the order object directly, so the code above works for both classic and HPOS storage without changes. ## Related guides - [Stripe VAT validation](/guides/stripe-vat-validation) - [Mollie VAT validation](/guides/mollie-vat-validation) - [EU VAT reverse-charge API](/guides/eu-vat-reverse-charge-api) - [Validate VAT in Laravel](/guides/validate-vat-number-laravel) - [Open-source VAT libraries](/libraries) - [Webhooks](/features/webhooks) - [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/woocommerce-vat-validation --- ## EU national VAT databases explained: fallback paths when VIES is down How the 27 member-state VAT registries sit behind VIES, which national databases expose direct public endpoints, and where the tradeoffs around using them directly actually land. ## Key facts - VIES is an aggregator in front of 27 national VAT registries plus Northern Ireland. When the gateway is down or a specific country returns `MS_UNAVAILABLE`, the only way to hit fresh data is the national registry itself. - Only a handful of member states publish direct public APIs (notably Italy's Agenzia delle Entrate, Germany's BZSt confirmation endpoint, and the Czech ARES register). Most states expose only a web form and treat VIES as the sole programmatic surface. - National registries frequently carry richer metadata than VIES returns (structured addresses, legal form, registration date, status transitions). Querying a national endpoint sometimes means upgrading the response, not downgrading it. - The Monday-morning maintenance window (roughly 03:00 to 05:00 UTC) trips all VIES traffic at once; a warm cache plus a sensible degraded-mode posture is what smooths that hour for callers that cannot wait. - vatverify's current approach leans on the 30-day VIES cache and a seven-day fallback window for degraded responses. We do not run parallel national-registry integrations for the EU-27 today. ## What the registry is "EU national VAT databases" is a loose category because the 27 member states do not share a single architecture. Each country's tax authority runs its own register: Agenzia delle Entrate (Italy), Agencia Tributaria (Spain), Direction Générale des Finances Publiques (France), the Bundeszentralamt für Steuern or BZSt (Germany), ARES (Czech Republic, a cross-register index run by the Ministry of Finance), and so on. VIES exists precisely because this heterogeneity would otherwise force every cross-border invoicer to integrate twenty-seven separate APIs. The legal framing: Council Regulation (EU) No 904/2010 on administrative cooperation for VAT obliges member states to expose their registration data through VIES. It does not oblige them to expose a public national API, which is why direct fallbacks only exist for countries that chose to publish one. A short tour of what is actually available: - **Italy (Agenzia delle Entrate).** A public web service for partita IVA validation, older but functional, returns the business name and registration status. - **Germany (BZSt confirmation endpoint).** `Bestätigungsverfahren` for German businesses confirming EU counterparties; effectively a VIES proxy, so not a true independent fallback, but useful for the `requestIdentifier` audit record. - **Czech Republic (ARES).** A rich cross-register index covering company, VAT, trades, and insolvency status. Not strictly a VAT registry but the VAT flag is present and the public JSON endpoint is generous. - **Netherlands (KVK, Kamer van Koophandel).** Company register with the VAT flag, behind a paid API key with tiered access. Not a pure fallback, but useful for enriched company data. - **Poland (Biała Lista, the White List).** Maintained by the Ministry of Finance, publicly accessible, returns VAT status plus sanctioned bank accounts. Required for Polish-domestic split-payment scenarios. - **France, Spain, Belgium, and most others**: VIES is the only public API. The national register has a web form for humans, no programmatic access. Northern Ireland sits inside VIES (prefix `XI`) under the Windsor Framework; it has no independent EU-level fallback because HMRC owns the underlying data and exposes it through its own UK-only API. ## What the API returns Because each national endpoint is different, the returned shape is endpoint-specific. A sketch of three common ones gives the flavour: Italy (Agenzia delle Entrate, SOAP): ```xml 12345678901 P Esempio S.R.L. 2017-03-01 ``` Czech ARES (REST JSON): ```json { "ico": "12345678", "dic": "CZ12345678", "obchodniJmeno": "Priklad s.r.o.", "sidlo": { "ulice": "Vaclavske namesti", "cisloDomovni": "1", "obec": "Praha", "psc": "11000" }, "pravniForma": "112", "datumVzniku": "2015-01-15", "seznamRegistraci": { "stavZdrojeVr": "AKTIVNI" } } ``` Poland Biała Lista (REST JSON): ```json { "result": { "subject": { "name": "Przyklad sp. z o.o.", "nip": "1234567890", "statusVat": "Czynny", "registrationLegalDate": "2014-05-12", "workingAddress": "ul. Przykladowa 1, 00-001 Warszawa" }, "requestId": "aa-bbbbbb-cccccc" } } ``` The common thread is that national responses tend to include **more** than VIES does: the registration date, the legal form, structured address components, sometimes a status code that distinguishes active from suspended from deregistered. The tradeoffs: national endpoints are less uniform, less predictable, and in some cases require credentials or paid access. Building a fallback strategy means picking which countries justify the integration work, and which are fine to leave on VIES-only with a graceful-degradation posture. ## Rate limits and quirks There is no single answer because every national registry sets its own limits. - **Italy's Agenzia delle Entrate** is loosely rate-limited and can return intermittent 500s under load. Back off aggressively. - **Czech ARES** publishes a fair-use limit (historically around one request per second per IP for the free tier) and is strict about it. - **Polish Biała Lista** allows a published burst but blacklists IPs that scrape in tight loops. - **German BZSt Bestätigungsverfahren** acts as a VIES passthrough; it shares all of VIES's rate-limit characteristics and adds a requirement that the caller is itself a German VAT-registered entity. - **Dutch KVK** is credential-gated and metered by subscription tier. Common failure modes across the board: cold-cache latencies of 1 to 3 seconds, HTML error pages returned inside a 200 response when the upstream application tier is confused, and character-encoding drift (Latin-1 leaking into what should be UTF-8 responses). ## How vatverify handles it For the 27 EU member states plus `XI`, vatverify treats VIES as the single source of truth and leans on its cache to absorb VIES's bad hours. The order, in plain terms: 1. **Normalise and format-check.** Uppercase the prefix, strip spaces and punctuation. Reject obviously malformed input as [`invalid_format`](/errors/invalid-format) before any network call. Greek numbers are expected to arrive with the VIES-native `EL` prefix. 2. **Cache lookup.** Upstash Redis, keyed per registry and number. Freshness is 30 days on valid results and 24 hours on invalids, with a seven-day fallback window past freshness used to serve degraded responses when VIES is unreachable. Fresh cache hits return in under 20 ms. 3. **Primary upstream: VIES.** 10-second timeout. On a clean response, cache and return. 4. **Soft-fail path.** If VIES returns `MS_UNAVAILABLE`, `SERVICE_UNAVAILABLE`, or times out, check whether any cached value exists for this number (including entries past the freshness window but still inside the fallback window). If one does, serve it with `meta.source_status: "degraded"`. If nothing is cached, return [`registry_unavailable`](/errors/registry-unavailable) with a 502 status. Country-specific checksum helpers ship separately in [`@vatverify/vat-rates`](https://www.npmjs.com/package/@vatverify/vat-rates) for clients that want to reject typos offline before calling the API. We deliberately do not maintain parallel integrations into 27 national registries. Most of them sit behind credentials, paid tiers, or fragile SOAP endpoints, and running two codepaths per country would add more downtime surface than it removes. The VIES cache plus the fallback window does the heavy lifting; genuinely-cold numbers during a VIES outage surface a structured `registry_unavailable` and a retry hint rather than a second-best data source. The caching posture is deliberately generous: a VAT registration does not flip day to day, 30 days of validity is well within the tolerance band for almost every commercial use case, and the cache is the single biggest lever against every form of upstream instability, whether that is a VIES outage or a national backend crashing during tax season. For audit-grade verification, pass `requester_vat_number` on the `/v1/validate` request. The call routes through VIES's authenticated consultation path and stores the returned `requestIdentifier` as `verify_id` in the response. That is the [consultation number](/glossary/consultation-number) to keep on file. ## Gotchas worth knowing - **VIES and national registries disagree more often than you would expect.** A number can show `valid: true` on VIES and `valid: false` on the national endpoint (or vice versa) during the 24-hour propagation window after a status change. Treat VIES as source of truth for EU cross-border invoicing (that is what Council Regulation 904/2010 says); use national data for enrichment. - **Fallback is not a free performance upgrade.** Direct national APIs are usually slower and less reliable than VIES on a good day. The fallback exists for VIES's bad days, not its baseline. - **Do not double-bill yourself.** If you run VIES and a national registry in parallel for redundancy, both count against your infrastructure budget even when the second call is discarded. Sequential fallback keeps costs sane. - **The `requestIdentifier` only comes from VIES.** A national-database fallback cannot produce a VIES consultation number. For audit-critical flows, wait for VIES to come back rather than accept a fallback result that is not audit-grade. ## See the API reference - API reference: [`GET /v1/validate`](/docs/api/validation/validate-vat) - Glossary: [VIES](/glossary/vies), [consultation number](/glossary/consultation-number), [checksum](/glossary/checksum), [VAT number](/glossary/vat-number) - Error surface: [`registry_unavailable`](/errors/registry-unavailable), [`country_unsupported`](/errors/country-unsupported), [`invalid_format`](/errors/invalid-format) - Related guides: [Handle VIES downtime](/guides/handle-vies-downtime), [VIES explained](/guides/registries/vies-explained), [HMRC VAT API](/guides/registries/hmrc-vat-api-explained), [Swiss BFS explained](/guides/registries/swiss-bfs-zefix-explained), [Norwegian brreg explained](/guides/registries/norwegian-brreg-explained) Source: https://www.vatverify.dev/guides/registries/eu-national-databases-explained --- ## HMRC VAT API explained: UK validation after Brexit How the HMRC Check a UK VAT Number endpoint works, the OAuth 2.0 flow behind it, sandbox vs production access, and the quirks of the 97-55 checksum. ## Key facts - HMRC is the sole authoritative registry for GB-prefixed VAT numbers since 1 January 2021. Northern Ireland (prefix `XI`) stayed in the EU VAT area and routes through VIES. - The endpoint is a proper REST API: `GET https://api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup/{vrn}`, JSON in, JSON out. - Authentication is OAuth 2.0 client credentials, not an API key. Tokens are bearer-type and expire (four hours in production, shorter in sandbox). - Production access requires a manual application review on HMRC's Developer Hub; budget roughly ten working days. - The UK [VRN](/glossary/hmrc-vrn) format is `GB` + 9 digits, optionally suffixed with a 3-digit branch code, and validates with the HMRC 97-55 checksum algorithm. ## What the registry is HMRC (His Majesty's Revenue and Customs) is the UK tax authority. Before Brexit, UK VAT numbers were accessible through VIES like any other member-state number. On 1 January 2021 that routing stopped for `GB` numbers. HMRC took over as the only public source of truth for UK VAT registration. Northern Ireland is the exception: under the Windsor Framework (which replaced the Northern Ireland Protocol in 2023), `XI` numbers remain in scope for EU VAT on goods and are still listed in VIES. The endpoint we use is part of HMRC's **Check a UK VAT Number** API, which in turn sits inside the broader Making Tax Digital (MTD) programme. MTD is HMRC's long-running project to move every tax operation onto an API-first stack. VAT validation is the simplest endpoint in that family and the one with the lowest scope requirements (`read:vat-registered-companies`); it does not read filings, submissions, or payments. Governance is straightforward: HMRC owns the data, runs the endpoint, and publishes versioned documentation on the Developer Hub. The legal basis for the registry itself is the Value Added Tax Act 1994 and subsequent amendments; the API layer is governed by HMRC's terms of use for third-party developers. ## What the API returns The `lookup` operation takes the 9-digit VRN in the path and returns a JSON body with a `target` object and a `processingDate`: ```json { "target": { "name": "CREDITIX LIMITED", "vatNumber": "553557825", "address": { "line1": "131 Edgware Road", "line2": "London", "postcode": "W2 2AP", "countryCode": "GB" } }, "processingDate": "2026-05-18T10:12:34.567Z" } ``` There is a `lookup/{vrn}/{requesterVrn}` variant. Passing your own VRN as `requesterVrn` triggers an authenticated consultation and returns a `consultationNumber` on the response. Functionally, it is the UK equivalent of the VIES `requestIdentifier`: audit-grade proof that you verified the buyer's number at the time of supply. Error responses follow a predictable structure: ```json { "code": "NOT_FOUND", "message": "The VRN specified was not found." } ``` with HTTP 404 for unknown VRNs, HTTP 400 for malformed input, HTTP 401 for missing or expired tokens, and HTTP 503 when the backend itself is unavailable. The HMRC 97-55 checksum ensures you can reject obvious typos before spending a token. The name refers to the modulus (97) and the `+55` offset introduced when HMRC ran out of numbers under the original rule. Weights 8, 7, 6, 5, 4, 3, 2 are applied to digits 1 through 7 and summed. The two-digit suffix must then equal either `97 − ((sum + 55) mod 97)` (the modern rule) or `(−sum) mod 97` (the legacy rule still valid for older numbers). `@vatverify/vat-rates` implements both variants offline. ## Rate limits and quirks HMRC's public guidance suggests a sustained ceiling of roughly 100 requests per second per OAuth application. In practice, the ceiling is higher for short bursts but 100 req/s is the safe planning number. Exceeding it triggers HTTP 429 with a `Retry-After` header. Observed behaviour worth knowing: - **Token expiry ≠ retry signal.** A 401 response can mean expired token or revoked credentials. Always try exactly one refresh before surfacing the error to the caller; do not retry in a loop. - **Sandbox is fixture-only.** The sandbox URL (`test-api.service.hmrc.gov.uk`) serves a fixed set of test VRNs. It is useful for CI and integration tests, useless for verifying a real number. - **Branch codes add 3 trailing digits.** `GB123456789012` is a valid VRN where `012` is a branch identifier. The registry accepts both the 9-digit and the 12-digit form, but you should always strip the branch suffix before running the 97-55 checksum; the checksum only applies to the nine-digit core. - **Government-department prefixes.** Older numbers can appear as `GBGD` (government department) or `GBHA` (health authority) with three trailing digits. They are rare in commercial flows. - **Production credential reviews.** Each application gets a manual review that looks at your stated use case, your privacy policy, and that your callback URLs are reachable. Incomplete applications bounce back silently; check the developer portal dashboard for review status. ## How vatverify handles it The validation pipeline for GB numbers mirrors the VIES path but adds the OAuth layer: 1. **Normalise**: strip whitespace, dots, and hyphens; uppercase the prefix. 2. **Format check**: the normalised input must begin with `GB` and contain a valid numeric body; inputs that fail the shape check return [`invalid_format`](/errors/invalid-format) before we touch HMRC. 3. **Cache lookup**: freshness is 30 days on valids and 24 hours on invalids, with a seven-day fallback window past freshness used to serve degraded responses when HMRC is unreachable. 4. **Token lookup**: Upstash Redis holds the HMRC OAuth token. If present, use it. If absent, run the client-credentials grant and cache the token for the lifetime HMRC advertises on the response minus a five-minute safety margin, then proceed. 5. **Upstream call**: 10-second timeout, bearer auth, structured JSON decode. The HMRC 97-55 checksum and other VRN format helpers ship in [`@vatverify/vat-rates`](https://www.npmjs.com/package/@vatverify/vat-rates) for clients that want to reject typos before calling the API. Token caching matters because the OAuth exchange adds 150-300 ms to a cold validation. A shared cache collapses that cost across every caller on every key. Cached lookups do not hit HMRC at all; a cache hit returns in under 20 ms. When HMRC returns 503 or times out, we apply the same graceful-degradation posture as with VIES: serve any cached result with `meta.source_status: "degraded"` and fall through to [`registry_unavailable`](/errors/registry-unavailable) only when there is nothing to serve. Passing `requester_vat_number` on a VIES-routed request persists the VIES `requestIdentifier` as `verify_id`; HMRC's equivalent endpoint is not currently plumbed through, so GB lookups return `verify_id: null` even when a requester is supplied. ## Gotchas worth knowing - **`XI` is not HMRC.** A Northern Irish number routes through VIES, not HMRC. Do not waste a token looking up an `XI` number on the HMRC endpoint; the lookup will 404 or 400 depending on HMRC's current routing rules. vatverify dispatches by prefix. - **Scope name changed.** Older HMRC docs reference `read:vat`; the current scope for the check-vat-number endpoint is `read:vat-registered-companies`. If you see 401 with `INVALID_SCOPE`, update the scope parameter on your OAuth request. - **Sandbox tokens expire faster.** If you build tests against sandbox and assume 4-hour tokens, they will fail intermittently. Cache with the shorter TTL, or refresh on every request in test environments. - **`processingDate` is registry time.** Use `verified_at` in the vatverify response (the UTC timestamp at which we hit the registry) for audit logging; HMRC's `processingDate` is a best-effort indicator and has drifted from UTC in past incidents. ## See the API reference - API reference: [`GET /v1/validate`](/docs/api/validation/validate-vat) - Glossary: [HMRC VRN](/glossary/hmrc-vrn), [checksum](/glossary/checksum), [consultation number](/glossary/consultation-number), [VAT number](/glossary/vat-number) - Error surface: [`registry_unavailable`](/errors/registry-unavailable), [`invalid-format`](/errors/invalid-format), [`country-unsupported`](/errors/country-unsupported) - Related guides: [HMRC VAT check API](/guides/hmrc-vat-check-api), [VIES explained](/guides/registries/vies-explained), [Handle VIES downtime](/guides/handle-vies-downtime) Source: https://www.vatverify.dev/guides/registries/hmrc-vat-api-explained --- ## Norwegian brreg explained: org-number, MVA, and the open registry How the Brønnøysund Register Centre runs Norway's Entity and VAT registers, what the public REST API returns, and where MVA diverges from the rest of Europe. ## Key facts - Norway sits outside the EU (it is in the EEA). Its VAT, called MVA (merverdiavgift), is a wholly national registry with no VIES crossover. - Every Norwegian business has a 9-digit organisation number (organisasjonsnummer) issued by Brønnøysundregistrene (the Brønnøysund Register Centre, abbreviated "brreg"). The 9th digit is a [MOD-11](/glossary/mod-11) check digit. - MVA registration is a separate flag managed jointly by Skatteetaten (the tax authority) and surfaced on the brreg record as `registrertIMvaregisteret`. - The REST API at `data.brreg.no/enhetsregisteret/api/enheter/{orgnr}` is public, open data, no auth. brreg enforces a fair-use rate limit, with 429 responses for IPs that sustain heavy traffic. - `MVA` is a display suffix with no digits attached; it is stripped before any lookup. ## What the registry is Brønnøysundregistrene is a shared-services agency run by the Norwegian Ministry of Trade, Industry and Fisheries. It operates around twenty national registers from its base in the town of Brønnøysund, far north of Trondheim. The two registers relevant to VAT validation are: - **Enhetsregisteret** (the Entity Register). Every Norwegian business is listed here with its 9-digit organisation number, legal form, registered address, and a set of flags indicating status in other registers. - **Merverdiavgiftsregisteret** (the VAT Register). A subset of Enhetsregisteret businesses that have crossed the NOK 50,000 annual-turnover threshold (or registered voluntarily). The register itself is operated by Skatteetaten (the Norwegian Tax Administration), but its membership flag is exposed via brreg's Entity Register record as `registrertIMvaregisteret`. The legal basis is the Enhetsregisterloven (Entity Register Act) of 1994 and the Merverdiavgiftsloven (VAT Act) of 2009. Public access to the data is a deliberate policy choice: Norway treats the entity register as open public data, published under a permissive reuse licence, and exposes it without authentication. MVA's standard rate is 25%. Reduced rates apply: 15% on food, 12% on passenger transport and cinema tickets, and 0% on certain cultural categories. These are tax-rules-engine concerns rather than registry concerns, but worth noting because they frequently surface on the same invoices where you are doing the validation. ## What the API returns The lookup is a plain `GET` on a REST endpoint: ``` GET https://data.brreg.no/enhetsregisteret/api/enheter/123456789 Accept: application/json ``` A successful response carries the full entity record. The fields we care about for VAT validation: ```json { "organisasjonsnummer": "123456789", "navn": "EXAMPLE AS", "organisasjonsform": { "kode": "AS", "beskrivelse": "Aksjeselskap" }, "registrertIMvaregisteret": true, "registreringsdatoEnhetsregisteret": "2018-04-01", "forretningsadresse": { "land": "Norge", "landkode": "NO", "postnummer": "0001", "poststed": "OSLO", "adresse": ["Eksempelgata 1"] }, "slettedato": null } ``` Key signals: - `registrertIMvaregisteret`: `true` if the business is VAT-registered. This is the flag vatverify checks for `data.valid`. - `slettedato`: a deregistration date. A non-null value means the entity has been deleted from the Entity Register; brreg strips the VAT flag at the same time. - `organisasjonsform.kode`: legal form. `AS` is limited company, `ENK` is sole proprietor (enkeltpersonforetak), `ASA` is public limited, `NUF` is a Norwegian-registered foreign enterprise. Unknown numbers return HTTP 404 with a small JSON error body. Malformed requests return HTTP 400. The API also exposes `/enhetsregisteret/api/enheter?organisasjonsnummer=...` as a search form, but direct path lookup is cheaper and the one we use. ## Rate limits and quirks brreg applies a fair-use rate limit rather than a single fixed number, and sustained heavy traffic is what triggers it. Exceeding the ceiling returns HTTP 429 with a short back-off window. In practice, short bursts against a warm dataset are rarely throttled, but long sequential scans are. Plan for modest concurrency and honour any `Retry-After` header you receive. Quirks to be aware of: - **NUF entities.** A Norwegian-registered foreign enterprise (`NUF`) is a branch of a foreign company. It has a Norwegian organisation number and can be VAT-registered even though its headquarters is abroad. Treat NUFs as standard Norwegian taxpayers when `registrertIMvaregisteret` is `true`. - **Struck-off businesses.** `slettedato` populates on the day of deregistration, but the VAT flag can take up to 48 hours to flip to `false`. Always check `slettedato` alongside the VAT flag; if either indicates the business is gone, return `valid: false`. - **ENK sole proprietors.** Many sole proprietors are below the NOK 50,000 threshold and are therefore not VAT-registered. You will see valid entities with `registrertIMvaregisteret: false`. - **EORI is not MVA.** Norwegian EORI numbers share the same 9-digit org-number core and look like `NO123456789`. EORI is a customs identifier managed by Tolldirektoratet, not a VAT identifier. Do not confuse the two on cross-border shipping flows. ## How vatverify handles it The brreg pipeline is the simplest of the four registries because there is no auth layer and the response shape is already JSON. 1. **Normalise**: strip whitespace, dots, hyphens, and the optional `MVA`/`mva` suffix; uppercase the prefix. 2. **Format check**: the request must start with the two-letter `NO` prefix followed by the nine-digit body. Bodies that fail the shape check return [`invalid_format`](/errors/invalid-format) before any network call. 3. **Cache lookup**: freshness is 30 days on valid records and 24 hours on invalids, with a seven-day fallback window past freshness used to smooth upstream outages. 4. **Upstream call**: HTTP GET with a 10-second timeout. Parse JSON, map `registrertIMvaregisteret` to `data.valid`, map `navn` to `data.company.name`, map `forretningsadresse` to `data.company.address`. Norwegian MOD-11 and the rest of the Brønnøysund checksum machinery (weights `3, 2, 7, 6, 5, 4, 3, 2` over the first eight digits) ships in the [`@vatverify/vat-rates`](https://www.npmjs.com/package/@vatverify/vat-rates) offline library for clients that want to reject typos before calling the API. On 404 (entity not found), we cache `valid: false` under the invalid-freshness window. On 5xx or timeout, we apply the degraded-cache posture: serve the last cached record with `meta.source_status: "degraded"`, else return [`registry_unavailable`](/errors/registry-unavailable). Authenticated consultations do not exist for brreg; the API is fully open, and there is no per-lookup identifier analogous to the VIES `requestIdentifier`. If you need audit-grade proof for a Norwegian validation, snapshot the response body alongside the `verified_at` timestamp in your own system, stored in tamper-evident form. Because brreg is open-data, a timestamped record of the public endpoint's response is the established substitute for a consultation number. Because brreg is a REST API with predictable latency, the degraded-mode path fires less often here than for the SOAP registries. A typical Norwegian validation against a warm cache returns in under 20 ms; a cache miss plus upstream call lands under 400 ms most days. The [tax-rules engine](/docs/api/tax-rules/decide-tax) downstream of `/v1/validate` consumes the same normalised response, so Norwegian entries behave identically to their EU counterparts once they have been resolved. ## Gotchas worth knowing - **The `MVA` suffix is display sugar.** It is not a distinct registry. Numbers with and without the suffix are the same record. - **Norway's threshold is NOK 50,000, not a euro figure.** If you cross-check against EU-style thresholds, you will misclassify small Norwegian businesses. Use the NOK figure and the `registrertIMvaregisteret` flag, not turnover assumptions. - **`slettedato` can precede the VAT flag flip.** During the 24-48 hour propagation window a business can show `slettedato` non-null and `registrertIMvaregisteret: true`. Check both. - **Open data ≠ opinionated schema.** brreg is a statistical register run by a statistics agency; field semantics can drift slightly across API versions. Pin your client to the documented schema and treat unknown fields as additive. ## See the API reference - API reference: [`GET /v1/validate`](/docs/api/validation/validate-vat) - Glossary: [MOD-11](/glossary/mod-11), [checksum](/glossary/checksum), [VAT number](/glossary/vat-number) - Error surface: [`registry_unavailable`](/errors/registry-unavailable), [`invalid-format`](/errors/invalid-format), [`country-unsupported`](/errors/country-unsupported) - Related guides: [Norwegian VAT brreg](/guides/norwegian-vat-brreg), [Swiss BFS and Zefix explained](/guides/registries/swiss-bfs-zefix-explained), [VIES explained](/guides/registries/vies-explained) Source: https://www.vatverify.dev/guides/registries/norwegian-brreg-explained --- ## Swiss BFS and Zefix explained: UID, VAT, and the eCH-0097 schema How the Swiss Federal Statistical Office runs the UID register, how Zefix relates to it, what the SOAP endpoint returns, and why Liechtenstein rides the same rails. ## Key facts - Switzerland issues a single business identifier (the UID, in the format `CHE` + 9 digits) used across every federal agency. VAT registration is a separate flag on that UID. - The UID register is run by the Federal Statistical Office (Bundesamt für Statistik, BFS) and exposed as a SOAP web service at `uid-wse.admin.ch` using the eCH-0097 standard. - Zefix is the Swiss commercial register (run by the Federal Office of Justice) and covers legal-entity registration; it is a separate dataset from the UID register but shares the same CHE identifier. - Liechtenstein validates through the same BFS endpoint: `LI`-prefixed numbers carry a CHE-format UID underneath. - Swiss VAT carries language-dependent suffixes (`MWST` / `TVA` / `IVA`) that are display-only and never sent upstream. ## What the registry is Before the UID was introduced, Swiss businesses carried several different identifiers: a commercial-register number, a tax-authority number, an insurance-authority number, and so on. The Federal Act on the Business Identification Number (Unternehmens-Identifikationsnummer-Gesetz, UIDG) of 2011 consolidated all of them into a single UID, in the format `CHE-xxx.xxx.xxx`, issued once at incorporation and used across every federal agency. Two registers sit alongside that identifier: - **The UID register**, run by BFS. This is the authoritative record of what the UID refers to and whether the business is active, struck off, or in liquidation. The VAT flag lives here. - **Zefix** (the Zentraler Firmenindex), run by the Federal Office of Justice (Bundesamt für Justiz, BJ). This is the commercial-register front-end; it aggregates the cantonal commercial registers and exposes company-level metadata (legal form, registered office, purpose, signatories). It shares the UID but covers a different slice of truth. For VAT validation specifically, BFS is the right endpoint. The `registrationStatus` flag on the UID record tells you whether the business is active in the VAT register (MWST-Register). Zefix is useful for richer company metadata once you already know the UID is valid. Liechtenstein sits inside the Swiss-Liechtenstein customs and currency union and uses the same UID scheme. A Liechtenstein VAT number displayed as `LI` routes to the same BFS endpoint; the underlying record is a CHE-format UID. ## What the API returns The BFS endpoint speaks SOAP and conforms to eCH-0097, the Swiss e-government standard for UID records, published and versioned by eCH (E-Government Standards Switzerland). The service URL is: ``` https://www.uid-wse.admin.ch/V5.0/PublicServices.svc ``` A UID lookup by identifier carries the `CHE` prefix and the nine-digit core in a typed element. A successful response returns an `organisation` block with the registered name, address components, legal form code, UID status, and a `vatRegisterInformation` child if the business is VAT-registered. A simplified view of the relevant fields: ```xml CHE 115932342 Example AG 0106
Musterstrasse 1 Zürich 8001
2 1 2018-04-01
``` `status` codes are documented in eCH-0097: `2` = active, `3` = cancelled, `4` = struck off. `vatStatus` mirrors the same pattern for the VAT sub-register. A business with `status: 2` and no `vatRegisterInformation` element is a valid Swiss company that is not VAT-registered (typically below the CHF 100,000 annual-turnover threshold). The nine-digit body uses a [MOD-11](/glossary/mod-11) check digit at the last position, which is the cheapest way to reject typos offline before touching BFS. ## Rate limits and quirks BFS does not publish a hard rate limit. Sustained bursts of roughly fifty requests per second from a single caller do not trip throttling; heavier concurrency can produce intermittent SOAP faults that look like transient timeouts. Observed quirks: - **Display variants are plentiful.** `CHE-123.456.789`, `CHE123456789`, `CHE 123 456 789`, and `CHE-123.456.789 MWST` all refer to the same UID. Dots, hyphens, spaces, and the MWST/TVA/IVA suffix are display sugar only; strip them before the upstream call. - **Three language suffixes, one meaning.** `MWST` (Mehrwertsteuer, German), `TVA` (Taxe sur la valeur ajoutée, French), and `IVA` (Imposta sul valore aggiunto, Italian) all signal the same VAT registration. Which one appears usually depends on the canton. - **Liechtenstein prefix.** A `LI` prefix sent in an international context unwraps to a CHE-format UID. vatverify routes `LI` to BFS and preserves `country.code: "LI"` in the response. - **`status: 2, vatStatus: null`.** The business exists, the UID is live, but it is not VAT-registered. This is a valid business state, not an error. Treat as `valid: false` for VAT purposes. - **eCH-0097 version.** BFS currently runs V5.0 of the schema. Older client libraries pinned to V4.0 will still work but miss a few fields (notably `vatEntryDate`). Target V5.0. ## How vatverify handles it The pipeline for `CH` and `LI` mirrors the VIES/HMRC shape with one routing twist: both prefixes land on the same BFS endpoint. 1. **Normalise**: strip hyphens, dots, spaces, and the MWST/TVA/IVA suffix; uppercase the prefix. The `CHE` prefix on the input is stripped from the body before the registry call, and `CH`/`LI`-prefixed inputs route to the same endpoint. 2. **Format check**: the normalised body must be a 9-digit number; inputs that fail the shape check return [`invalid_format`](/errors/invalid-format) without hitting BFS. 3. **Cache lookup**: freshness is 30 days on valid records and 24 hours on invalids, with a seven-day fallback window past freshness used to serve degraded responses when BFS is unreachable. 4. **Upstream call**: SOAP POST to BFS with a 10-second timeout; parse the eCH-0097 response into the common `data.company` shape. The UID body MOD-11 check digit and other Swiss format rules ship in [`@vatverify/vat-rates`](https://www.npmjs.com/package/@vatverify/vat-rates) for clients that want to reject typos before calling the API. A BFS SOAP fault or timeout falls through to the same degraded-cache path as the other registries: return the last good record with `meta.source_status: "degraded"`, or [`registry_unavailable`](/errors/registry-unavailable) when nothing is cached. We do not hit Zefix directly for the `/v1/validate` endpoint because the VAT flag lives in BFS's UID register. Zefix is useful if a downstream caller wants company-officer data or purpose-of-business text, both of which BFS does not carry; that is a future endpoint, not the current validation surface. Swiss numbers do not participate in VIES, so there is no authenticated-consultation identifier analogous to the EU `requestIdentifier`. For audit-grade evidence on a Swiss lookup, snapshot the response body together with the `verified_at` timestamp returned by the API and store it in tamper-evident form alongside the invoice. The Swiss Federal Tax Administration (Eidgenössische Steuerverwaltung, ESTV) does not publish a machine-readable equivalent of VIES's consultation number, so a timestamped record is the established substitute. ## Gotchas worth knowing - **UID is not VAT.** Every registered Swiss business has a UID. Only those over the CHF 100,000 annual-turnover threshold (or those that have voluntarily registered) have the VAT flag set. A valid UID with no VAT flag returns `valid: false` on `/v1/validate`. - **The registration threshold is global turnover, not Swiss-only.** A foreign business selling taxable supplies into Switzerland can be required to register even below the domestic threshold. The registry reflects that; your business logic should not assume "no Swiss presence = no Swiss VAT". - **Sole proprietors get UIDs too.** Einzelunternehmen (sole proprietorships) receive the same CHE identifier as companies. Many are below the VAT threshold and therefore valid businesses with no VAT registration. - **Zefix and BFS disagree on cancellation timing.** A business struck off the commercial register (Zefix) can still show `status: 2` in BFS for a short window while the VAT deregistration is processed. If a customer disputes a `valid: true` for a dissolved company, check both registers before concluding the data is wrong. ## See the API reference - API reference: [`GET /v1/validate`](/docs/api/validation/validate-vat) - Glossary: [Swiss UID](/glossary/swiss-uid), [checksum](/glossary/checksum), [MOD-11](/glossary/mod-11), [VAT number](/glossary/vat-number) - Error surface: [`registry_unavailable`](/errors/registry-unavailable), [`invalid-format`](/errors/invalid-format), [`country-unsupported`](/errors/country-unsupported) - Related guides: [Swiss UID validation guide](/guides/swiss-uid-validation-guide), [Norwegian brreg explained](/guides/registries/norwegian-brreg-explained), [VIES explained](/guides/registries/vies-explained) Source: https://www.vatverify.dev/guides/registries/swiss-bfs-zefix-explained --- ## VIES explained: how the EU VAT aggregator actually works A deep dive into the VAT Information Exchange System, the SOAP protocol it speaks, the national registries it fronts, and how vatverify smooths over its quirks. ## Key facts - VIES fronts 27 national VAT registries plus Northern Ireland (XI) through a single SOAP gateway run by the European Commission (DG TAXUD). - The public endpoint lives at `https://ec.europa.eu/taxation_customs/vies/services/checkVatService`. There is no REST mirror. - Responses vary in shape per country: some return structured street/postcode/city fields, others return a single concatenated address string. - Maintenance windows fall on Monday mornings (roughly 03:00 to 05:00 UTC); individual member states can go offline independently of the gateway. - Passing `requesterCountryCode` and `requesterVatNumber` upgrades the call to an authenticated consultation and returns a `requestIdentifier` (the consultation number) that you can cite in a tax audit. ## What the registry is VIES (the VAT Information Exchange System) has been the European Commission's public-facing gateway to national VAT registries since the early 1990s. It exists because intra-Community B2B trade works on reverse-charge: a seller in Spain invoicing a buyer in Germany zero-rates the invoice and the buyer accounts for the VAT at their own national rate. That mechanism only stands up if the seller can prove the buyer was VAT-registered at the time of supply. VIES is the proof mechanism. The gateway is operated by DG TAXUD (Taxation and Customs Union, part of the European Commission). The legal basis sits in Council Regulation (EU) No 904/2010 on administrative cooperation in the field of value-added tax, and national registries are obliged to expose their registration data through it. VIES itself does not store VAT numbers; it is a routing layer that proxies each lookup to the right member-state backend, reads whatever shape that backend returns, and wraps it in a common SOAP envelope. That shared envelope is the only thing standardised. Everything behind it is national, which is why consuming VIES well is less about SOAP and more about country-specific quirks. ## What the API returns The protocol is SOAP 1.1. A `checkVat` request is a small XML envelope: ```xml IE 6388047V ``` POST it with `Content-Type: text/xml; charset=utf-8` and an empty `SOAPAction` header. A successful response contains `valid`, `name`, `address`, `requestDate`, plus the echoed `countryCode` and `vatNumber`: ```xml IE 6388047V 2026-05-18+02:00 true GOOGLE IRELAND LIMITED
3RD FLOOR, GORDON HOUSE, BARROW STREET, DUBLIN 4
``` The `checkVatApprox` operation is the authenticated variant. You pass your own `requesterCountryCode` and `requesterVatNumber`, the gateway logs the consultation, and the response carries a `requestIdentifier` (11 alphanumeric characters) that you can store on the invoice line. In the event of an audit, this is the [consultation number](/glossary/consultation-number) that proves you verified the buyer's number at the time of supply. Two details worth calling out in the response shape: - The `address` field is country-defined. Germany returns it as a structured element with `Ort`, `PLZ`, `Strasse`. Ireland returns a single comma-separated string. France sometimes omits it entirely. - Legal names come through in UTF-8. An XML parser defaulting to Latin-1 will mojibake German umlauts, Spanish ñ, and Danish å. vatverify normalises both before handing anything back to the caller, so the output of `/v1/validate` follows the same schema whether the upstream was a German `Ort` field or an Irish string. ## Rate limits and quirks VIES has no published rate limit. In practice, the gateway trips HTTP 503s after roughly sixty concurrent requests from a single IP, and a sustained ceiling of about 10 requests per second is what most callers can hold without back-pressure. Batched calls with `requesterVatNumber` get throttled harder because the authenticated consultation path does extra work on the backend. Observed failure modes: - **`SERVICE_UNAVAILABLE`**: the gateway itself is down. Typically aligns with the Monday-morning maintenance window but can happen at other times. - **`MS_UNAVAILABLE`**: the gateway is up but the national registry for the requested country is unreachable. This is the most common fault; Spain, Germany, and France trend the highest on the per-country outage ledger. - **`MS_MAX_CONCURRENT_REQ`**: you asked for too many concurrent lookups against a single country. Back off. - **`TIMEOUT`**: the gateway gave up waiting on the national backend. Treat as transient. - **`INVALID_INPUT`**: format rejection before any registry call. Always run a [checksum](/glossary/checksum) locally first to cut this down. Country-specific behaviours to expect: Italy returns HTTP 200 with `valid: false` for numbers that used to exist but have been deregistered, without a deregistration date. Greece uses the prefix `EL`, not the ISO `GR`, for historical reasons predating the ISO 3166 alignment. The Netherlands reissued the VAT numbers of sole proprietors in January 2020 so the 9-digit core would no longer be derived from the holder's BSN (citizen number); the overall format (`NL` + 9 digits + `B` + 2 digits) did not change, but legacy-to-new number mappings were issued and some third-party validators took a while to accept the new randomised numbers. ## How vatverify handles it Every `/v1/validate` call that lands on a VIES country runs through a short pipeline before any network work: 1. **Normalise**: strip spaces, dots, and hyphens; uppercase the prefix. Greek numbers are expected to arrive with the VIES-native `EL` prefix; `GR` inputs return `country_unsupported` from the normaliser. 2. **Format check**: the normalised input must look like a two-letter prefix followed by an alphanumeric body. Inputs that fail the shape check return [`invalid_format`](/errors/invalid-format) without hitting VIES. 3. **Cache lookup**: Upstash Redis, keyed per registry and VAT number. Freshness is 30 days for valid results and 24 hours for invalids, with a seven-day fallback window past freshness used to serve degraded responses when VIES is unreachable. Country-specific checksum helpers (the MOD-11 family, MOD-97, Luhn variants for Italy and Sweden, and others) ship in [`@vatverify/vat-rates`](https://www.npmjs.com/package/@vatverify/vat-rates) for clients that want to reject obvious typos offline before calling the API. A cache miss hits the VIES SOAP endpoint with a 10-second timeout. On `MS_UNAVAILABLE`, `TIMEOUT`, or other upstream faults, the pipeline checks for a previously-cached value for the same number. If one exists, it is served with `meta.source_status: "degraded"` so the caller can decide whether to retry later. If nothing is cached, the API returns a structured [`registry_unavailable`](/errors/registry-unavailable) error with a 502 status. Passing `requester_vat_number` on the request triggers the authenticated VIES consultation path and persists the `requestIdentifier` in the response as `verify_id`. This is the number you save alongside your invoice record to satisfy an audit. The batch endpoint (`POST /v1/validate/batch`) applies the same pipeline to each item. Batches without a `requester_vat_number` run every item in parallel because most resolve from cache; when a requester is set, each item hits VIES's authenticated consultation path directly, so the batch runs through a pool of five concurrent workers to avoid tripping `MS_MAX_CONCURRENT_REQ`. Cached items inside a batch do not count against your monthly quota; only cache misses do. When VIES is in maintenance, batches with warm-cache coverage continue to serve, while genuinely-cold items return `source_status: "degraded"` or `registry_unavailable` on a per-item basis. ## Gotchas worth knowing - **Greece is `EL`, not `GR`.** VIES only recognises `EL` for Greek VAT numbers, and vatverify follows VIES's alpha-2 set; `GR`-prefixed inputs return `country_unsupported`. If you let a user type country codes freely, map `GR` to `EL` on your side before calling the API. - **Northern Ireland is `XI`, not `GB`.** Post-Brexit, GB numbers are UK only and validate through [HMRC](/guides/registries/hmrc-vat-api-explained). XI numbers stayed in the EU VAT area for goods under the Windsor Framework and still validate through VIES. - **`valid: false` is not always permanent.** Registries routinely lag behind tax-authority decisions by up to 24 hours. Rerunning a rejected lookup the next day will sometimes flip it to `true` once the backend has caught up. - **The `requestDate` is the member-state query date, not a source-of-truth timestamp.** Do not use it as the authoritative registration date of the business. ## See the API reference - API reference: [`GET /v1/validate`](/docs/api/validation/validate-vat) - Glossary: [VIES](/glossary/vies), [consultation number](/glossary/consultation-number), [checksum](/glossary/checksum), [VAT number](/glossary/vat-number) - Error surface: [`registry_unavailable`](/errors/registry-unavailable), [`country_unsupported`](/errors/country-unsupported), [`invalid_format`](/errors/invalid-format) - Related guides: [Handle VIES downtime](/guides/handle-vies-downtime), [VIES API guide](/guides/vies-api-guide), [HMRC VAT API](/guides/registries/hmrc-vat-api-explained), [EU national databases](/guides/registries/eu-national-databases-explained) Source: https://www.vatverify.dev/guides/registries/vies-explained --- # Errors ## b2c_not_supported The /decide endpoint only handles B2B transactions. B2C is not supported in v1. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "b2c_not_supported", "message": "B2C transactions are not supported. /decide handles B2B only." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/decide` is designed for B2B transactions, where both parties are VAT-registered businesses. B2C (business-to-consumer) involves different rules in each country that are out of scope for v1. ## How to fix For B2C transactions, apply your seller country's **standard VAT rate** to the sale. You can retrieve this from `GET /v1/rates/:country`: ```bash curl https://api.vatverify.dev/v1/rates/de # Returns standard_rate: 19 for Germany ``` The buyer's location determines the rate for digital services (OSS rules), but for most physical goods, the seller's country rate applies. ## Why /decide is B2B-only `/v1/decide` answers "should I charge VAT, and which country's?" by combining the EU VAT Directive's reverse-charge logic with the buyer-side validity check. The reverse-charge mechanism is fundamentally a B2B concept under Articles 138 and 196 of the Directive: it triggers on a valid VAT-registered counterparty. B2C falls outside that mechanism and into a different decision tree: - **Distance sales of goods** route through OSS once the EU-wide €10,000 threshold is crossed. - **Digital services to EU consumers** use the destination-country rate under place-of-supply rules and report through OSS. - **Local B2C sales** apply the seller's domestic rate. Each of those branches has different evidence requirements (location proofs, threshold tracking, OSS registration status) that v1 does not model. Folding them into `/decide` would change the contract substantially. ## How to detect B2C client-side If you call `/v1/decide` without a buyer VAT number, the API treats the transaction as B2C and returns this error. The check on your side is simple: if `buyer_vat` is empty, do not call `/decide`. Use `/v1/rates/:country` instead and apply the seller-country logic. See the [reverse charge glossary](/glossary/reverse-charge) for the rule that drives the B2B-only design, and the [OSS glossary](/glossary/oss) for the B2C path. ## Common mistakes - **Calling `/decide` when the buyer has no VAT number**: if there is no `buyer_vat`, the transaction is B2C. Use the rates endpoint instead. - **Expecting `/decide` to handle all transaction types**: it is intentionally scoped to EU B2B only in v1. ## Related errors - [`seller_country_unsupported`](/docs/errors/seller-country-unsupported): seller country is outside EU-27 - [`buyer_vat_not_registered`](/docs/errors/buyer-vat-not-registered): buyer has a VAT number but it is not registered - [`country_unknown`](/docs/errors/country-unknown): country code not in the rates dataset Source: https://www.vatverify.dev/errors/b2c-not-supported --- ## batch_too_large The vat_numbers array contains more than 50 items. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "batch_too_large", "message": "Batch size 73 exceeds the maximum of 50 items per request." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/validate/batch` accepts a maximum of 50 VAT numbers per request. Sending more than 50 items in `vat_numbers` is rejected immediately before any validation is attempted. ## How to fix 1. Split your input into chunks of at most 50 items. 2. Make multiple batch requests, one per chunk. 3. Cache results on your side to avoid re-validating the same numbers. ```typescript // Example: chunk an array into batches of 50 const BATCH_SIZE = 50; const chunks = []; for (let i = 0; i < vatNumbers.length; i += BATCH_SIZE) { chunks.push(vatNumbers.slice(i, i + BATCH_SIZE)); } const results = await Promise.all( chunks.map(chunk => client.validateBatch({ vat_numbers: chunk })) ); ``` ## Common mistakes - **Assuming the API truncates to 50:** it rejects, it doesn't truncate. The whole request fails. - **Not chunking before sending:** always pre-split large arrays client-side. - **Ignoring cache hits:** cache hits don't count against quota. Filtering out recently-validated numbers before batching can significantly reduce costs. ## Related errors - [`rate_limited`](/docs/errors/rate-limited): batch was a valid size but quota was exhausted - [`plan_required`](/docs/errors/plan-required): batch endpoint requires Pro or Business plan Source: https://www.vatverify.dev/errors/batch-too-large --- ## buyer_vat_not_registered The buyer_vat number passed to /decide is invalid or not registered with the registry. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "buyer_vat_not_registered", "message": "buyer_vat 'FR00000000000' is not a registered business." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/decide` validates the `buyer_vat` against the registry before computing the VAT treatment. If the buyer's VAT number is invalid (bad format or not registered), this error is returned. `/decide` requires a verified buyer. An unregistered buyer means the transaction is likely B2C. ## How to fix 1. Call `GET /v1/validate?vat_number=` first. If `valid: false`, the buyer is not a registered business. 2. If the buyer is unregistered, treat the transaction as B2C and apply the seller's standard rate (see [`GET /v1/rates/:country`](/docs/api/rates/get-rate)). 3. If you believe the VAT number is valid but getting this error, the buyer may be temporarily deregistered or the registry may be lagging. ## Common mistakes - **Not pre-validating the buyer:** always validate the buyer VAT before calling `/decide` to avoid this error in production. - **Using test magic numbers for the buyer:** test magic numbers simulate valid/invalid responses but are not "registered" in the VIES sense that `/decide` checks. - **Passing a buyer in a country where the registry is slow to update:** new businesses can take days to appear in VIES after registration. ## Related errors - [`seller_country_unsupported`](/docs/errors/seller-country-unsupported): buyer is fine but seller country is outside EU-27 - [`b2c_not_supported`](/docs/errors/b2c-not-supported): no buyer VAT at all (consumer transaction) Source: https://www.vatverify.dev/errors/buyer-vat-not-registered --- ## bzst_rejected BZSt returned an unrecognised status code that does not map to a typed vatverify error. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "bzst_rejected", "message": "BZSt returned unrecognised status code: 9999.", "retryable": false }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 890, "bzst_status_code": "9999" } } ``` ## What happened BZSt responded with a status code that is not covered by vatverify's mapping of known eVatR codes. This is rare. It typically means either the BZSt API contract changed and introduced a new status code, or an edge case in the BZSt response was not anticipated in the current implementation. The raw BZSt status code is always surfaced in `meta.bzst_status_code` so you can record it and report it. ## How to fix 1. Open a bug report with vatverify support, providing the `request_id` and the value from `meta.bzst_status_code`. This allows the unknown code to be mapped and a typed error added. 2. As a workaround, fall back to a plain `/v1/validate` call for the same VAT number. This queries VIES rather than BZSt and will confirm whether the number is currently registered, though it will not produce a §18e qualified confirmation. 3. Do not retry `bzst_rejected` automatically. It is marked `retryable: false` because a retry against BZSt will produce the same unrecognised status. ## Related errors - [`bzst_unavailable`](/errors/bzst-unavailable): BZSt unreachable or in maintenance - [`bzst_server_error`](/errors/bzst-server-error): BZSt internal error on a known code ## See also - [BZSt](/glossary/bzst) - [/v1/confirm endpoint](/docs/api/confirmations/confirm) Source: https://www.vatverify.dev/errors/bzst-rejected --- ## bzst_server_error BZSt accepted the request but returned an internal server error (evatr-2004, evatr-2011, evatr-3011). **HTTP `502` · Bad Gateway** ## Example response ```json { "error": { "code": "bzst_server_error", "message": "BZSt evatr-2004: internal server error.", "retryable": true }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 1620, "bzst_status_code": "2004" } } ``` ## What happened The BZSt eVatR endpoint received and processed the request but returned an internal error status: `2004`, `2011`, or `3011`. The request was well-formed and the connection to BZSt was successful. The failure originated inside the BZSt system. This error is distinct from [`bzst_unavailable`](/errors/bzst-unavailable), where the eVatR service itself was unreachable or in a maintenance state. Here the service was reachable but encountered a fault while handling the specific request. These codes occur rarely. They are most common during high-load periods at BZSt or immediately after a deployment on the BZSt side. ## How to fix 1. Retry once after a short delay (10-15 seconds). Most `bzst_server_error` responses resolve on the next attempt. 2. If the error persists across multiple retries, contact vatverify support and include the `request_id` from `meta`. This lets the support team correlate the BZSt status code with the upstream request log. 3. Do not discard the original request parameters. A persistent `bzst_server_error` on the same number may indicate a BZSt-side data issue for that specific VAT number that requires manual investigation. ## Related errors - [`bzst_unavailable`](/errors/bzst-unavailable): BZSt service unreachable or in maintenance - [`registry_unavailable`](/errors/registry-unavailable): general upstream registry outage ## See also - [BZSt](/glossary/bzst) - [Registry unavailable](/errors/registry-unavailable) Source: https://www.vatverify.dev/errors/bzst-server-error --- ## bzst_session_limit The API hit the per-session quota for qualified confirmations against BZSt (evatr-0008). **HTTP `429` · Too Many Requests** ## Example response ```json { "error": { "code": "bzst_session_limit", "message": "BZSt evatr-0008: session request limit reached. Retry after 60 s." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 310, "bzst_status_code": "0008" } } ``` ## What happened BZSt returned status code `0008` from the eVatR service, indicating the per-session quota for qualified confirmations has been reached. This is a BZSt-side throttle, separate from your vatverify plan quota. It applies at the eVatR session level and can occur during bursts of `/v1/confirm` calls even when your vatverify monthly quota is nowhere near exhausted. Free-tier sessions are managed automatically, but high-frequency bursts can trigger this limit before the session is recycled. ## How to fix 1. Wait at least 60 seconds before retrying the same request. 2. If you are sending many confirmations in quick succession, add a delay between calls rather than firing them concurrently. 3. Use `X-RateLimit-Remaining` on vatverify responses to track your plan quota separately. `bzst_session_limit` is a BZSt-side limit, not a vatverify quota limit. 4. If you see this error repeatedly at moderate call volumes, contact support with the `request_id` so the session configuration can be reviewed. ## Related errors - [`rate_limited`](/errors/rate-limited): your vatverify plan quota exhausted - [`bzst_unavailable`](/errors/bzst-unavailable): BZSt unreachable or degraded ## See also - [BZSt](/glossary/bzst) - [Rate limited](/errors/rate-limited) - [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) Source: https://www.vatverify.dev/errors/bzst-session-limit --- ## bzst_unavailable BZSt is temporarily unreachable or returned a degradation status (evatr-0011, evatr-1001 through evatr-1004). **HTTP `503` · Service Unavailable** ## Example response ```json { "error": { "code": "bzst_unavailable", "message": "BZSt evatr-1001: service temporarily unavailable.", "retryable": true }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 4200, "bzst_status_code": "1001" } } ``` ## What happened The BZSt eVatR service returned a degradation status code: `0011` (general unavailability), or one of `1001` through `1004` (various maintenance and overload states). The request reached vatverify correctly and was forwarded to BZSt, but BZSt could not fulfil it. This is distinct from [`bzst_server_error`](/errors/bzst-server-error), where BZSt accepted the request but failed internally. BZSt schedules maintenance windows, particularly outside German business hours. During these windows the eVatR endpoint returns unavailability codes rather than timing out. ## How to fix 1. Retry with exponential backoff. A starting interval of 30 seconds is appropriate for most maintenance windows. 2. Check [/status](/status) for the current BZSt registry health before assuming a persistent outage. 3. If your workflow is time-sensitive and BZSt is down, fall back to a plain VIES check via `/v1/validate`. A VIES check confirms the VAT number is currently registered but does not produce the qualified BZSt confirmation required by §18e UStG. 4. If `bzst_unavailable` persists beyond 30 minutes, check the [status page](/status) and consider queueing the confirmation for retry once the registry recovers. ## Related errors - [`bzst_server_error`](/errors/bzst-server-error): request reached BZSt but their server failed - [`registry_unavailable`](/errors/registry-unavailable): general upstream registry outage ## See also - [Registry status](/status) - [BZSt](/glossary/bzst) - [Registry unavailable](/errors/registry-unavailable) Source: https://www.vatverify.dev/errors/bzst-unavailable --- ## country_unknown The country code passed to the rates endpoint does not exist in the dataset. **HTTP `404` · Not Found** ## Example response ```json { "error": { "code": "country_unknown", "message": "Country code 'xx' not found in rates dataset." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened You called `GET /v1/rates/:country` with a country code that does not exist in the VAT rates dataset. Unlike `country_unsupported` (which is about live validation), this error is specific to the rates endpoints. ## How to fix 1. Call `GET /v1/rates` (no country code) to get the full list of valid country codes. 2. Country codes in the rates endpoint are **lowercase**: use `de`, not `DE`. 3. For Greece, use `el` not `gr`. ## Country code conventions The rates dataset uses ISO 3166-1 alpha-2 codes in lowercase, with two intentional deviations that match the EU VAT system: - **`el`** for Greece (not `gr`). The EU VAT prefix uses `EL` derived from "Hellas". - **`xi`** for Northern Ireland (not part of ISO 3166). The XI prefix was created post-Brexit for Northern Ireland goods trade with the EU. Both deviations also apply when validating VAT numbers through `/v1/validate`. See the [VAT number glossary](/glossary/vat-number) for the full list of country code conventions. ## Common scenarios that hit this error - **Pulling rates from a hardcoded country list that includes non-EU codes.** Calling `/v1/rates/us` or `/v1/rates/jp` will return `country_unknown` because the rates dataset is scoped to the 32 supported countries (EU-27 plus XI, GB, CH, LI, NO). - **Uppercase codes**: `GET /v1/rates/DE` is rejected; the canonical form is lowercase. - **Country codes that exist on the validation side but not in rates**: in current versions of the API, all 32 validation-supported countries also have rates, so this divergence does not occur. If new validation-only or rates-only countries are added in future, this error is what surfaces the gap. ## Common mistakes - **Uppercase country codes**: `GET /v1/rates/DE` will fail; use `/v1/rates/de`. - **Using `gr` for Greece**: the correct code in the rates dataset is `el`. - **Confusing this with `country_unsupported`**: `country_unknown` only comes from the rates endpoints, not from validation. ## Related errors - [`country_unsupported`](/docs/errors/country-unsupported): country not supported for live validation - [`invalid_format`](/docs/errors/invalid-format): VAT number format does not match the country - [`not_found`](/docs/errors/not-found): generic resource-not-found Source: https://www.vatverify.dev/errors/country-unknown --- ## country_unsupported The VAT number's country prefix maps to a country that vatverify does not support. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "country_unsupported", "message": "Country AU is not supported." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The two-letter country prefix on the VAT number (e.g. `AU123456789`) maps to a country outside our 32 supported. vatverify covers EU-27, UK, Switzerland, Liechtenstein, and Norway. Other countries return this error. ## How to fix 1. Check the [Supported Countries](/docs/supported-countries) page for the full list of valid country codes. 2. If you are validating a Greek VAT number, use `EL` not `GR`. Greece is the one EU member state that uses a non-ISO code in VIES. 3. For unsupported countries, apply offline format validation using [`@vatverify/vat-rates`](https://www.npmjs.com/package/@vatverify/vat-rates) which covers 44 countries with checksum algorithms. ## Common mistakes - **Using `GR` for Greece:** the correct VIES code is `EL`. This is the most common cause of unexpected `country_unsupported` errors from EU developers. - **Sending Australian ABN numbers:** AU is not yet supported for live validation. - **Passing a raw number without a country prefix:** the API requires the full VAT number including the country code prefix (e.g. `DE811569869`, not `811569869`). ## Related errors - [`country_unknown`](/docs/errors/country-unknown): country code not in the rates dataset (rates endpoints only) - [`invalid_format`](/docs/errors/invalid-format): the VAT number string itself is malformed Source: https://www.vatverify.dev/errors/country-unsupported --- ## invalid_format The request body is malformed, a required field is missing, or a field has the wrong type. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "invalid_format", "message": "vat_numbers must be a non-empty array." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The request failed schema validation before reaching any registry. Common causes: a required field is absent, a field has the wrong type (e.g. a string where an array is expected), or the JSON body is syntactically invalid. ## How to fix 1. Check the [API reference](/docs/api/validation/validate-vat) for required fields on the endpoint you're calling. 2. For `GET /v1/validate`, `vat_number` is required as a query parameter. 3. For `POST /v1/validate/batch`, `vat_numbers` must be a non-empty array of strings. 4. For `POST /v1/decide`, both `seller_vat` and `buyer_vat` are required. 5. Verify your request includes `Content-Type: application/json` on POST requests. ## Common mistakes - **Sending `vat_number` as an array:** the single-validate endpoint takes a string, not an array. - **Omitting `Content-Type: application/json` on POST requests:** the body is read as empty without it. - **Sending an empty `vat_numbers: []` array to the batch endpoint:** minimum 1 item required. - **Invalid JSON syntax:** trailing commas, unquoted keys. Use a JSON validator before debugging the API. ## Related errors - [`country_unsupported`](/docs/errors/country-unsupported): format is valid but country isn't supported - [`batch_too_large`](/docs/errors/batch-too-large): array is valid but exceeds 50 items Source: https://www.vatverify.dev/errors/invalid-format --- ## invalid_requester_vat The requester_vat_number supplied to /v1/confirm is malformed or fails the MOD-11 checksum required for §18e qualified confirmations. **HTTP `422` · Unprocessable Entity** ## Example response ```json { "error": { "code": "invalid_requester_vat", "message": "requester_vat_number 'DE12345678' is invalid: must be DE followed by 9 digits and pass MOD-11 check." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The `/v1/confirm` endpoint requires a valid German USt-IdNr. as `requester_vat_number` to submit a §18e qualified confirmation request to BZSt. BZSt mandates that the requester identify themselves with their own German VAT number so the confirmation can be recorded against their tax record. The number you supplied failed validation before the request was forwarded to BZSt. Common causes: - The number is too short or too long (must be exactly 11 characters: `DE` + 9 digits). - The final digit does not satisfy the MOD-11 checksum. - A Steuernummer (10-13 digits, no `DE` prefix) was supplied instead of the USt-IdNr. - The number contains spaces, dots, or other non-digit characters after the `DE` prefix. ## How to fix 1. Confirm the requester number is exactly 11 characters in the form `DE` followed by 9 digits (e.g. `DE123456789`). 2. Verify the number passes MOD-11. The [MOD-11](/glossary/mod-11) glossary entry explains how the check digit is computed. 3. Do not confuse the USt-IdNr. with the Steuernummer. The Steuernummer is for domestic income-tax purposes and is not accepted by BZSt for §18e confirmations. 4. If you use the same requester number for all calls, set it as the default in your API key settings at `/dashboard/keys` to avoid passing it on every request. ## Related errors - [`invalid_format`](/errors/invalid-format): the target VAT number being validated is malformed ## See also - [USt-IdNr.](/glossary/ust-idnr) - [§18e UStG](/glossary/paragraph-18e-ustg) - [/v1/confirm endpoint](/docs/api/confirmations/confirm) Source: https://www.vatverify.dev/errors/invalid-requester-vat --- ## not_found You are using a test-mode key and sent a VAT number that is not in the magic number registry. **HTTP `404` · Not Found** ## Example response ```json { "error": { "code": "not_found", "message": "VAT number 'DE123456789' not found in test mode registry." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened Test-mode keys (`vtv_test_*`) do not make real registry calls. Instead, they return deterministic responses for a fixed set of magic VAT numbers. Sending a number that isn't in the magic registry returns `not_found`. ## How to fix Use one of the documented magic VAT numbers for test mode: | VAT number | Response | |---|---| | `IE6388047V` | `valid: true`, Google Ireland Limited | | `DE811569869` | `valid: true`, Zalando SE | | `FR44732829320` | `valid: true`, BlaBlaCar SAS | | `IE0000000X` | `valid: false`, no company data | | `DE999999999` | 502 `registry_unavailable` | | Any other well-formed VAT | `valid: true`, synthesized "Magic Corp (XX)" | See the full [Test mode](/docs/test-mode) guide. ## Common mistakes - **Using a real customer's VAT number with a test key:** test keys don't hit the registry. Switch to a live key for real lookups. - **Expecting test keys to behave like live keys for arbitrary inputs:** they don't. Test keys are for CI and integration testing with known inputs. ## Related errors - [`unauthorized`](/docs/errors/unauthorized): key is missing or invalid - [`invalid_format`](/docs/errors/invalid-format): VAT number is malformed Source: https://www.vatverify.dev/errors/not-found --- ## plan_required The endpoint you called requires a higher plan than your current one. **HTTP `402` · Payment Required** ## Example response ```json { "error": { "code": "plan_required", "message": "Batch validation requires a Pro or Business plan." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The endpoint is gated to a higher plan. `POST /v1/validate/batch` requires **Pro or Business**. `POST /v1/decide` requires **Business**. ## How to fix 1. Check your current plan in the dashboard. 2. Upgrade to the required plan, or 3. During development, use a `vtv_test_` key. Test keys bypass all plan gates and have unlimited quota. ## Plan-gate matrix The vatverify endpoint set is layered by plan. As of the current API version: - **Free / Starter / Pro / Business**: `/v1/validate` (single VAT lookup), `/v1/rates`, all read endpoints. - **Pro / Business only**: `/v1/validate/batch` (batch VAT validation up to 50 numbers per request), webhooks. - **Business only**: `/v1/decide` (tax-rules engine), BZSt qualified confirmation, audit log replay beyond 30 days. Hitting `plan_required` always points at the same fix path: either upgrade, or swap the live key for a test key while you build the integration. ## Why test keys bypass plan gates Test mode is intentionally permissive. The contract: test-mode keys (prefix `vtv_test_`) return deterministic synthetic data, never hit live registries, and never count against quota or plan limits. The point is to let developers build and CI-test against the full API surface without upgrading first. A practical consequence: a test integration that works fine in CI will hit `plan_required` the moment it is switched to a live key on a lower plan. The right place to gate-test plan restrictions is in staging with a live key on the actual deployed plan. ## Common mistakes - **Calling `/v1/validate/batch` on a Free or Starter plan**: batch requires Pro+. - **Calling `/v1/decide` on a Pro plan**: decide requires Business. - **Expecting test keys to reflect plan restrictions**: they do not. Use a live key to test plan gating. ## Related errors - [`unauthorized`](/docs/errors/unauthorized): key is missing or invalid entirely - [`rate_limited`](/docs/errors/rate-limited): key is valid but quota is exhausted - [`webhook_limit_reached`](/docs/errors/webhook-limit-reached): webhook slots full Source: https://www.vatverify.dev/errors/plan-required --- ## rate_limited Your monthly quota of live registry calls has been exhausted. **HTTP `429` · Too Many Requests** ## Example response ```json { "error": { "code": "rate_limited", "message": "Monthly quota of 500 exceeded. Resets 2026-05-01T00:00:00Z." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` Headers on this response: ``` X-RateLimit-Limit: 500 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1746057600 Retry-After: 1382400 ``` ## What happened Every plan has a monthly quota of live registry calls. Cache hits are free and don't count. When the quota is exhausted, all further requests that would require a registry call are rejected with `429` until the quota resets on the 1st of the next month. ## How to fix 1. Check `X-RateLimit-Remaining` on every response to track remaining quota before hitting the limit. 2. Respect `Retry-After`. It tells you the exact seconds until reset. Don't retry before then. 3. Use `cache=false` sparingly. Bypassing cache forces a live call and counts quota even if the same number was recently validated. 4. Upgrade your plan for a higher monthly quota. 5. Cache validation results on your side (e.g. in your database) to avoid re-validating the same VAT numbers. ## Common mistakes - **Busy-retrying on 429:** retrying immediately wastes calls and won't succeed. Respect `Retry-After`. - **Not monitoring `X-RateLimit-Remaining`:** quota exhaustion mid-month is avoidable with monitoring. - **Re-validating known customers on every checkout:** store the validation result in your database with a timestamp. Re-validate only when the stored result is older than your compliance requirement (e.g. 30 days). - **Bypassing cache unnecessarily:** `cache=false` is for audited compliance workflows, not default usage. ## Related errors - [`plan_required`](/docs/errors/plan-required): quota is fine but endpoint requires higher plan - [`batch_too_large`](/docs/errors/batch-too-large): request rejected before quota is consumed Source: https://www.vatverify.dev/errors/rate-limited --- ## registry_unavailable The upstream registry (VIES, HMRC, BFS, or Brønnøysund) returned an error or is temporarily down. **HTTP `502` · Bad Gateway** ## Example response ```json { "error": { "code": "registry_unavailable", "message": "VIES fault: MS_MAX_CONCURRENT_REQ" }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 1840 } } ``` ## What happened The upstream registry returned an error, timed out, or is temporarily unavailable. This is not a problem with your request. It is a transient failure at the registry level. VIES in particular is prone to throttling errors (`MS_MAX_CONCURRENT_REQ`, `MS_UNAVAILABLE`) during peak hours. Check `meta.source` in the response to see which registry failed (`vies`, `hmrc`, `bfs`, or `brreg`). ## How to fix Implement retry logic with exponential backoff: ```typescript // Retry 502 with backoff: 250ms → 500ms → 1s const delays = [250, 500, 1000]; let result; for (let i = 0; i <= delays.length; i++) { try { result = await client.validate({ vat_number }); break; } catch (err) { if (err instanceof RegistryError && i < delays.length) { await new Promise(r => setTimeout(r, delays[i])); } else { throw err; } } } ``` The `@vatverify/node` SDK handles this automatically with `max_retries: 2` by default. ## Common mistakes - **Treating 502 as a permanent failure:** registry outages are temporary. Always retry before surfacing an error to the user. - **Retrying with no backoff:** immediate retries under load can worsen throttling. Use backoff. - **Not checking `meta.source_status: "degraded"`:** when `source_status` is `degraded`, we returned a cached result despite the registry being down. The response is still useful. The `valid` value is from a recent real lookup. - **Missing per-item 502s in batch responses:** in batch calls, a `registry_unavailable` per-item error means that specific number failed. The rest of the batch may still have succeeded. Always iterate `results` and handle `ok: false` items. ## Related errors - [`rate_limited`](/docs/errors/rate-limited): quota exhausted (your side, not the registry) Source: https://www.vatverify.dev/errors/registry-unavailable --- ## seller_country_unsupported The seller_vat country prefix is not in EU-27. The /decide endpoint only supports EU sellers. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "seller_country_unsupported", "message": "seller_vat country 'GB' is not supported. /decide covers EU-27 sellers only." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened `POST /v1/decide` only calculates VAT treatment for EU-27 sellers. The `seller_vat` you passed has a country prefix outside the EU-27 (e.g. GB, CH, NO). Non-EU VAT rules involve different tax frameworks that are out of scope for v1. ## How to fix 1. Verify the `seller_vat` prefix is one of the EU-27 country codes (AT, BE, BG, CY, CZ, DE, DK, EE, EL, ES, FI, FR, HR, HU, IE, IT, LT, LU, LV, MT, NL, PL, PT, RO, SE, SI, SK). 2. For UK sellers (GB), apply UK VAT rules directly. UK B2B exports to EU are generally zero-rated. 3. For CH/NO sellers, consult local tax regulations. These are outside the EU VAT system. ## Common mistakes - **Passing a UK (GB) seller VAT:** UK left the EU VAT system post-Brexit. `/decide` does not cover GB sellers. - **Confusing XI (Northern Ireland) with GB:** XI is under the EU VAT rules for goods (Brexit protocol) and is supported. - **Passing a Swiss (CH) or Norwegian (NO) seller:** not supported by `/decide`. ## Related errors - [`buyer_vat_not_registered`](/docs/errors/buyer-vat-not-registered): seller is fine but buyer VAT is invalid - [`b2c_not_supported`](/docs/errors/b2c-not-supported): seller is fine but transaction is B2C Source: https://www.vatverify.dev/errors/seller-country-unsupported --- ## unauthorized The API key is missing, malformed, or has been revoked. **HTTP `401` · Unauthorized** ## Example response ```json { "error": { "code": "unauthorized", "message": "Missing API key." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened The `Authorization` header was absent, the key format was invalid, or the key has been revoked. Authentication happens before any other processing. This error costs no quota. ## How to fix 1. Confirm the `Authorization` header is present on every request: `Authorization: Bearer vtv_live_xxx` 2. Check that your key is valid and not revoked in the dashboard. 3. If you revoked the key by mistake, generate a new one. Old keys cannot be restored. 4. For local development, use a `vtv_test_` key. It behaves identically but never counts quota. ## Why authentication runs first `unauthorized` is the cheapest error vatverify can return: it short-circuits before any quota counting, registry lookup, or plan-gate check. The trade-off is that downstream errors mask each other. If your key is revoked, you will see `unauthorized` even on requests that would have failed for other reasons (rate limit, plan gate, format). Fix the auth issue first, then the next layer of errors becomes visible. For debugging, the `request_id` in the response is the key piece of context. vatverify retains request logs for support purposes; producing the `request_id` lets us trace exactly which key was attempted and at what time. ## Key prefixes vatverify keys follow a fixed prefix scheme: - **`vtv_live_`**: live keys, count against quota and plan gates apply. - **`vtv_test_`**: test-mode keys, return deterministic synthetic data, never count quota, bypass plan gates. A key that does not start with one of these prefixes will fail the format check and return `unauthorized` without a registry lookup. If you suspect your environment is mangling the key (whitespace, BOM, copy-paste artifacts), the prefix mismatch is usually the first symptom. ## Common mistakes - **Missing the `Bearer ` prefix**: the value must be `Bearer vtv_live_xxx`, not just `vtv_live_xxx`. - **Passing the key as a query param**: `?api_key=vtv_live_xxx` is not supported. The header is required. - **Using a test key for integration tests against endpoints that need a live key**: test keys work everywhere; the reverse is not true. - **Whitespace around the key**: trailing newlines from env file parsing (`vtv_live_xxx\n`) will cause this error. - **Key revoked from a different team member's dashboard**: keys revoked by anyone with access cannot be restored. Generate a new one. ## Related errors - [`plan_required`](/docs/errors/plan-required): key is valid but the endpoint requires a higher plan - [`rate_limited`](/docs/errors/rate-limited): key is valid but quota is exhausted - [`webhook_limit_reached`](/docs/errors/webhook-limit-reached): webhook slots full Source: https://www.vatverify.dev/errors/unauthorized --- ## webhook_limit_reached You have reached the maximum of 5 registered webhook endpoints for this API key. **HTTP `400` · Bad Request** ## Example response ```json { "error": { "code": "webhook_limit_reached", "message": "Maximum of 5 webhook endpoints per key." }, "meta": { "request_id": "a1b2c3d4-...", "latency_ms": 0 } } ``` ## What happened Each API key can have at most 5 registered webhook endpoints. This limit prevents runaway registration loops. Deletion is the off switch: remove an existing endpoint to free a slot. ## How to fix 1. List your current endpoints with `GET /v1/webhooks` to see what is registered. 2. Delete an endpoint you no longer need with `DELETE /v1/webhooks/{id}`. 3. Retry your registration request. ## Why the limit exists The 5-endpoint cap is per API key, not per account. The cap protects two failure modes: - **Registration loops**: a buggy deploy script that re-registers the same webhook on every boot will hit the cap quickly. Hitting `webhook_limit_reached` is the signal to fix the loop, not to lift the cap. - **Fan-out cost**: every webhook event is delivered to every registered endpoint with retries on failure. A larger cap on a low-tier plan would let one customer generate disproportionate outbound traffic. If your architecture genuinely needs more than 5 endpoints under one key, the typical pattern is to fan out internally from a single webhook receiver to multiple downstream consumers. That keeps the cap for vatverify simple and moves the multiplexing into your own infrastructure where you can scale it. ## Edge cases - **Endpoint with the same URL counts as a separate registration.** If you accidentally register the same URL twice, both consume slots. List with `GET /v1/webhooks` and dedupe. - **A revoked endpoint still counts until deleted.** Disabling an endpoint via the dashboard pauses delivery but does not free the slot. Use `DELETE /v1/webhooks/{id}` to actually remove it. - **Test-mode keys have the same cap.** Test-mode webhooks are useful for local development but the 5-endpoint cap applies; rotate test endpoints aggressively rather than accumulating them. ## Related errors - [`plan_required`](/docs/errors/plan-required): webhooks require Pro or Business plan - [`invalid_format`](/docs/errors/invalid-format): webhook URL must be HTTPS - [`unauthorized`](/docs/errors/unauthorized): API key missing or revoked Source: https://www.vatverify.dev/errors/webhook-limit-reached --- # Glossary ## Brønnøysund The Norwegian central business register (Brønnøysundregistrene, BRREG) that issues 9-digit organization numbers and tracks VAT registration for Norwegian entities. ## What it is Brønnøysundregistrene (BRREG) is Norway's national business register authority, located in the town of Brønnøysund on the Norwegian coast. It operates multiple national registers, including the Register of Business Enterprises (Foretaksregisteret), the Register of Mortgages, and the VAT register (Merverdiavgiftsregisteret). Every Norwegian organization registered in the country receives a 9-digit organization number (organisasjonsnummer) from BRREG. ## Where you meet it You meet BRREG whenever you validate a Norwegian VAT number. Because Norway is a member of the European Economic Area (EEA) but not the EU, Norwegian VAT numbers do not appear in VIES. Validation goes directly to BRREG via its public REST API at `data.brreg.no`, which is freely accessible and requires no authentication. The BRREG API returns entity name, address, organization form, and a separate MVA registration flag. The MVA flag is the authoritative indicator of whether an organization is VAT-registered. It is tracked separately from the organization number, because VAT registration is a status the organization can gain or lose, while the organization number is permanent. ## How vatverify uses it When you submit a `NO`-prefixed number to `/v1/validate`, vatverify queries `data.brreg.no`, normalises the response, and returns the standard validation payload including `valid`, `name`, `address`, and `vatRegistered`. The `meta.source` field will show `brreg` to distinguish this from a VIES call. ## Common confusions - Norwegian numbers do not appear in VIES. Do not attempt to validate a Norwegian organization number through the VIES endpoint directly; it will return a negative result even for perfectly valid VAT-registered Norwegian companies. - The 9-digit organization number is not the same as the VAT number. The VAT-facing identifier is `NO` followed by the organization number, optionally with the MVA suffix. vatverify accepts all three forms and normalises them. ## See also - [MVA suffix](/glossary/mva-suffix) - [VAT number](/glossary/vat-number) - [Norway VAT checker](/check/norway) - [Norwegian VAT and BRREG guide](/guides/norwegian-vat-brreg) Source: https://www.vatverify.dev/glossary/bronnoysund --- ## BZSt Germany's Bundeszentralamt für Steuern, the federal tax office that runs the §18e qualified VAT confirmation service for German sellers making intra-EU supplies. ## What it is The Bundeszentralamt für Steuern (BZSt) is Germany's Federal Central Tax Office. It operates independently of the regional Finanzamt offices and handles cross-border and central tax functions that cannot be delegated to any single state (Bundesland). For VAT purposes, the most relevant BZSt function is the eVatR service (elektronische Validierung und Rückgabe), which implements the §18e UStG qualified VAT number confirmation. ## Where you meet it German sellers making VAT-free intra-EU supplies under the zero-rate rule must confirm that their buyer holds a valid VAT number. A plain VIES check satisfies the minimum requirement, but only a BZSt qualified confirmation provides the enhanced legal protection described in §18e UStG. The BZSt confirmation matches not just the VAT number but also the buyer's registered name, street, postcode, and town, returning per-field match codes (A, B, C, or D). The BZSt eVatR endpoint is at `api.evatr.vies.bzst.de`. In vatverify, calls to `/v1/confirm` forward to this endpoint automatically, translate the SOAP response into structured JSON, and surface the match codes alongside the standard validation fields. ## Why it differs from the Finanzamt The Finanzamt is the regional tax office that handles income tax assessments and issues the domestic Steuernummer for individual taxpayers and businesses. The BZSt handles EU-facing identifiers, issues the USt-IdNr. (Germany's EU VAT number), and operates the eVatR API that other EU member states' businesses also use when trading with German counterparties. ## Evidence retention BZSt qualified confirmation records must be retained for 10 years under §147 AO (Abgabenordnung). vatverify retains confirmation responses on your behalf for this period by default, including the BZSt status codes and per-field match results, so you have audit-ready evidence without managing the archive yourself. ## See also - [§18e UStG](/glossary/paragraph-18e-ustg) - [USt-IdNr.](/glossary/ust-idnr) - [VIES](/glossary/vies) - [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) - [Germany VAT checker](/check/germany) Source: https://www.vatverify.dev/glossary/bzst --- ## Checksum An arithmetic check digit built into a VAT number so that format-level validation can reject typos before any registry call. ## What it is A checksum is a digit (or short sequence) derived from the other characters in an identifier by a fixed algorithm. For VAT numbers, the checksum lets a validator confirm the number is internally consistent without contacting a registry. Every EU member state publishes a checksum rule for its VAT number format; the algorithms vary from MOD-11 (Norway, Switzerland) to MOD-97 (Belgium) to Luhn-style weightings (Germany, Italy, Spain, and others). ## Where you meet it You meet the checksum in the first branch of a VAT number validation function. In vatverify's tax-rules engine, the format check runs before any call to VIES, HMRC, the Swiss BFS, or Norwegian Skatteetaten. A checksum mismatch short-circuits the call and returns `invalid_format` against `/v1/validate`, saving a round-trip to the registry. In code, each country typically has its own `isChecksumValid(digits)` helper keyed on the VAT number's country prefix. ## Common confusions - A passing checksum does not mean the number is registered. It only means the string is well-formed; registration status is a separate registry call. - Not every country uses the same scheme. A number that validates for Germany with a MOD-11 variant will not validate for France with the SIREN-based rule. ## See also - [MOD-11 checksum](/glossary/mod-11) - [VAT number](/glossary/vat-number) - [invalid_format error](/errors/invalid-format) Source: https://www.vatverify.dev/glossary/checksum --- ## Consultation number A reference identifier returned by VIES that proves you queried a VAT number on a specific date, used as evidence for reverse-charge invoicing. ## What it is A consultation number is a unique reference issued by VIES (the `requestIdentifier` field in the `checkVatApprox` SOAP response) when you query another company's VAT number and supply your own VAT number as the requester. It ties your query to a date and a result, so the tax authority of your member state treats the lookup as "consulted in good faith" for the purpose of applying reverse charge. ## Where you meet it You meet the consultation number in two places. First, in the VIES SOAP response: the `requestIdentifier` element appears only when the `` and `` inputs are present. Second, in your invoice archive: some member states require you to store the consultation number alongside the invoice as proof that the buyer's VAT number was confirmed valid at the time of supply. In vatverify's `/v1/validate` response, it is surfaced as `consultationNumber` when the upstream VIES call returned one. ## Why it matters in audit The consultation number is the audit-trail artifact that proves a seller exercised due diligence before zero-rating an intra-Community supply. If a tax administration later challenges whether the buyer's VAT number was valid at the time of the invoice, producing the consultation number and the matching VIES response is the strongest possible defence. For German sellers under §18e UStG, the BZSt qualified confirmation produces an analogous artifact: a per-field match-code grid showing name, street, postcode, and town confirmations. The two systems coexist; a German seller invoicing a French buyer can rely on either or both depending on the audit posture they want to maintain. ## Storage requirements EU rules require sellers to retain VAT records for a minimum period set by each member state, typically 6 to 10 years. The consultation number is part of that record set. Persist it on the invoice header alongside the buyer VAT number, the date of supply, and the resulting tax treatment, so the full chain of evidence is reconstructible from the invoice alone. vatverify's audit log on the Pro and Business plans stores the consultation number alongside the raw VIES payload by default; the audit log is exportable as JSON for archival into your accounting system. ## Common confusions - A plain `checkVat` call (no requester VAT number supplied) does not return a consultation number. You need `checkVatApprox` with both your country code and VAT number in the request. - The consultation number does not extend validity beyond the moment of the query. A buyer deregistering the next day still means the next sale needs a fresh check. - A consultation number is not interchangeable with a BZSt qualified confirmation. The BZSt artifact is a per-field match-code grid; the VIES consultation number is a single opaque identifier referencing the moment of the lookup. ## See also - [VIES](/glossary/vies) - [Reverse charge](/glossary/reverse-charge) - [§18e UStG (German qualified confirmation)](/glossary/paragraph-18e-ustg) - [VIES API guide](/guides/vies-api-guide) Source: https://www.vatverify.dev/glossary/consultation-number --- ## HMRC VRN The UK VAT registration number issued by HM Revenue and Customs to businesses registered for VAT in Great Britain. ## What it is An HMRC VRN (VAT registration number) is the nine-digit identifier issued by HM Revenue and Customs to a UK-registered business, typically displayed with a `GB` prefix (for example `GB123456789`). Northern Ireland businesses trading goods with the EU also receive an `XI`-prefixed number that remains validatable through VIES. ## Where you meet it You meet HMRC VRNs on UK sales invoices (the supplier VRN is a mandatory field under the Value Added Tax Regulations 1995, reg. 14) and in the public Check a UK VAT number service operated by HMRC at `api.service.hmrc.gov.uk/organisations/vat/check-vat-number/lookup/{vrn}`. In vatverify, a GB-prefixed number is routed to HMRC's endpoint rather than VIES; the response includes the registered name and address, which surfaces in `/v1/validate` as the `registeredName` field. ## Two valid lengths A GB VAT number can be 9 or 12 digits after the prefix: - **9 digits**: standard form, covers the majority of UK businesses. - **12 digits**: branch trader number, the 9-digit root plus a 3-digit branch suffix used by companies with multiple VAT-registered branches. Validators that hardcode `GB\d{9}` reject correctly-formatted branch trader numbers. The right pattern accepts both lengths. Two legacy forms also still appear: `GBGD###` for government departments and `GBHA###` for health authorities. See the [UK page](/check/united-kingdom) for the full format detail. ## Post-Brexit routing Since 1 January 2021, GB-prefixed VAT numbers are no longer queryable through VIES. The EU Commission removed GB from the VIES system, and HMRC's own API became the authoritative endpoint. Validators that route GB through legacy VIES integrations either return errors or stale results. XI-prefixed Northern Ireland numbers continue to flow through VIES under the Windsor Framework, because Northern Ireland goods trade with the EU still operates under EU VAT rules. See the [Northern Ireland page](/check/northern-ireland) for the dual-number context. ## Common confusions - Since 1 January 2021, GB VRNs are no longer queryable through VIES. Validating a UK number through VIES after that date returns an error or a cached "invalid" result. - The 9-digit VRN uses a weighted-checksum algorithm (HMRC's "7-cube" rule with subsequent revisions for numbers above 9,990,000,000), so a typo is caught at format level before the HMRC call. - A GB VRN does not validate the same way as an XI VRN. They have the same 9-digit numeric structure but route to different registries (HMRC vs VIES) and have different legal effects for VAT purposes. ## See also - [HMRC VAT check API guide](/guides/hmrc-vat-check-api) - [VIES](/glossary/vies) - [Checksum](/glossary/checksum) - [United Kingdom VAT check](/check/united-kingdom) - [Northern Ireland VAT check](/check/northern-ireland) Source: https://www.vatverify.dev/glossary/hmrc-vrn --- ## IOSS The EU Import One-Stop-Shop scheme for declaring VAT on low-value goods imported from outside the EU and sold to EU consumers. ## What it is IOSS (Import One-Stop-Shop) is the EU scheme, live since 1 July 2021, for collecting VAT on distance sales of goods imported from outside the EU with an intrinsic value of €150 or less. The seller (or the electronic interface facilitating the sale) charges the destination-country VAT at checkout and declares it through a single monthly IOSS return. ## Where you meet it You meet IOSS as a 12-character identifier starting with "IM" (for example `IM1234567890`), which you quote on the customs declaration so the parcel clears without VAT being collected again at the border. In a tax-rules engine, IOSS is the branch that fires when the goods ship from outside the EU, the consignment value is at or below €150, and the buyer is a non-business EU consumer. Stripe Tax exposes the "IOSS" option on its registrations page. ## What IOSS replaced Before 1 July 2021, the EU operated a `low-value consignment relief` that exempted imports under €22 from VAT entirely. The IOSS reform removed that exemption and made every imported consignment to EU consumers subject to VAT, with IOSS as the simplification mechanism. Since the reform, every parcel arriving from outside the EU carries VAT, regardless of value, with collection happening either at checkout (under IOSS) or at the border (without IOSS). ## Why sellers register for IOSS Without IOSS, parcels arrive at the EU border without prepaid VAT. Customs collects VAT (and often a handling fee from the postal carrier) before releasing the parcel to the consumer. The consumer experience is poor: surprise charges and delivery delays are routine. With IOSS, the seller charges destination VAT at checkout, the parcel clears customs without further VAT collection, and the consumer experience matches a domestic purchase. This is why most cross-border e-commerce platforms now require their sellers to register for IOSS or to provide an IOSS number from a fiscal intermediary. ## Common confusions - The €150 threshold is the intrinsic value of the goods, excluding transport and insurance shown separately on the invoice. - IOSS numbers are not validated through VIES. They are issued by a single member state of identification and checked via the EU's dedicated IOSS registry lookup. - A non-EU seller registering for IOSS typically needs an EU-established intermediary unless the seller is established in a country with a mutual-assistance agreement with the EU on VAT. ## See also - [OSS](/glossary/oss) - [MOSS](/glossary/moss) - [Place of supply](/glossary/place-of-supply) Source: https://www.vatverify.dev/glossary/ioss --- ## MOD-11 checksum A weighted modulo-11 algorithm used by several European tax authorities to protect VAT numbers against transcription errors. ## What it is MOD-11 is a family of checksum algorithms where each digit of an identifier is multiplied by a position-dependent weight, the weighted sum is taken modulo 11, and the result determines a check digit that travels with the number. Norway's organisation number (used as the VAT identifier when suffixed with "MVA") and Switzerland's UID both rely on a MOD-11 variant to reject single-digit typos and most digit swaps. ## Where you meet it You meet MOD-11 at the format-validation stage, before any registry call. In vatverify's `/v1/validate`, a MOD-11 mismatch produces an `invalid_format` error for Norwegian (`NO`) and Swiss (`CHE`) VAT numbers, so you never waste a registry lookup on a number that cannot be valid. In code, the Norwegian check uses weights `3, 2, 7, 6, 5, 4, 3, 2` over the first eight digits; the Swiss UID uses weights `5, 4, 3, 2, 7, 6, 5, 4` over its nine significant digits. ## Common confusions - A MOD-11 remainder of 10 means the number is invalid by construction. Norwegian organisation numbers with that remainder are skipped during issuance. - MOD-11 only proves the number is well-formed. A well-formed number can still be unregistered or deregistered; only a registry lookup confirms current status. ## See also - [Checksum](/glossary/checksum) - [Swiss UID](/glossary/swiss-uid) - [invalid_format error](/errors/invalid-format) Source: https://www.vatverify.dev/glossary/mod-11 --- ## MOSS The EU Mini One-Stop-Shop scheme for cross-border B2C digital services, replaced by the wider OSS regime on 1 July 2021. ## What it is MOSS (Mini One-Stop-Shop) was the EU scheme introduced on 1 January 2015 that let suppliers of telecommunications, broadcasting and electronically supplied services declare VAT on B2C sales to EU consumers through a single return. It was superseded by the broader OSS on 1 July 2021, which extended the same idea to distance sales of goods and other services. ## Where you meet it You still meet MOSS in legacy data: historical VAT returns, older invoicing templates, and accounting exports covering periods up to Q2 2021. Tax-rules engines that support historical recalculation will keep a `"scheme": "moss"` branch for dates before 1 July 2021. Current registrations and filings use OSS. ## What MOSS solved Before MOSS, a digital-services supplier selling B2C across the EU was technically required to register for VAT in every member state where it had consumers. The compliance overhead made cross-border B2C digital services impractical for small and mid-sized sellers. MOSS replaced that with a single registration in the supplier's member state of identification, with a quarterly return covering all EU consumer sales of in-scope services. The 2015 MOSS launch coincided with the change in place-of-supply rules for B2C digital services, which moved from the supplier's location to the consumer's location. MOSS was the simplification that made the new place-of-supply rules workable without forcing every digital seller into 28 separate VAT registrations. ## What replaced it The 2021 OSS reform extended the MOSS principle in three ways: it covered distance sales of goods (not just digital services), it included most other cross-border B2C services, and it added the parallel IOSS scheme for low-value imports. See [OSS](/glossary/oss) and [IOSS](/glossary/ioss) for the current schemes. ## Common confusions - MOSS covered only digital services (TBE services). OSS extended the scheme to physical goods and most other cross-border B2C services. - MOSS is not gone from the law books overnight. Corrections to old MOSS returns are still filed through the member state of identification where the original registration lived. - "Non-Union MOSS" was the variant for non-EU-established digital-services suppliers; that variant rolled into "Non-Union OSS" with the 2021 reform. ## See also - [OSS](/glossary/oss) - [IOSS](/glossary/ioss) - [Place of supply](/glossary/place-of-supply) Source: https://www.vatverify.dev/glossary/moss --- ## MTIC fraud Missing Trader Intra-Community fraud, a VAT scheme exploiting the EU's reverse-charge rules where shell companies vanish before remitting collected VAT. ## What it is Missing Trader Intra-Community (MTIC) fraud is a structured VAT evasion scheme that exploits the zero-rate applied to intra-EU B2B supplies. In its basic form, a supplier in member state A sells goods to a "missing trader" in member state B at zero-rated VAT. The missing trader then sells the goods on to a domestic buyer in member state B, charging full VAT but disappearing before remitting it to the tax authority. The domestic buyer, or a connected "broker" further down the chain, then claims an input VAT refund. The result is that VAT is refunded by the treasury without ever having been collected. "Carousel fraud" is the term used when the same goods are cycled through the chain repeatedly, multiplying the fraudulent refund claims. ## Where it concentrates MTIC fraud concentrates in goods with high value relative to volume: mobile phones, laptops, microprocessors, gold, and more recently carbon credits and telecoms bandwidth. These goods allow large VAT amounts to be moved through a short chain of transactions quickly. ## Why it matters for VAT validation A hallmark of MTIC fraud is that the missing trader registers a VAT number with a hollow or fictional address. When a German seller performs a §18e BZSt qualified confirmation and submits the buyer's name and address for matching, a shell company with a fabricated address will typically return a C-code on the address fields, indicating no match. This is one of the practical signals that a §18e confirmation is designed to surface. EU Commission reports have estimated annual EU-wide losses from MTIC and related VAT fraud at tens of billions of euros, though the precise figure varies by methodology and year. ## Common confusions - MTIC fraud targets the seller's zero-rate, not the buyer's input-tax claim, in the first step. Sellers who apply the zero-rate and can demonstrate good-faith validation are generally protected from claw-back. Sellers who cannot demonstrate good-faith checks may be held liable even if they were unknowingly part of the chain. - Not every chain transaction is MTIC. Legitimate businesses trade in the same goods. The risk is a matter of due diligence, not a blanket prohibition on trading in high-risk categories. ## See also - [§18e UStG](/glossary/paragraph-18e-ustg) - [Reverse charge](/glossary/reverse-charge) - [VIES](/glossary/vies) - [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) Source: https://www.vatverify.dev/glossary/mtic-fraud --- ## MVA suffix The optional MVA suffix on Norwegian organization numbers, indicating the organization is registered for merverdiavgift (Norwegian VAT). ## What it is MVA stands for merverdiavgift, the Norwegian term for value-added tax. The MVA suffix is appended to a Norwegian organization number to indicate that the entity is VAT-registered. The full format is `NO` followed by a 9-digit Brønnøysund organization number, optionally followed by `MVA` (e.g. `NO123456789MVA`). ## Where you meet it The MVA suffix appears on Norwegian VAT invoices and in VAT registration certificates. It is not structurally part of the organization number itself; it is a registration indicator. This means two Norwegian organizations can have organization numbers that look identical except for the suffix, and only one is VAT-registered. Not every Norwegian organization is required to register for VAT. Registration is mandatory once turnover exceeds a defined threshold. Smaller entities, associations, and public bodies below the threshold hold an organization number without the MVA suffix. ## How vatverify handles it When you submit a Norwegian number to `/v1/validate`, vatverify queries the Brønnøysund register (BRREG) and returns a `vatRegistered` flag in the response indicating whether the organization holds an active VAT registration. This is accurate regardless of whether you included the MVA suffix in your input: vatverify normalises the input and checks the registration status directly from BRREG. ## Common confusions - The MVA suffix is not part of the numeric identifier. Stripping it does not change which organization is being referred to. - A missing MVA suffix does not mean the organization is not VAT-registered. The only reliable way to confirm registration status is to query BRREG directly, which vatverify does on every `/v1/validate` call for NO-prefixed numbers. ## See also - [Brønnøysund](/glossary/bronnoysund) - [VAT number](/glossary/vat-number) - [Norway VAT checker](/check/norway) - [Norwegian VAT and BRREG guide](/guides/norwegian-vat-brreg) Source: https://www.vatverify.dev/glossary/mva-suffix --- ## OIB Croatia's Osobni identifikacijski broj, an 11-digit identifier used for both individuals and legal entities, with the same number serving as the Croatian VAT number when prefixed with HR. ## What it is The OIB (Osobni identifikacijski broj, or Personal Identification Number) is Croatia's universal identifier for natural persons and legal entities. It is an 11-digit number issued by the Tax Administration (Porezna uprava) and was introduced in 2009 to replace a fragmented set of earlier identifiers. Every Croatian taxpayer, individual or company, holds exactly one OIB for life. ## Where you meet it For EU VAT purposes, the Croatian VAT number is the OIB prefixed with `HR`, giving a 13-character string: `HR` followed by the 11-digit OIB (e.g. `HR12345678901`). This is the number queried through VIES when validating a Croatian counterparty. The same OIB also appears on Croatian income tax filings, social security records, and public company registry entries, because a single number serves all these purposes. ## Checksum The last digit of every OIB is a check digit computed using a MOD-11 algorithm. A number where the computed check digit would be 10 is not issued. This means a single transposition in any of the first 10 digits can be detected before a registry call is made. ## Common confusions - The OIB is used for both individuals and legal entities, which is unusual compared to most EU countries that maintain separate identifier series. A company OIB and a director's personal OIB are structurally identical. - The HR prefix is required for EU VAT validation. The 11-digit OIB alone, without the prefix, will not resolve in VIES. ## See also - [VAT number](/glossary/vat-number) - [MOD-11 checksum](/glossary/mod-11) - [Croatia VAT checker](/check/croatia) Source: https://www.vatverify.dev/glossary/oib --- ## OSS The EU One-Stop-Shop scheme that lets a seller declare and pay VAT on cross-border B2C sales to EU consumers through a single quarterly return. ## What it is OSS (One-Stop-Shop) is the EU scheme, live since 1 July 2021, that lets a business register in one member state and report VAT on distance sales of goods and services to consumers across the other 26 member states through a single electronic return. It replaced the narrower MOSS scheme and raised the country-by-country distance-selling thresholds into a single EU-wide threshold of €10,000 per year. ## Where you meet it You meet OSS in your tax-rules engine as a branch that fires when the sale is B2C, the seller and buyer are in different EU member states, and the EU-wide €10,000 threshold has been exceeded. In Stripe Tax, enabling OSS is done on the registrations page by adding an "OSS Union" entry against your home country. In a typed decision payload, the rule surfaces as `"scheme": "oss_union"` with the destination country's VAT rate applied. ## Three OSS schemes There are three OSS variants, each for a different category of seller: - **Union OSS**: for EU-established sellers making intra-EU B2C distance sales of goods and certain services. - **Non-Union OSS**: for non-EU-established sellers supplying B2C services to EU consumers (for example, a US SaaS company selling to EU consumers). - **Import OSS (IOSS)**: a separate registration for distance sales of imported goods up to a per-consignment value of €150. See [IOSS](/glossary/ioss) for the full mechanic. The three schemes are reported on different OSS returns; a business may register for one, two, or all three depending on its business model. ## The €10,000 threshold The €10,000 threshold is calculated on the prior calendar year's intra-EU B2C distance sales of goods plus telecommunications/broadcasting/electronic services. Below the threshold, the seller charges VAT at their domestic rate; above it, OSS applies and the destination country's rate is charged. Once a seller crosses the threshold in a year, OSS applies for the rest of that year and the entire following year regardless of subsequent volume. ## Common confusions - OSS is for B2C only. Cross-border B2B inside the EU uses reverse charge instead. - OSS covers intra-EU sales. Imports from outside the EU to EU consumers are covered by IOSS, a separate registration. - OSS does not validate the buyer's VAT number; it applies because the buyer is a consumer, not a business. ## See also - [IOSS](/glossary/ioss) - [MOSS](/glossary/moss) - [Reverse charge](/glossary/reverse-charge) - [Place of supply](/glossary/place-of-supply) Source: https://www.vatverify.dev/glossary/oss --- ## §147 AO The German tax-records retention rule requiring documents that support tax-relevant claims to be kept for 10 years, including BZSt qualified confirmation evidence under §18e UStG. ## What it is §147 of the Abgabenordnung (AO, Germany's General Tax Code) sets out the retention periods for tax-relevant records. The general rule for accounting books, invoices, and records that underpin tax claims is 10 years. A shorter 6-year period applies to "other tax-relevant documents" that do not directly support a specific tax-reducing claim. ## Where you meet it §147 AO is the basis for the 10-year retention requirement that applies to BZSt qualified confirmation records obtained under §18e UStG. When a German seller applies the zero-rate on an intra-EU supply and holds a §18e qualified confirmation as evidence, that confirmation document is a record that "directly supports a tax-relevant claim" (the zero-rate). The 10-year clock starts at the end of the calendar year in which the last entry was made in relation to that transaction. ## Digital retention §147 AO explicitly permits digital storage. The requirements are that the records remain legible, can be made available to the tax authority on request, and their integrity is assured (no alteration after archiving). A PDF export of the BZSt confirmation is acceptable, as is machine-readable JSON stored in a compliant archive. The key requirement is that you can reproduce the confirmation in human-readable form during a tax audit. ## How vatverify handles retention vatverify retains BZSt qualified confirmation responses, including the per-field match codes and the raw BZSt status codes, for 10 years from the date of the confirmation. This is the default for all plans that include the `/v1/confirm` endpoint. The retention is designed to meet the §147 AO standard so you do not need to manage a separate archive for compliance evidence. ## See also - [§18e UStG](/glossary/paragraph-18e-ustg) - [BZSt](/glossary/bzst) - [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) Source: https://www.vatverify.dev/glossary/paragraph-147-ao --- ## §18e UStG The German law requiring sellers making VAT-free intra-EU supplies to obtain a qualified confirmation from BZSt that the foreign customer's VAT number and company details match. ## What it is §18e of the Umsatzsteuergesetz (UStG, Germany's VAT Act) entitles a German seller to obtain a qualified confirmation from the Bundeszentralamt für Steuern (BZSt) that a foreign customer's VAT number is valid and that the registered name and address details on file match the information shown on the invoice. ## Where you meet it You meet §18e UStG whenever a German business makes a VAT-free intra-EU supply to a buyer in another EU member state and wants to protect its legal position in a tax audit. The zero-rate applies only if the buyer is a VAT-registered business. If the buyer's number turns out to be invalid, or the registered details don't match the invoice, the German seller can lose the zero-rate and become liable for the output VAT. A "simple" VIES check (querying whether the number is currently valid) is the baseline requirement. The §18e qualified confirmation goes further: it asks BZSt to compare the buyer's VAT number against four additional fields. ## What BZSt matches BZSt returns a match code for each of the following fields, compared between what you supplied and what is on the BZSt/VIES register: | Field | Match codes | |---|---| | Company name | A (match), B (similar), C (no match), D (not requested) | | Street | A / B / C / D | | Postcode | A / B / C / D | | Town | A / B / C / D | A result of A or B on all fields is considered a positive qualified confirmation. A C result on any field means the supplied detail does not match the register and you should verify the invoice details directly with the buyer. ## Why it matters A positive §18e qualified confirmation shifts the burden of proof to the tax authority. If you relied on the confirmation in good faith and the buyer's VAT number was fraudulently issued or the details were misrepresented, the German tax authority cannot simply claw back the zero-rate from you. Without the confirmation, the seller bears the full risk if the buyer's status turns out to be wrong. This protection is particularly relevant where MTIC fraud is a risk in the supply chain. ## See also - [BZSt](/glossary/bzst) - [USt-IdNr.](/glossary/ust-idnr) - [BZSt §18e qualified confirmation guide](/guides/bzst-paragraph-18e-qualified-confirmation) - [Germany VAT checker](/check/germany) Source: https://www.vatverify.dev/glossary/paragraph-18e-ustg --- ## Place of supply The country whose VAT rules govern a transaction, determined by fixed rules that differ for goods, services, B2B, and B2C. ## What it is Place of supply is the legal concept, defined in Articles 31 to 61 of the EU VAT Directive 2006/112/EC, that decides which country's VAT applies to a given transaction. For B2B services the default place of supply is where the customer is established; for B2C services to EU consumers it is generally where the supplier is established, with exceptions for digital services and distance sales of goods. ## Where you meet it You meet place of supply as the first decision a tax-rules engine makes. In a vatverify decision payload, it appears as `"placeOfSupply": "DE"` (or another ISO 3166-1 alpha-2 country code), and it drives downstream fields such as the applicable VAT rate, whether reverse charge applies, and whether OSS or IOSS is in scope. On a VAT invoice, place of supply is implied by the tax rate line and the country code on both parties' VAT numbers. ## Four headline rules Most place-of-supply outcomes can be explained by four rules: - **B2B services (general rule)**: place of supply is the customer's country. The seller invoices net of VAT and the buyer self-accounts under reverse charge. - **B2C services (general rule)**: place of supply is the supplier's country. The seller charges domestic VAT. - **B2C digital services across the EU**: place of supply is the consumer's country. The seller charges destination VAT and reports through OSS. - **Goods (intra-Community)**: place of supply is where the goods are when the transport begins for B2C distance sales above the OSS threshold; the destination country for B2B intra-Community supplies (combined with reverse charge). The framework is deceptively simple at this level and extremely complex at the edges. Articles 31 to 61 of the Directive cover dozens of special cases: passenger transport, real estate, restaurant and catering, cultural and educational events, and many others. ## Evidence requirements for B2C digital services For B2C digital services, the consumer's country drives place of supply, so the seller has to evidence that country at the moment of sale. EU rules require the seller to maintain at least two non-contradictory pieces of evidence: typically the billing address, the IP address, and the country of the payment-method issuer. Three matching signals out of three is the strongest defensible position; two-of-three is the regulatory minimum. A tax-rules engine that emits `"placeOfSupply"` should also expose the evidence it used to reach that conclusion, so an auditor can reconstruct the decision later. ## Common confusions - Place of supply is not the same as the shipping address. For B2B services it follows the buyer's establishment, even if the deliverable is consumed elsewhere. - For digital services to EU consumers, the place of supply is the consumer's country, which is why evidence of location (IP address, billing address, bank country) matters. - Place of supply is what determines the rate; the VAT number validation determines whether reverse charge applies on top of that rate. ## See also - [OSS](/glossary/oss) - [Reverse charge](/glossary/reverse-charge) - [VAT number](/glossary/vat-number) Source: https://www.vatverify.dev/glossary/place-of-supply --- ## Reverse charge A VAT mechanism that shifts the obligation to account for VAT from the supplier to the business customer in cross-border B2B sales. ## What it is Reverse charge is the rule that moves the liability to report VAT from the seller to the buyer when both are VAT-registered businesses in different EU member states. The seller issues a zero-rated invoice; the buyer self-accounts for VAT at their local rate in their own return. ## Where you meet it You meet reverse charge whenever a tax-rules engine returns `"reverseCharge": true` for a cross-border B2B sale. On the invoice side, EU rules require the supplier to include a note such as "Reverse charge" (Article 226(11a) of the VAT Directive 2006/112/EC) and omit their own VAT line. In Stripe Tax, this shows up as a zero tax rate with a reason of `reverse_charge`. In vatverify's `/v1/validate` response, the `decision` object surfaces the reverse-charge determination alongside the VAT number validation result. ## EU intra-Community vs domestic reverse-charge Reverse charge appears in two distinct contexts that share the same name: - **EU intra-Community reverse-charge**: the cross-border B2B mechanism described above, anchored in Article 138 (zero-rate on intra-Community supplies) and Article 196 (recipient liable) of the EU VAT Directive. Triggered by a valid VAT number on the buyer side. - **Domestic reverse-charge**: a member-state-specific rule applied to high-fraud-risk sectors (construction, scrap metal, mobile phones, integrated circuits, and others) between two VAT-registered businesses inside the same country. Each member state publishes its own list of in-scope sectors. See the [Germany](/check/germany), [Belgium](/check/belgium), and [Netherlands](/check/netherlands) country pages for examples of domestic reverse-charge for construction. The two contexts can compose: a Belgian construction service supplied to a French buyer is both intra-Community (cross-border) and within scope of Belgium's domestic construction reverse-charge regime; the cross-border classification dominates. ## Audit-trail consequences A reverse-charged invoice has zero VAT collected by the seller. The seller's audit trail must justify the zero-rate position with two pieces of evidence: the buyer's VAT number was valid at the time of supply, and the buyer is established in a different member state. VIES validation (or BZSt qualified confirmation for German sellers) is the standard way to evidence the first point. See the [§18e UStG glossary](/glossary/paragraph-18e-ustg) for the German qualified-confirmation requirement. ## Common confusions - Reverse charge is B2B only. A B2C cross-border sale falls under OSS or the destination country's local VAT, not reverse charge. - A valid VAT number on the buyer side is what qualifies a sale for reverse charge. If the number fails registry validation, the seller must usually charge local VAT instead. ## See also - [EU VAT reverse charge API guide](/guides/eu-vat-reverse-charge-api) - [§18e UStG (German qualified confirmation)](/glossary/paragraph-18e-ustg) - [VAT number](/glossary/vat-number) - [OSS](/glossary/oss) Source: https://www.vatverify.dev/glossary/reverse-charge --- ## Steuernummer The German domestic tax number used by the Finanzamt for income tax purposes, distinct from the EU-facing USt-IdNr. used for intra-EU VAT. ## What it is The Steuernummer is a 10-to-13-digit number issued by a German regional Finanzamt to identify a taxpayer for domestic income tax and other inland revenue purposes. The length varies by Bundesland because the first digits encode the regional tax office code. It is not prefixed with a country code. ## Where you meet it The Steuernummer appears on domestic German tax correspondence, income tax returns, and wage tax assessments. It does not appear on cross-border B2B invoices and is not registered in VIES. ## Why it is not a VAT number The Steuernummer serves the inland tax system. It is not a valid identifier for EU VAT purposes. A German business making intra-EU supplies uses a separate USt-IdNr. (Umsatzsteuer-Identifikationsnummer), which is always 11 characters: `DE` followed by 9 digits. Submitting a Steuernummer to VIES or to vatverify will return an `invalid_format` error because the format does not match the DE pattern. This is one of the most common sources of confusion for new users: a German customer provides their Steuernummer when asked for their VAT number for an invoice. The two numbers are issued by different authorities, have different formats, and serve entirely different purposes. ## How to get from one to the other A German taxpayer who has both numbers should know which is which from their original tax registration paperwork. If a customer hands you only their Steuernummer when you need their USt-IdNr., the right next step is to ask them to look up their USt-IdNr. directly. The numbers cannot be derived from each other; both are issued by the German tax administration but through different processes. A German business that has not registered for intra-EU trade may not have a USt-IdNr. at all. The USt-IdNr. is requested separately from the standard Finanzamt registration through the Bundeszentralamt für Steuern (BZSt). Without one, the business cannot zero-rate intra-Community supplies under EU rules. ## Three German tax identifiers, three purposes Germany operates three distinct tax identifiers in parallel: - **Steuernummer**: domestic tax-office identifier (income tax, payroll tax, domestic correspondence). - **USt-IdNr.**: EU-facing VAT identifier (intra-Community trade, VIES). - **Steuerliche Identifikationsnummer (Steuer-ID, IdNr.)**: lifetime personal tax number for individuals, 11 digits, issued by BZSt. The three coexist and serve different administrative functions. For VAT validation purposes, only the USt-IdNr. is relevant; the other two never reach VIES. ## See also - [USt-IdNr.](/glossary/ust-idnr) - [§18e UStG (German qualified confirmation)](/glossary/paragraph-18e-ustg) - [Germany VAT check](/check/germany) Source: https://www.vatverify.dev/glossary/steuernummer --- ## Swiss UID The Swiss business identification number issued by the Federal Statistical Office, used as the basis for the Swiss VAT registration number. ## What it is The Swiss UID (Unternehmens-Identifikationsnummer) is a nine-digit identifier issued by the Federal Statistical Office (BFS) to every Swiss legal entity. It is written as `CHE-123.456.789` and, when the entity is VAT-registered, the VAT number is the same UID with a `MWST` (German), `TVA` (French) or `IVA` (Italian) suffix. The French and Italian names are IDE and IDI respectively; all three refer to the same number. ## Where you meet it You meet the Swiss UID in two registries. The BFS runs the UID Register at `uid.admin.ch`, which returns the entity's name, legal form, address, and VAT status via a public SOAP interface. The Federal Tax Administration's MWST register is queried separately to confirm active VAT registration. In vatverify, a `CHE`-prefixed number hits the BFS UID service, and the `/v1/validate` response includes `vatRegistered` alongside the registry status so you can see both facts in one payload. ## Common confusions - A valid UID does not imply VAT registration. Many small Swiss entities hold a UID without being above the CHF 100,000 turnover threshold that triggers mandatory VAT registration. - The UID carries a MOD-11 check digit. Numbers where the computed check digit is 10 are not issued, so a typo in the last digit is caught before the registry call. ## See also - [Swiss UID validation guide](/guides/swiss-uid-validation-guide) - [MOD-11 checksum](/glossary/mod-11) - [VAT number](/glossary/vat-number) Source: https://www.vatverify.dev/glossary/swiss-uid --- ## USt-IdNr. The German VAT identification number used for intra-EU B2B transactions, distinct from the domestic Steuernummer. ## What it is The Umsatzsteuer-Identifikationsnummer (USt-IdNr.) is Germany's EU-facing VAT number. It takes the form `DE` followed by exactly 9 digits (e.g. `DE123456789`), giving an 11-character string. The final digit is a MOD-11 check digit, which means a typo in any digit position can be caught before the number is submitted to a registry. ## Where you meet it The USt-IdNr. appears on cross-border B2B invoices between EU member states and in the `requester_vat_number` field when calling `/v1/confirm` for a §18e BZSt qualified confirmation. It is also the number queried through VIES to confirm that a German counterparty is currently VAT-registered in Germany. ## How it differs from the Steuernummer The Steuernummer is issued by the regional Finanzamt for domestic income tax purposes. It is 10-13 digits depending on the Bundesland format and encodes the regional tax office. The Steuernummer is not valid for EU cross-border VAT purposes and will not resolve in VIES. German businesses therefore hold two separate numbers: the Steuernummer for domestic filings with their Finanzamt, and the USt-IdNr. for EU-facing transactions. Customers frequently supply the wrong number when asked for their VAT number for an invoice, which is the most common cause of `invalid_format` errors in vatverify for DE-prefixed inputs. ## How to obtain one A German business applies for the USt-IdNr. through the Finanzamt that issued their Steuernummer. The Finanzamt forwards the application to BZSt, which issues the EU-facing number. The process can take several weeks. ## See also - [Steuernummer](/glossary/steuernummer) - [BZSt](/glossary/bzst) - [MOD-11 checksum](/glossary/mod-11) - [Germany VAT checker](/check/germany) Source: https://www.vatverify.dev/glossary/ust-idnr --- ## VAT number The country-specific identifier issued to a business that is registered for value-added tax and used as the key for cross-border B2B tax treatment. ## What it is A VAT number is the identifier a national tax authority issues to a business once it is registered for VAT. Inside the EU it is sometimes called a VAT identification number (VATIN). Its format is country-specific and is defined in each member state's VAT legislation; EU numbers follow the ISO 3166-1 alpha-2 country prefix (for example `DE123456789`, `FR12345678901`). ## Where you meet it You meet the VAT number anywhere a B2B transaction needs to be tax-classified: on invoices (it is a mandatory field under Article 226 of the VAT Directive), in Stripe Customer Tax IDs (`type: "eu_vat"`, `value: "DE123456789"`), and as the `vatNumber` field in vatverify's `/v1/validate` request. A valid, registered buyer VAT number is the input a tax-rules engine needs before it can apply reverse charge. ## Format varies dramatically by country There is no unified VAT number format across the EU. Lengths range from 8 characters (Spain, Slovenia, Cyprus) to 12 characters (Lithuania for individuals, Netherlands). Some countries use only digits; others mix in letters at fixed positions. A few examples: - **France**: `FR{2 alphanumeric}{9-digit SIREN}`. The 2-character key can include letters. - **Cyprus**: `CY{8 digits}{1 letter}`. The trailing letter is part of the canonical form. - **Netherlands**: `NL{9 digits}B{2 digits}`. The literal `B` is fixed. - **Czechia**: `CZ` plus 8, 9, or 10 digits depending on registrant type. A regex that hardcodes a single length will reject correctly-formed numbers from several member states. See the [country pages](/check) for the specific format and structural rules per country. ## ISO 3166 country code with two exceptions EU VAT numbers use the ISO 3166-1 alpha-2 country code as a prefix, with two intentional exceptions: - **EL** (not GR) is the prefix for Greek VAT numbers, derived from "Hellas". - **XI** is the prefix for Northern Ireland VAT numbers under the post-Brexit Windsor Framework, used for goods traded with the EU. A validator that recognises only ISO 3166 codes will reject correctly-formatted Greek and Northern Irish numbers. See the [Greece](/check/greece) and [Northern Ireland](/check/northern-ireland) pages for the historical context. ## VAT number is not the company registration number Each country also has a corporate registration identifier maintained by a separate registry (Companies House in the UK, Handelsregister in Germany, Kamer van Koophandel in the Netherlands). The corporate registration number is for company law and public records; the VAT number is for tax purposes. The two are distinct identifiers, often issued by different authorities, even when their numeric content overlaps. ## Common confusions - A VAT number is not the same as a company registration number. Germany's Handelsregister number, for example, is a separate identifier from the VAT `USt-IdNr.` issued by the Bundeszentralamt für Steuern. - The UK VRN now starts with `GB` for Great Britain registrations and `XI` for Northern Ireland registrations that remain within the EU VAT area for goods. - A valid format does not guarantee a registered number. Format checks happen offline; registration status only comes from VIES (EU) or the relevant national registry. ## See also - [VIES](/glossary/vies) - [HMRC VRN](/glossary/hmrc-vrn) - [VAT number validation in Node.js](/guides/validate-vat-number-nodejs) - [Country pages](/check) Source: https://www.vatverify.dev/glossary/vat-number --- ## VIES The European Commission's cross-border aggregator that lets you confirm a VAT number is currently registered in a given EU member state. ## What it is VIES (VAT Information Exchange System) is the European Commission service that relays VAT number validation queries to each member state's national tax administration and returns whether the number is currently valid. It is the authoritative source for cross-border B2B VAT number validation inside the EU. ## Where you meet it You meet VIES whenever you call the `checkVat` or `checkVatApprox` SOAP operations on `ec.europa.eu/taxation_customs/vies/services/checkVatService`. The response includes `valid`, `name`, `address`, `countryCode`, and `vatNumber` fields. A consultation number (`requestIdentifier`) is only returned when you supply the requester's own VAT number. In vatverify, the same check is exposed as REST under `/v1/validate`, which wraps VIES and normalises its SOAP faults into structured JSON errors. ## Coverage VIES covers the 27 EU member states plus Northern Ireland (XI prefix). It does not cover the United Kingdom, Switzerland, Liechtenstein, or Norway, each of which has its own national registry. See [/check](/check) for the country-by-country routing vatverify uses. The protocol is SOAP/XML; there is no REST endpoint published by the European Commission. Most modern integrations either build their own SOAP client or use a wrapper service. ## Common confusions - VIES does not store data itself. It forwards your query to the issuing country's registry, so downtime on a single member state can produce a `MS_UNAVAILABLE` fault while the rest still work. - VIES only covers EU registrations plus Northern Ireland. UK VRNs are validated against HMRC, not VIES. See the [UK page](/check/united-kingdom) and the [Northern Ireland page](/check/northern-ireland) for the post-Brexit split. - A `valid: true` response from VIES does not always include the company name. Germany, Spain, and Austria suppress trader name disclosure under national privacy policies; the number is still authoritatively validated, but `name` and `address` come back null. ## See also - [VIES API guide](/guides/vies-api-guide) - [Handle VIES downtime](/guides/handle-vies-downtime) - [Consultation number](/glossary/consultation-number) - [Germany VAT check](/check/germany) - [Spain VAT check](/check/spain) Source: https://www.vatverify.dev/glossary/vies --- ## Windsor Framework The 2023 UK-EU agreement that replaced the Northern Ireland Protocol, setting the rules under which Northern Ireland businesses use XI-prefix VAT numbers for goods trade with the EU. ## What it is The Windsor Framework is the agreement between the UK and the EU signed in 2023 that revised the original Northern Ireland Protocol (in force since January 2021). The Protocol had been put in place to avoid a hard customs border between Northern Ireland and the Republic of Ireland after Brexit, while the UK as a whole left the EU single market and customs union. The Windsor Framework adjusted the governance and practical arrangements without changing the core VAT principle: Northern Ireland remains within the EU VAT area for goods. ## Where you meet it The Windsor Framework is the legal basis for why Northern Irish businesses continue to use XI-prefix VAT numbers registered in VIES for goods trade with EU customers. For VAT validation purposes, the Framework did not change what was already in place under the Protocol. XI numbers existed before the Framework, and they continue to exist after it. ## What changed and what did not The Windsor Framework introduced the "Stormont Brake" mechanism, which allows the Northern Ireland Assembly to flag concerns about new EU rules automatically applying to Northern Ireland. This is a governance mechanism and has no direct effect on how VAT numbers work. For VAT: Northern Ireland businesses selling goods to EU buyers use XI numbers through VIES. Northern Ireland businesses selling services, or making purely domestic UK sales, use GB numbers through HMRC. This distinction predates the Framework and was not altered by it. ## Common confusions - "Windsor Framework" and "Northern Ireland Protocol" are sometimes used interchangeably in older documentation. They refer to different instruments, but for VAT purposes the practical rules are the same. - The Framework did not move Northern Ireland back into the UK customs union for goods. XI numbers remain valid for EU goods trade. ## See also - [XI prefix](/glossary/xi-prefix) - [Northern Ireland VAT checker](/check/northern-ireland) - [United Kingdom VAT checker](/check/united-kingdom) Source: https://www.vatverify.dev/glossary/windsor-framework --- ## XI prefix The post-Brexit ISO code assigned to Northern Ireland VAT numbers used for goods trade with the EU under the Windsor Framework. ## What it is The XI prefix is the country code assigned to Northern Ireland for EU VAT purposes following Brexit. A Northern Irish VAT number takes the form `XI` followed by the same 9-digit numeric structure as a GB VAT number (e.g. `XI123456789`). The XI prefix routes the VAT number through VIES rather than the HMRC API, because Northern Ireland remains within the EU's VAT area for goods under the Windsor Framework. ## Where you meet it You meet the XI prefix on invoices from Northern Irish businesses that sell goods to EU customers, and on VIES lookups that return XI-prefixed results for those businesses. In vatverify, a number beginning with `XI` is validated against VIES, not HMRC. A number beginning with `GB` is validated against the HMRC VAT number API. ## Goods vs services The XI prefix applies only to goods. For services, Northern Irish businesses are treated as UK businesses and use GB-prefixed VAT numbers routed through HMRC. This creates a situation where a Northern Irish business may hold both a GB VAT number (for domestic and services transactions) and an XI VAT number (for intra-EU goods trade). Both numbers are valid for their respective contexts. ## Why XI exists When the UK left the EU in January 2021, all GB VAT numbers were removed from VIES. However, the original Northern Ireland Protocol (later replaced by the Windsor Framework in 2023) kept Northern Ireland within the EU customs union for goods to avoid a hard border with Ireland. The XI prefix was created to allow Northern Irish businesses to continue appearing in VIES for EU goods trade while the rest of the UK moved to the HMRC system. ## Common confusions - An XI number is not a GB number, even though the digits are structurally identical. Routing an XI number to HMRC will fail; it must go through VIES. - Not every Northern Irish business holds an XI number. A business that trades only in services, or only domestically, may hold only a GB number. ## See also - [HMRC VRN](/glossary/hmrc-vrn) - [VIES](/glossary/vies) - [Windsor Framework](/glossary/windsor-framework) - [Northern Ireland VAT checker](/check/northern-ireland) - [United Kingdom VAT checker](/check/united-kingdom) Source: https://www.vatverify.dev/glossary/xi-prefix --- # Country pages Per-country VAT validation landing pages with prefilled live validator, format breakdown, registry details, rate card, and country-specific gotchas. Wave 1 live today (6 pages); Wave 2 extends to all 32 countries. - https://www.vatverify.dev/check/germany: Germany. VIES + BZSt §18e qualified confirmation. USt-IdNr. vs Steuernummer distinction, MTIC fraud patterns. - https://www.vatverify.dev/check/united-kingdom: United Kingdom. HMRC direct (no longer in VIES post-Brexit). 9-digit standard + 12-digit branch formats. - https://www.vatverify.dev/check/switzerland: Switzerland. BFS UID register. CHE-xxx.xxx.xxx format, vatRegistered flag. - https://www.vatverify.dev/check/liechtenstein: Liechtenstein. BFS UID via Swiss-Liechtenstein VAT union (1923 treaty). EEA, not EU. - https://www.vatverify.dev/check/norway: Norway. Brønnøysund (BRREG) register. 9-digit org number + optional MVA suffix. EEA, not EU. - https://www.vatverify.dev/check/northern-ireland: Northern Ireland (XI prefix). Windsor Framework goods-only VIES membership; services stay under GB/HMRC. ---