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, and are there to tell you which of your integrations made a call.
curl "https://api.vatlas.dev/v1/lookup/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 when there is something to add to it. The shape does not change with the register the data came from: your code has nothing to branch on.
meta.source: the register that answered:SIRENE,MF_WL,PRH,RIK,UR_VID,ARES,VIES…meta.sourceDate: the last day that register was seen saying it, andmeta.sourceSincethe first: the publication date of the file for an imported register, the day of the call for a live one.meta.sourceUpdatedAt: UTC timestamp of the data served. On aviesanswer it can be older than the call itself: when neither VIES nor the register will answer at all, the last answer we hold is served rather than a 503. This field says when it was checked.meta.proof: the proof that the check was made:{ "type": "vies_consultation_number", "value": "…" }. A consultation number is what a tax authority accepts as evidence. Present on every answer that came from VIES and on no other: a national register issues nothing of the kind, so the key is absent rather than null. It is an object and not a bare string, so readingtypeis all your code has to do.meta.checkedAt: on aVAT_NOT_REGISTEREDerror only: the instant VIES gave that answer when we asked it for this request, and the day it said so when the answer came from what we already held.
Resolve a VAT number
GET /v1/lookup/{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. |
prefer |
query, optional | What to favour when the sources disagree: completeness, the default, takes the fullest answer among every source the country has; proof answers only when the answer carries a proof, skipping the stored history and every source that issues no verification code. Any other value is a 400. |
history |
query, optional flag | A flag: write ?history to be given everything the source that answered has ever said, under a history node beside data and meta. Off by default, since it is one query more and most integrations read the answer alone. |
Request
curl "https://api.vatlas.dev/v1/lookup/FR08000325175" \
-H "Authorization: Bearer $VATLAS_KEY"
Response · 200 OK
{
"data": {
"vatNumber": "FR08000325175",
"countryCode": "FR",
"nationalNumber": "08000325175",
"nationalId": "000325175",
"name": "THIERRY JANOYER",
"legalForm": "Personne physique",
"status": "active",
"address": {
"line1": "51 RUE MARX DORMOY",
"line2": null,
"postalCode": "13004",
"city": "MARSEILLE",
"country": "FR"
}
},
"meta": {
"source": "SIRENE",
"sourceDate": "2026-09-01",
"sourceSince": "2026-01-05",
"sourceUpdatedAt": "2025-12-06T09:43:55.000Z"
}
}
Response · 200 OK, answered by VIES
When the answer comes from VIES rather than from a register, meta carries one field more. proof.value is the consultation number VIES issued for that check, the identifier a tax authority accepts as evidence that the number was verified on that day. We ask for one on every call; when VIES issues none, the answer is still served, without meta.proof. Keep the value with the invoice it justifies.
"meta": {
"source": "VIES",
"sourceDate": "2026-08-28",
"sourceSince": "2026-08-28",
"sourceUpdatedAt": null,
"proof": {
"type": "vies_consultation_number",
"value": "908a3b38-e0dc-4502-8b06-d057eeac6861"
}
}
Preferring a proof
Which source answers is normally ours to decide, and by default we take the fullest answer among every source the country has, most of the time a national register we have already imported: rich, fast, and with nothing to prove it. ?prefer=proof turns that around: the stored history and every source that issues no verification code are skipped, and you are answered only if the answer carries meta.proof.
curl "https://api.vatlas.dev/v1/lookup/FR08000325175?prefer=proof" \
-H "Authorization: Bearer $VATLAS_KEY"
It costs one request, like a plain lookup. The real cost is time, since a live VIES call goes out every time, and one more way to fail: 503 PROOF_UNAVAILABLE, when VIES answers and issues no consultation number, which is what it does for most numbers it reports as unregistered. Like every 503, it is not billed. Answers are kept in your audit trail with their consultation number.
An empty name is not a bug. Germany and Spain disclose neither legal name nor address through VIES. Germany is answered from the Peppol e-invoicing directory where the company is listed there; the name is self-declared, hence status: unknown. Otherwise, and for Spain, only status is meaningful.
Every source's answer
GET /v1/lookup-all/{vatNumber}
The endpoint above answers “what is this number”, and stops at the first source that names a company. This one answers “what has every register said about this number, and when”: it publishes each source’s whole history, calls any live source whose last statement has gone stale, and reports that call’s failure when it fails. Nothing is skipped as redundant, because a report with a hole in it proves nothing. Each entry is a lookup (data and meta, or error and meta where that source has nothing), so code that reads the endpoint above reads these without a second shape to learn.
| Parameter | Type | Description |
|---|---|---|
vatNumber |
path, required | Same number, same normalisation as the plain lookup. Only the path differs. |
history |
query, optional flag | On here, unlike on the plain lookup: ?history=false drops the history node from every entry, and saves the query it costs. What is left is the statement in force for each source, which is what a comparison between registers reads. |
The report is the result
So the status line is about the report and not about the number: 200 as soon as the number parses, even when every source failed. The verdicts live one level down, in data.sources. Only a malformed number is a 400, and it costs nothing.
Request
curl "https://api.vatlas.dev/v1/lookup-all/CZ27074358" \
-H "Authorization: Bearer $VATLAS_KEY"
Response · 200 OK
{
"data": {
"vatNumber": "CZ27074358",
"countryCode": "CZ",
"nationalNumber": "27074358",
"sources": [
{
"data": {
"vatNumber": "CZ27074358", "name": "ACME s.r.o.", /* the same company shape as above */
},
"meta": {
"source": "ARES", "name": "ARES (Ministerstvo financí)", "kind": "live",
"sourceDate": "2026-09-04", "sourceSince": "2026-09-04",
"call": { "status": "found", "checkedAt": "2026-09-04T22:03:00.505Z" }
},
"history": [
{ "from": "2026-06-14", "to": "2026-08-30", "status": "active", /* … */ },
{ "from": "2026-09-04", "to": "2026-09-04", "status": "active", /* … */ }
]
},
{
"error": {
"code": "SOURCE_UNAVAILABLE", "message": "The source did not answer.",
"fault": "MS_UNAVAILABLE", "attempts": 4
},
"meta": {
"source": "VIES", "name": "VIES (European Commission)", "kind": "live",
"call": { "status": "unavailable", /* … */ }
},
"history": []
}
]
}
}
What each entry carries
| Field | Type | Description |
|---|---|---|
data | object | The company as this source describes it today, meaning its statement in force, in the exact shape the plain lookup publishes. Absent when that source has nothing to show, where error takes its place. |
error | object | In place of data: VAT_NOT_REGISTERED when this source says the number is not registered, SOURCE_UNAVAILABLE when the call this request made did not come back (with fault and attempts), COMPANY_NOT_FOUND when the source has never said anything about it. |
meta | object | Where the answer comes from and how fresh it is, exactly as on the plain lookup, plus name and kind (the register as its publisher writes it, and how we read it), and call when this request made a live call. |
meta.source | string | The register, inside meta: SIRENE, ARES, MF_WL, PEPPOL, GEMI, VIES… with name beside it, as its publisher writes it. |
meta.kind | enum | bulk for a register we import as a file, live for one we call, both for the four that offer each. A register that is both is listed once: it has one history, read two ways. |
meta.call | object | Inside meta, present exactly when this request made a live call, which is when the stored history had aged past a week. Its status is that call’s verdict (found, not-registered, not-found or unavailable), and an unavailable carries error.fault and error.attempts, which are what tell a source outage from a rate limit. |
history | array | What this source has said, oldest first, and absent under ?history=false. One entry per uninterrupted stretch during which it said the same thing, bounded by from and to, so a company that has not moved since 2019 is one entry spanning seven years rather than eighty. A gap between one entry and the next means no file was read in between. |
history[] | object | Each entry carries from and to, the stretch over which that source kept saying it (the publication dates of the files for an imported register, the days of the calls for a live one), then the company in the same shape the plain lookup publishes, file when it was read out of one, and proof on a positive VIES entry. |
How many entries come back depends on the country. A source that does not exist here produces no entry at all. France has no live national register; Bulgaria has no file to import; a register whose credentials this deployment does not hold is absent entirely. There is nothing to prove about a register that was never going to be asked. A source that exists and has never held this number is listed all the same, with an error saying it holds nothing.
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 as the originating register publishes it, in its own language and not harmonised across countries. Where the register publishes a code alone (France, Czechia, Belgium) the label is resolved for you. |
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. |
Monitor
Follow a company by its VAT number and receive a callback when a register changes its name, status or address. Useful for keeping a supplier or customer file current without re-checking every number yourself.
List the companies you follow
GET /v1/monitor
Returns every monitor on the account. meta.limit is how many your plan allows.
curl "https://api.vatlas.dev/v1/monitor" \
-H "Authorization: Bearer $VATLAS_KEY"
Follow a company
POST /v1/monitor/{vatNumber}
Answers 201. Following the same number twice answers 409.
curl -X POST "https://api.vatlas.dev/v1/monitor/FR95884831538" \
-H "Authorization: Bearer $VATLAS_KEY"
Callbacks go to the default URL set in your console. To send this one somewhere else, pass a url in the body:
curl -X POST "https://api.vatlas.dev/v1/monitor/FR95884831538" \
-H "Authorization: Bearer $VATLAS_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/vatlas"}'
Read one monitor
GET /v1/monitor/{vatNumber}
Returns the monitor, what it last saw, and its recent delivery attempts.
Stop following
DELETE /v1/monitor/{vatNumber}
Answers 204. The delivery history goes with it.
The event you receive
data and meta are the same envelope as a plain lookup, so a client that already reads GET /v1/lookup/{vatNumber} needs nothing new. previous is what you were last told.
{
"event": "company.changed",
"monitor": "01a08018-e2b8-770c-a9e6-89c82d01bf53",
"occurredAt": "2026-09-08T03:00:41.563Z",
"vatNumber": "FR95884831538",
"data": { "name": "ACME SARL", "status": "active", … },
"meta": { "source": "SIRENE", "sourceDate": "2026-09-08", … },
"previous": { "data": { … }, "meta": { … } }
}
The first check after you add a monitor never sends an event: it records the current state, so the next change has something to compare against.
Monitors do not count against your monthly quota. Your plan caps how many companies you can follow at once.
Lookup Async
When no source can answer for a number right away, queue the lookup instead of retrying yourself. Vatlas retries it at several points during the day, then over the following days, and sends you the result as soon as it gets one.
Resolve a VAT number asynchronously
POST /v1/lookup-async/{vatNumber}
Answers 202: nothing is resolved yet. Add ?prefer=proof to ask for a lookup with proof. Pass a url in the body to override your default callback URL.
curl -X POST "https://api.vatlas.dev/v1/lookup-async/FR95884831538" \
-H "Authorization: Bearer $VATLAS_KEY"
{
"data": {
"publicId": "01a0801b-0700-715f-b982-859f0d21da79",
"vatNumber": "FR95884831538",
"kind": "single",
"status": "pending",
"attempts": 0
}
}
One request off your monthly quota, taken when you queue it. If no source ever answers, the lookup is given up on and the request is credited back.
Every source, asynchronously
POST /v1/lookup-async-all/{vatNumber}
The same thing for every source at once, each answering for itself. Also 202, also one request.
curl -X POST "https://api.vatlas.dev/v1/lookup-async-all/FR95884831538" \
-H "Authorization: Bearer $VATLAS_KEY"
List your lookups
GET /v1/lookup-async
Newest first. Filter with ?status=pending|success|failed, page with ?limit= and ?before=. Costs no quota.
Read one lookup
GET /v1/lookup-async/id/{publicId}
Returns the lookup, every attempt made so far and, once there is one, the result. Costs no quota.
Cancel a lookup
DELETE /v1/lookup-async/id/{publicId}
Answers 204. The request already spent is not credited back.
The result you receive
data and meta are exactly what the synchronous call would have returned, so the same parsing works for both.
{
"event": "lookup.completed",
"lookup": "01a0801b-0700-715f-b982-859f0d21da79",
"vatNumber": "FR95884831538",
"data": { … },
"meta": { … }
}
If it is given up on, you receive lookup.failed with an error object instead, so you are never left waiting on silence.
Callbacks
Monitor and Lookup Async both deliver by POSTing JSON to a URL you choose. Each has its own default URL in the console, and any single monitor or lookup can override it.
What you receive
Answer 2xx and we consider it delivered. The body is JSON; the event name is in the body and in the X-Vatlas-Event header. X-Vatlas-Delivery is unique per attempt, so you can use it to ignore a duplicate.
POST /hooks/vatlas
Content-Type: application/json
X-Vatlas-Event: company.changed
X-Vatlas-Delivery: f9b8936a-a499-489f-9099-6feb93f23bfb
X-Vatlas-Signature: t=1788855521,v1=6bc87dd5523b8a5b…
| Event | Sent when |
|---|---|
company.changed | A register changed what it says about a company you follow. |
monitor.test | You pressed “Send a test event” in the console. Same format, invented data. |
lookup.completed | A queued lookup got an answer. |
lookup.failed | A queued lookup was given up on. |
Checking a webhook signature (Monitor, Lookup Async)
Every request we send carries an X-Vatlas-Signature header. It has two parts separated by a comma: t, the unix time we sent it, and v1, a hash of the request made with your signing secret. Here is what arrives:
POST /hooks/vatlas
X-Vatlas-Event: company.changed
X-Vatlas-Signature: t=1788855521,v1=abb136a424dd0f7c1e5b90d2a3f84c67e10b9d5a2c8e73f4061b8ad9e2c4570f
{"event":"company.changed", … }
Take t, a full stop, and the raw request body, exactly the bytes you received, before parsing them. A body that has been through a JSON parser and back is not the same string, and the hash would no longer match. That gives you what was signed:
1788855521.{"event":"company.changed", … }
Compute the HMAC-SHA256 of that string with your signing secret, and compare it with v1:
HmacSHA256(t + '.' + rawBody, signingSecret) == v1
With real values, that is:
HmacSHA256('1788855521.{"event":"company.changed", … }', 'vtl_sign_xxx…')
== 'abb136a424dd0f7c1e5b90d2a3f84c67e10b9d5a2c8e73f4061b8ad9e2c4570f'
Compare the two in constant time if your language offers it, and reject the request if they differ, or if t is more than a few minutes old, which is what stops someone replaying a request they captured earlier.
Your signing secret is on the Monitor page in the console, where you can also roll it. One secret covers both features.
Retries
A delivery that does not answer 2xx is retried a few times over the next few seconds. Every attempt, successful or not, is listed in the console with the status your endpoint returned. That is the first place to look if something never arrived.
Before a company you follow ever changes, use “Send a test event” in the console: a real signed request to your real URL, carrying invented data in the exact format of a real event.
Audit trail
GET /v1/audit
Every answer we have given you, as it was sent, so you can produce it again months later: what we said about a VAT number, which register it came from and on what date that register said it. The same history the console shows, over HTTP.
It costs no quota. These answers were counted when they were produced.
On the Free plan this endpoint returns your last 3 answers, which is enough to see the feature working. Pro keeps the full history for 730 days.
curl "https://api.vatlas.dev/v1/audit?vatNumber=FR95884831538" \
-H "Authorization: Bearer $VATLAS_KEY"
{
"data": [
{
"publicId": "01a0570f-5f96-77a0-9bf3-b990b0368c0c",
"vatNumber": "FR95884831538",
"fetchedAt": "2026-06-14T09:12:03.000Z",
"httpStatus": 200,
"errorCode": null,
"kind": "single",
"companyName": "SARL JULIEN MAULNY EURL",
"source": "SIRENE",
"sourceDate": "2026-09-01",
"sourceSince": "2026-06-01",
"sources": ["SIRENE"],
"response": { "data": { … }, "meta": { … } }
}
],
"meta": { "count": 1, "nextBefore": null }
}
Query parameters
| Parameter | Meaning |
|---|---|
vatNumber |
One number’s history. Normalized like everywhere else, so spacing and case do not matter. Omit it for your most recent calls, newest first. |
limit |
Page size, 1 to 100. Defaults to 25. |
before |
Cursor: pass the meta.nextBefore of the previous page, that is the publicId of its last row, to get the next one. null means there is no next page. A cursor that is not one of your own rows is a 400. |
publicId is a UUIDv7: it is the value to pass back as before to get the next page.
response is the envelope exactly as it was sent at the time, the error body for a 404 or a 503 included: it is the record of what you were told, not a fresh answer.
List every country and how it is served
GET /v1/countries
Returns every prefix the service answers for, 39 in all, 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": "PEPPOL", "strategy": "both" }
]
}
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. |
INVALID_QUERY |
400 | A query parameter is out of range or malformed: prefer with a value other than completeness or proof, history with a value other than true or false, or on /v1/audit a limit outside 1-100 or a before that is not a cursor we handed you. The request is rejected rather than quietly corrected. |
INVALID_BODY |
400 | The JSON body is not an object, or one of its fields is of the wrong type. A boolean has a spelling in JSON, and "true" is not it. |
CALLBACK_URL_MISSING |
400 | Neither the monitor nor the account names a callback URL, so there is nowhere to deliver. Refused at creation rather than discovered days later. |
INVALID_CALLBACK_URL |
400 | The callback URL is not absolute, is not http or https, carries credentials, or points at an address this service will not call. |
MONITOR_LIMIT_REACHED |
403 | Your plan follows a fixed number of companies at a time, and it is reached. Every listing reports the ceiling as meta.limit. |
MONITOR_EXISTS |
409 | This account already follows that number. Following it twice would deliver the same event twice. |
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. A company can be perfectly active in its national register without an intra-community VAT registration. The response carries meta.checkedAt and the same meta.proof as a positive answer. |
COMPANY_NOT_FOUND |
404 | Nothing left to ask: the number is valid, but was found nowhere. |
SOURCE_UNAVAILABLE |
503 | Nothing stored answers, and no live source could be reached. This is not a negative result and is never stored: retry later. |
PROOF_UNAVAILABLE |
503 | Only with ?prefer=proof: the check was made and VIES issued no consultation number, so it cannot be proved. This says nothing about the number: there may well be a good answer behind it, which is what asking for a proof declines to accept. Retry, or ask for ?prefer=completeness to take the answer unproved. |
SOURCE_ERROR |
None | A source failed in a way that is not an outage. It only ever appears inside data.sources on /v1/lookup-all, never as the status of a request. |
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 source failed to answer, and that is not your call to pay for. /v1/countriesand/healthneed no key and cost no quota;/v1/auditneeds one and costs none either.- 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/lookup/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/lookup/${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/lookup/{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/lookup/' . 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'];