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.dev

Paths 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, and meta.sourceSince the 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 a vies answer 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 reading type is all your code has to do.
  • meta.checkedAt: on a VAT_NOT_REGISTERED error 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.

Parameters
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.

Parameters
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

Fields of one entry
Field Type Description
dataobjectThe 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.
errorobjectIn 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.
metaobjectWhere 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.sourcestringThe register, inside meta: SIRENE, ARES, MF_WL, PEPPOL, GEMI, VIES… with name beside it, as its publisher writes it.
meta.kindenumbulk 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.callobjectInside 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.
historyarrayWhat 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[]objectEach 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

Fields of data
Field Type Description
vatNumberstringCanonical number, country prefix included.
countryCodestringTwo-letter VAT prefix (EL for Greece, XI for Northern Ireland).
nationalNumberstringThe national part of the number, without the prefix.
nationalIdstring | nullIdentifier in the national register (SIREN, Business ID, registrikood…). May differ from the VAT number.
namestringLegal name as published by the source. Empty string when the source does not disclose it.
legalFormstring | nullLegal 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.
statusenumactive, inactive or unknown.
address.line1string | nullStreet, or the whole address when the source does not split it.
address.line2string | nullAddress complement.
address.postalCodestring | nullPostal code.
address.citystring | nullTown. null for Latvia, whose register publishes a territory code.
address.countrystringISO 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…
Events
Event Sent when
company.changedA register changed what it says about a company you follow.
monitor.testYou pressed “Send a test event” in the console. Same format, invented data.
lookup.completedA queued lookup got an answer.
lookup.failedA 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

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"
  }
}
Error codes
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 400 format error is never counted: the number is rejected before any source is consulted.
  • A 503 is not counted either: a source failed to answer, and that is not your call to pay for.
  • /v1/countries and /health need no key and cost no quota; /v1/audit needs one and costs none either.
  • Quota exhausted: 429, with the same error envelope. X-RateLimit-Reset says 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'

Creating a key takes a minute

100 requests a month for free, no card required. 5,000 a month for 29 € excl. VAT when you outgrow it.