API reference · v1
Vatlas API documentation
One REST API, three entry points, one JSON envelope. Everything is a GET, everything is UTF-8, and every date is UTC in ISO 8601.
Base URL
https://api.vatlas.devPaths are versioned (/v1/…). A version never changes shape in a breaking way: a new field may appear, none is ever removed.
Authentication
Every call carries a key as a bearer token in the Authorization header. You can create as many keys as you like — they all draw on the same account quota, so a key is there to tell you which of your integrations made a call.
curl "https://api.vatlas.dev/v1/vat/FR08000325175" \
-H "Authorization: Bearer vtl_live_xxxxxxxxxxxxxxxxxxxx"
Keep the key server-side. It must never ship in a front-end bundle: anyone could read it and burn your quota. Proxy the call through your backend.
A missing, unknown or revoked key answers 401 with the standard error envelope, and is not counted. Create your key — the Free plan is free and needs no card.
Response envelope
Success always carries data and meta. An error always carries error, and meta whenever sources were consulted. The shape does not change with the origin of the data: your code has nothing to branch on.
meta.origin—database,sourceorvies.meta.source—SIRENE,MF_WL,PRH,RIK,UR_VIDorVIES.meta.sourceUpdatedAt— UTC timestamp of the data served.meta.checked— every source consulted, in order.
Resolve a VAT number
GET /v1/vat/{vatNumber}
The heart of the API. Input is normalised before validation: case, spaces, dots and dashes are ignored, and Greece's ISO prefix GR is accepted alongside its VAT prefix EL. data.vatNumber always comes back in canonical form.
| Parameter | Type | Description |
|---|---|---|
vatNumber |
path, required | Intra-community VAT number, country prefix included. FR08000325175 and fr 08 000.325-175 are equivalent. |
Request
curl "https://api.vatlas.dev/v1/vat/FR08000325175" \
-H "Authorization: Bearer $VATLAS_KEY"
Response — 200 OK
{
"data": {
"vatNumber": "FR08000325175",
"countryCode": "FR",
"nationalNumber": "08000325175",
"nationalId": "000325175",
"name": "THIERRY JANOYER",
"legalForm": "1000",
"status": "active",
"address": {
"line1": "51 RUE MARX DORMOY",
"line2": null,
"postalCode": "13004",
"city": "MARSEILLE",
"country": "FR"
}
},
"meta": {
"origin": "database",
"source": "SIRENE",
"sourceUpdatedAt": "2025-12-06T09:43:55.000Z",
"checked": ["database"]
}
}
An empty name is not a bug. Germany, Spain and the Netherlands disclose neither legal name nor address through VIES. For those countries, until a local register is wired in, only status is meaningful.
Response fields
| Field | Type | Description |
|---|---|---|
vatNumber | string | Canonical number, country prefix included. |
countryCode | string | Two-letter VAT prefix (EL for Greece, XI for Northern Ireland). |
nationalNumber | string | The national part of the number, without the prefix. |
nationalId | string | null | Identifier in the national register (SIREN, Business ID, registrikood…). May differ from the VAT number. |
name | string | Legal name as published by the source. Empty string when the source does not disclose it. |
legalForm | string | null | Legal form code from the originating register, not harmonised across countries. |
status | enum | active, inactive or unknown. |
address.line1 | string | null | Street, or the whole address when the source does not split it. |
address.line2 | string | null | Address complement. |
address.postalCode | string | null | Postal code. |
address.city | string | null | Town. null for Latvia, whose register publishes a territory code. |
address.country | string | ISO 3166-1 alpha-2 country code. |
List every country and how it is served
GET /v1/countries
Returns all 28 prefixes — the 27 member states plus Northern Ireland — with the source behind each and its strategy: bulk for an imported register, on-demand for the register's own API, both, or vies for a member state with no usable local source, which still resolves through the VIES fallback.
{
"data": [
{ "countryCode": "FR", "name": "France", "source": "SIRENE", "strategy": "bulk" },
{ "countryCode": "PL", "name": "Poland", "source": "MF_WL", "strategy": "on-demand" },
{ "countryCode": "CZ", "name": "Czechia", "source": "ARES", "strategy": "on-demand" },
{ "countryCode": "FI", "name": "Finland", "source": "PRH", "strategy": "both" },
{ "countryCode": "DE", "name": "Germany", "source": "VIES", "strategy": "vies" }
]
}
Liveness probe
GET /health
Answers only once a database round trip has succeeded, so it works as-is for a readiness probe. This call costs no quota.
{ "status": "ok" }
Error codes
Every error shares the same shape:
{
"error": {
"code": "INVALID_VAT_FORMAT",
"message": "Unknown VAT country prefix \"XX\"",
"reason": "UNKNOWN_COUNTRY"
}
}
| Code | HTTP | Meaning |
|---|---|---|
INVALID_VAT_FORMAT |
400 | Invalid syntax or checksum; no source was consulted. reason says which: EMPTY, UNKNOWN_COUNTRY, BAD_FORMAT, BAD_CHECKSUM. |
MISSING_SECRET_KEY |
401 | No Authorization: Bearer credential was sent, or the header used another scheme. This call is not counted against your quota. |
INVALID_SECRET_KEY |
401 | The key is unknown or has been revoked. Revoking a key takes effect within a minute, so a key revoked seconds ago may still answer. |
QUOTA_EXCEEDED |
429 | The monthly quota for the account is spent. It renews at the start of the next month; X-RateLimit-Reset carries the exact instant. |
VAT_NOT_REGISTERED |
404 | VIES gave a definitive answer: the number is not registered for intra-community trade. This is not the same as the company not existing — it can be perfectly active in its national register without an intra-community VAT registration. |
COMPANY_NOT_FOUND |
404 | Nothing left to ask: the number is valid, but was found nowhere. |
SOURCE_UNAVAILABLE |
503 | Not in the database, and VIES did not answer. This is not a negative result and is never cached: retry later. |
NOT_FOUND |
404 | Unknown route. |
INTERNAL_ERROR |
500 | Unexpected failure on our side. |
Quotas & limits
The quota is monthly and belongs to the account, not to an individual key. Every metered response carries the current state of the counter, so there is nothing to estimate:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1788220800
- A
400format error is never counted: the number is rejected before any source is consulted. - A
503is not counted either — a member state failed to answer, and that is not your call to pay for. /v1/countriesand/healthneed no key and cost no quota.- Quota exhausted:
429, with the same error envelope.X-RateLimit-Resetsays when it renews.
Plan details on the pricing page.
Code samples
curl -s "https://api.vatlas.dev/v1/vat/FR08000325175" \
-H "Authorization: Bearer $VATLAS_KEY" \
| jq '.data.name'
// Node 20+ / Deno / browser (through your backend)
const lookupVat = async (vatNumber) => {
const res = await fetch(
`https://api.vatlas.dev/v1/vat/${encodeURIComponent(vatNumber)}`,
{ headers: { 'Authorization': `Bearer ${process.env.VATLAS_KEY}` } },
)
const payload = await res.json()
if (!res.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`)
return payload.data
}
const company = await lookupVat('FR08000325175')
console.log(company.name, '—', company.address.city)
import os
import requests
def lookup_vat(vat_number: str) -> dict:
res = requests.get(
f"https://api.vatlas.dev/v1/vat/{vat_number}",
headers={"Authorization": f"Bearer {os.environ['VATLAS_KEY']}"},
timeout=10,
)
payload = res.json()
if res.status_code != 200:
raise RuntimeError(payload["error"]["code"])
return payload["data"]
company = lookup_vat("FR08000325175")
print(company["name"], company["address"]["city"])
<?php
function lookupVat(string $vatNumber): array
{
$context = stream_context_create([
'http' => ['header' => 'Authorization: Bearer ' . getenv('VATLAS_KEY')],
]);
$url = 'https://api.vatlas.dev/v1/vat/' . rawurlencode($vatNumber);
$payload = json_decode(file_get_contents($url, false, $context), true);
if (isset($payload['error'])) {
throw new RuntimeException($payload['error']['code']);
}
return $payload['data'];
}
$company = lookupVat('FR08000325175');
echo $company['name'];