API documentation
The VATWarden API is a plain JSON-over-HTTPS API. Base URL: https://vatwarden.com.
All request and response bodies are JSON; all timestamps are ISO 8601 UTC.
Authentication
Create an API key in the dashboard under API keys. Keys look like
vw_live_ followed by 32 characters and are shown once at creation — we store only a
hash. Send the key as a bearer token on every request:
Authorization: Bearer vw_live_...
All endpoints require authentication except GET /v1/health. Requests without a
valid key receive 401 unauthorized. Requests from accounts without an active
subscription or trial receive 402 subscription_required.
Errors
Errors share one shape:
{
"error": {
"code": "quota_exceeded",
"message": "Monthly validation quota reached. Upgrade your plan or wait for the next period."
}
}
Some errors carry an extra error.detail string: the registry's own code for the
failure (for example MS_UNAVAILABLE on an upstream_unavailable). Log it,
but branch on error.code.
| Code | HTTP status | Meaning |
|---|---|---|
unauthorized | 401 | Missing or invalid API key. Use: Authorization: Bearer vw_live_... |
invalid_request | 400 | The request body is missing, is not valid JSON, or does not match the endpoint schema. |
invalid_format | 400 | The VAT number does not match the required format for this country. (As an error status only on PUT /v1/monitored — POST /v1/validate reports format failures as a normal answer, see below.) |
unsupported_country | 400 | Country not supported. Supported: EU member states (incl. XI) and GB. |
invalid_requester_info | 400 | Your own VAT number in Settings was rejected by the registry, so checks cannot be completed. Correct it in Settings — until then no consultation IDs can be issued. |
subscription_required | 402 | An active subscription is required. Visit your dashboard to subscribe. |
monitored_limit | 403 | Monitored VAT ID limit reached for your plan. |
not_found | 404 | Not found. |
payload_too_large | 413 | The request body exceeds 64 KB. |
unsupported_media_type | 415 | Send JSON with Content-Type: application/json. |
quota_exceeded | 429 | Monthly validation quota reached (trial accounts hard-stop at the included quota). |
rate_limited | 429 | Too many requests. Limit: 10 requests/second per API key. |
internal_error | 500 | Unexpected fault on our side. Safe to retry. |
upstream_unavailable | 503 | The government registry is currently unavailable and no cached answer exists. Retry later. |
POST /v1/validate
Validate one VAT number against the authoritative registry (VIES for EU member states and Northern Ireland, HMRC for the United Kingdom) and archive the evidence.
| Parameter | Type | Description |
|---|---|---|
country_code | string, required | Two-letter country code.
EU member states plus XI (Northern Ireland) and GB.
GR is accepted for Greece and normalized to EL;
UK is normalized to GB. |
vat_number | string, required | The VAT number. Whitespace, dots, dashes and a leading country prefix are stripped, letters uppercased. |
stale_ok | boolean, default true | When the
registry is unavailable, allow serving the freshest confirmed answer from the last 30 days
(marked stale: true). Set false to get
503 upstream_unavailable instead of a cached answer. |
curl -s -X POST https://vatwarden.com/v1/validate \
-H "Authorization: Bearer vw_live_..." \
-H "Content-Type: application/json" \
-d '{"country_code": "DE", "vat_number": "DE 811 569 869"}'
Response:
{
"id": "val_0mkq9r2h71x4c8t2w6p3n5d1",
"country_code": "DE",
"vat_number": "811569869",
"valid": true,
"name": "Bayerische Motoren Werke Aktiengesellschaft",
"address": "Petuelring 130\n80809 München",
"consultation_id": "WAPIAAAAY5J8kHRD",
"checked_at": "2026-08-06T09:14:07Z",
"source": "vies",
"stale": false,
"error_code": null
}
| Field | Description |
|---|---|
id | Evidence record ID. Time-ordered and opaque. Store it — it is your permanent reference to this check. |
valid | true, false, or null
(only in archived outage records; a 200 response always has
true/false). |
name, address | Registered trader name and address as
reported by the registry, or null when the registry withholds them. |
consultation_id | The registry's own reference for this exact lookup
(VIES issues one when your account has a requester VAT number configured in Settings).
null on cached and precheck answers — consultation IDs are never inherited. |
checked_at | When the answer was obtained from the registry. For cached answers this is the time of the original confirmation, not of your request. |
source | vies — live answer from VIES.
hmrc — live answer from HMRC. cache — served from the archive
because the registry was unavailable. precheck — rejected by the offline format
check, no registry contact. |
stale | true exactly when source is
cache. |
error_code | Usually null. invalid_format
on precheck rejections; on cached answers, the reason the live lookup failed. |
Cached (stale) answers
{
"id": "val_0mkqa02cw9h5k1r7t3b8m4z2",
"country_code": "DE",
"vat_number": "811569869",
"valid": true,
"name": "Bayerische Motoren Werke Aktiengesellschaft",
"address": "Petuelring 130\n80809 München",
"consultation_id": null,
"checked_at": "2026-08-04T02:41:19Z",
"source": "cache",
"stale": true,
"error_code": "upstream_unavailable"
}
Format failures
A number that cannot be valid for its country is answered immediately with
200 — it is a definitive result, archived like any other, and it counts toward your
quota. The registry is never contacted.
{
"id": "val_0mkqa1fjp2d8w5n3c7x1v9q6",
"country_code": "DE",
"vat_number": "12345",
"valid": false,
"name": null,
"address": null,
"consultation_id": null,
"checked_at": "2026-08-06T09:15:22Z",
"source": "precheck",
"stale": false,
"error_code": "invalid_format"
}
Quota
Live, cached and precheck answers each consume one validation. A 503
upstream_unavailable consumes nothing. Nightly monitoring re-checks consume nothing.
GET /v1/validations/:id
Retrieve one archived validation, including raw_response — the registry's verbatim
JSON at the time of the check (its exact shape depends on the registry).
curl -s https://vatwarden.com/v1/validations/val_0mkq9r2h71x4c8t2w6p3n5d1 \
-H "Authorization: Bearer vw_live_..."
{
"id": "val_0mkq9r2h71x4c8t2w6p3n5d1",
"country_code": "DE",
"vat_number": "811569869",
"valid": true,
"name": "Bayerische Motoren Werke Aktiengesellschaft",
"address": "Petuelring 130\n80809 München",
"consultation_id": "WAPIAAAAY5J8kHRD",
"checked_at": "2026-08-06T09:14:07Z",
"source": "vies",
"stale": false,
"error_code": null,
"raw_response": {
"isValid": true,
"requestDate": "2026-08-06T09:14:07.184Z",
"vatNumber": "811569869",
"countryCode": "DE",
"name": "Bayerische Motoren Werke Aktiengesellschaft",
"address": "Petuelring 130\n80809 München",
"requestIdentifier": "WAPIAAAAY5J8kHRD"
}
}
GET /v1/validations
List your archived validations, newest first.
| Query parameter | Description |
|---|---|
country | Filter by two-letter country code. |
number | Filter by VAT number (normalized form, as stored). |
valid | true or false. |
limit | Page size, default 50, max 200. Larger values are capped at
200; anything that is not a positive number (including -1) falls back to 50 —
there is no "unlimited" page size. |
before | Cursor: return records with IDs before this ID. Pass the
last id of the previous page to fetch the next one. |
curl -s "https://vatwarden.com/v1/validations?country=DE&valid=true&limit=50" \
-H "Authorization: Bearer vw_live_..."
{
"data": [
{
"id": "val_0mkq9r2h71x4c8t2w6p3n5d1",
"country_code": "DE",
"vat_number": "811569869",
"valid": true,
"name": "Bayerische Motoren Werke Aktiengesellschaft",
"address": "Petuelring 130\n80809 München",
"consultation_id": "WAPIAAAAY5J8kHRD",
"checked_at": "2026-08-06T09:14:07Z",
"source": "vies",
"stale": false,
"error_code": null
}
],
"has_more": false
}
has_more is true when a full page was returned; continue with
before until it is false.
Monitored VAT IDs
Monitored IDs are re-checked nightly (around 02:30 UTC). When one changes status you get a webhook. Re-checks are free and write evidence records like any other validation.
PUT /v1/monitored/:country/:number
Add a VAT ID to monitoring, or update its label. Idempotent: returns 201 when
newly added, 200 when it was already monitored. The body is optional.
curl -s -X PUT https://vatwarden.com/v1/monitored/FR/40303265045 \
-H "Authorization: Bearer vw_live_..." \
-H "Content-Type: application/json" \
-d '{"label": "Acme SARL"}'
{
"country_code": "FR",
"vat_number": "40303265045",
"label": "Acme SARL",
"status": "unknown",
"last_checked_at": null
}
status starts as unknown and becomes valid or
invalid after the first nightly check. It only becomes unknown again
after three consecutive nights of registry errors — registry downtime alone never flips a
status. Exceeding your plan's monitored cap returns 403 monitored_limit.
GET /v1/monitored
{
"data": [
{
"country_code": "FR",
"vat_number": "40303265045",
"label": "Acme SARL",
"status": "valid",
"last_checked_at": "2026-08-06T02:41:55Z"
}
]
}
DELETE /v1/monitored/:country/:number
Stop monitoring. Returns 204. The evidence history is kept.
curl -s -X DELETE https://vatwarden.com/v1/monitored/FR/40303265045 \
-H "Authorization: Bearer vw_live_..."
GET /v1/usage
Current billing period usage and entitlements.
{
"period": "2026-08",
"plan": "starter",
"plan_status": "active",
"validations_used": 231,
"validations_included": 500,
"monitored_cap": 250
}
GET /v1/health
Reachability of the upstream registries as observed by VATWarden. No authentication
required — point your monitoring at it, query string and all (?ts=… cache-busting
parameters are fine).
{
"vies": {
"status": "ok",
"last_error": null,
"observed_at": "2026-08-06T09:14:07.184Z"
},
"hmrc": {
"status": "not_enabled"
}
}
Each registry reports ok (last call succeeded), degraded (last call
failed — last_error carries the registry's code), unknown (no call
observed yet since start-up), or not_enabled. The endpoint always returns
200: it describes the registries, not VATWarden's own liveness.
Webhooks
Configure endpoint URLs in the dashboard under Webhooks. Each endpoint gets a
secret (whsec_...), shown once. There is currently one event type:
monitored.status_changed
Sent when a nightly re-check moves a monitored VAT ID between valid,
invalid and unknown — most importantly when a customer deregisters
(valid → invalid).
{
"id": "evt_0mkqb7x2k4p8d1c9w5h3t7f2",
"type": "monitored.status_changed",
"created": 1754448115,
"data": {
"country_code": "FR",
"vat_number": "40303265045",
"label": "Acme SARL",
"old_status": "valid",
"new_status": "invalid",
"validation": {
"id": "val_0mkqb7wq3n8z5r1j6v4s2e9x",
"valid": false,
"name": null,
"address": null,
"consultation_id": "WAPIAAAAY6QpR2mN",
"checked_at": "2026-08-06T02:41:55Z"
}
}
}
data.validation is the evidence record that triggered the transition, or
null when the transition was to unknown (three consecutive nights of
registry errors, no fresh answer).
Verifying signatures
Every delivery is signed. The Vatwarden-Signature header has the form:
Vatwarden-Signature: t=1754448115,v1=5257a869e7...
t is a unix timestamp and v1 is
HMAC-SHA256(secret, t + "." + rawBody) in hex. Recompute it over the raw request
body and compare in constant time; reject timestamps older than a few minutes.
import crypto from 'node:crypto';
// Verify a VATWarden webhook. IMPORTANT: use the RAW request body bytes —
// the signature covers the exact payload, so parse JSON only after verifying.
function verifyVatwardenSignature(secret, header, rawBody, toleranceSec = 300) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || '');
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
// Example with Express (note express.raw — not express.json):
app.post('/webhooks/vatwarden', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.get('Vatwarden-Signature');
const raw = req.body.toString('utf8');
if (!verifyVatwardenSignature(process.env.VATWARDEN_WEBHOOK_SECRET, sig, raw)) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(raw);
if (event.type === 'monitored.status_changed') {
// event.data.new_status is 'valid', 'invalid' or 'unknown'
}
res.status(200).end(); // 2xx within 3 seconds, or we retry
});
Delivery and retries
Deliveries time out after 3 seconds; only a 2xx response counts as delivered. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then marked failed. Recent delivery attempts and response codes are visible in the dashboard. Respond 2xx quickly and process asynchronously.
Rate limits
Each API key may make 10 requests per second (short bursts up to 20 are
absorbed). Beyond that, requests receive 429 rate_limited. Back off briefly and
retry; for sustained parallel load, spread requests across time rather than keys.