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.

CodeHTTP statusMeaning
unauthorized401Missing or invalid API key. Use: Authorization: Bearer vw_live_...
invalid_request400The request body is missing, is not valid JSON, or does not match the endpoint schema.
invalid_format400The 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_country400Country not supported. Supported: EU member states (incl. XI) and GB.
invalid_requester_info400Your 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_required402An active subscription is required. Visit your dashboard to subscribe.
monitored_limit403Monitored VAT ID limit reached for your plan.
not_found404Not found.
payload_too_large413The request body exceeds 64 KB.
unsupported_media_type415Send JSON with Content-Type: application/json.
quota_exceeded429Monthly validation quota reached (trial accounts hard-stop at the included quota).
rate_limited429Too many requests. Limit: 10 requests/second per API key.
internal_error500Unexpected fault on our side. Safe to retry.
upstream_unavailable503The 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.

ParameterTypeDescription
country_codestring, requiredTwo-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_numberstring, requiredThe VAT number. Whitespace, dots, dashes and a leading country prefix are stripped, letters uppercased.
stale_okboolean, default trueWhen 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
}
FieldDescription
idEvidence record ID. Time-ordered and opaque. Store it — it is your permanent reference to this check.
validtrue, false, or null (only in archived outage records; a 200 response always has true/false).
name, addressRegistered trader name and address as reported by the registry, or null when the registry withholds them.
consultation_idThe 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_atWhen the answer was obtained from the registry. For cached answers this is the time of the original confirmation, not of your request.
sourcevies — 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.
staletrue exactly when source is cache.
error_codeUsually 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 parameterDescription
countryFilter by two-letter country code.
numberFilter by VAT number (normalized form, as stored).
validtrue or false.
limitPage 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.
beforeCursor: 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 (validinvalid).

{
  "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.