EU EORI Validation API: SOAP Request and Response

By Remco from Avatcado

The European Commission validates EORI numbers through one SOAP operation, validateEORI, with no login. It takes up to 10 numbers per request and returns status 0 (valid) or 1 (not valid), plus name and address when the trader consented. It covers the EU and XI, not GB. Avatcado checks EU, XI and GB numbers with one JSON request.

The European Commission checks EORI numbers through one SOAP operation, validateEORI. It needs no account, API key or token. This guide gives the request that works, the response fields, the two ways the service reports a problem, and the limits measured against the live service on 13 November 2026.

Short answer: one SOAP call, no login

Send a SOAP 1.1 envelope with one to ten eori elements to the endpoint below. Each number comes back with status 0 (valid) or 1 (not valid). The trader's name and address are included only when the trader consented to publication. The Commission publishes a WSDL for the service and no developer documentation, so the behavior described here was read from the WSDL and from live responses.

Endpointhttps://ec.europa.eu/taxation_customs/dds2/eos/validation/services/validation
WSDLThe endpoint with ?wsdl appended
OperationvalidateEORI, namespace http://eori.ws.eos.dds.s/
AuthenticationNone
CoversNumbers issued by the 27 EU member states, and XI numbers for Northern Ireland

Send a validateEORI request

The SOAPAction header is an empty quoted string. Numbers must be uppercase with no spaces: the schema pattern is two capital letters followed by at least one character, 17 characters in total at most.

EU EORI service · cURL
Ready to copy
curl --max-time 15 \
  "https://ec.europa.eu/taxation_customs/dds2/eos/validation/services/validation" \
  --header "Content-Type: text/xml; charset=utf-8" \
  --header 'SOAPAction: ""' \
  --data '<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ev="http://eori.ws.eos.dds.s/">
  <soapenv:Body>
    <ev:validateEORI>
      <ev:eori>IT01234567890</ev:eori>
      <ev:eori>NL123456789</ev:eori>
    </ev:validateEORI>
  </soapenv:Body>
</soapenv:Envelope>'

Read the response

The response below uses synthetic values in the shape the live service returns. It shows the three outcomes: valid with published details, valid without details, and not valid.

EU EORI service · XML
Ready to copy
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">
  <S:Body>
    <ns0:validateEORIResponse xmlns:ns0="http://eori.ws.eos.dds.s/">
      <return>
        <requestDate>13/11/2026</requestDate>
        <result>
          <eori>IT01234567890</eori>
          <status>0</status>
          <statusDescr>Valid</statusDescr>
          <name>EXAMPLE S.P.A.</name>
          <street>VIA ROMA 1</street>
          <postalCode>41122</postalCode>
          <city>MODENA</city>
          <country>Italy</country>
        </result>
        <result>
          <eori>NL123456789</eori>
          <status>0</status>
          <statusDescr>Valid</statusDescr>
        </result>
        <result>
          <eori>DE000000000000000</eori>
          <status>1</status>
          <statusDescr>Not valid</statusDescr>
        </result>
      </return>
    </ns0:validateEORIResponse>
  </S:Body>
</S:Envelope>
ElementAlways presentMeaning
requestDateYesDate of the check as dd/mm/yyyy. There is no time and no reference number
eoriYesThe number this result belongs to
statusYes0 valid, 1 not valid
statusDescrYesValid or Not valid
name, street, postalCode, city, countryNoTrader details, present only with the trader's consent to publication
addressNoA free-text element. For one Greek trader it held a repeat of the name, so build the address from the fields above

Three details decide whether a parser is correct:

  • Match results on the number. A request for seven numbers came back with the valid results first, in a different order than requested.
  • Valid without a name is a complete answer. Of three valid numbers checked, one returned a name and address and two returned neither. Treat the trader fields as optional.
  • country is an English country name. The live value was Italy, where a two-letter code might be expected.

Handle the two error shapes

The service reports problems in two different ways, and neither says anything about the number.

TriggerHTTP statusBody
A number that breaks the schema pattern (lowercase, too long, no prefix)500SOAP fault, faultcode Client
More than 10 numbers in one request200errorDescription: Maximum allowed number of EORI in one request is 10. No results
No numbers in the request200errorDescription: No response for empty request.

One malformed number fails the whole request. A batch of ten with a single lowercase entry returns this fault and no result for the other nine:

EU EORI service · XML
Ready to copy
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">
  <S:Body>
    <ns0:Fault xmlns:ns0="http://schemas.xmlsoap.org/soap/envelope/">
      <faultcode>ns0:Client</faultcode>
      <faultstring>cvc-pattern-valid: Value 'de123' is not facet-valid with respect to pattern '[A-Z]{2}\p{IsBasicLatin}+' for type 'EORINumber'.</faultstring>
    </ns0:Fault>
  </S:Body>
</S:Envelope>

Check the format before sending, and check for errorDescription before reading results: an HTTP 200 with no result elements is an error. Record a fault, a timeout or a missing result as a check that did not complete. Recording any of them as Not valid marks a real trader as unregistered.

Limits the service states, and the ones it leaves out

  • 10 numbers per request. Verified: 10 returns 10 results, 11 returns the error above.
  • Request rate. The WSDL says the number of requests in a timeframe is limited and gives no figure. Plan for throttling: bound each request with a timeout and retry with a pause.
  • No uptime commitment, status page or change notice. None is published for this service.
  • No search by name. The operation takes numbers only.
  • No proof of the check. Unlike a VIES consultation number, the response carries only the date. Store the response yourself if you need evidence.

Route GB and XI numbers correctly

The service accepts a GB number without complaint and answers Not valid, even for a number that HMRC lists as valid. That was confirmed with one published GB number checked against both registers on the same day. Send GB numbers to HMRC, as described in the HMRC EORI check API guide. XI numbers belong here: HMRC's API refuses them and names the Commission's service in its error message.

Greek EORI numbers carry the ISO prefix GR, while Greek VAT numbers carry EL. The service lists a Greek trader as valid under GR and answers Not valid for the same digits under EL. If you reuse a VAT number field for both, see EORI number vs VAT number for the differences.

Run the TypeScript example

This Node.js 20+ example checks one number against the right register: GB with HMRC, every other prefix with the Commission's service. It rejects malformed input before sending, bounds each request to 15 seconds, matches the result on the number, and reports faults and timeouts as Unavailable. It needs no dependency and no credentials. The source ends with its run command.

EU EORI service · TypeScript
Ready to copy
type EoriResult =
  | { status: "Valid"; eori: string; register: "EU" | "HMRC"; name: string | null; address: string | null }
  | { status: "Invalid"; eori: string; register: "EU" | "HMRC" }
  | { status: "Malformed"; eori: string; reason: string }
  | { status: "Unavailable"; eori: string; register: "EU" | "HMRC"; reason: string };

const EU_URL = "https://ec.europa.eu/taxation_customs/dds2/eos/validation/services/validation";
const HMRC_URL = "https://api.service.hmrc.gov.uk/customs/eori/lookup/check-multiple-eori";
const TIMEOUT_MS = 15_000;

const XML_ENTITIES: Record<string, string> = { lt: "<", gt: ">", quot: '"', apos: "'", amp: "&" };

function element(xml: string, name: string): string | null {
  const match = xml.match(new RegExp("<(?:[\\w.-]+:)?" + name + ">([\\s\\S]*?)</(?:[\\w.-]+:)?" + name + ">"));
  if (!match) return null;
  const text = match[1].replace(/&(lt|gt|quot|apos|amp);/g, (_, entity: string) => XML_ENTITIES[entity]).trim();
  return text === "" ? null : text;
}

async function checkWithEu(eori: string): Promise<EoriResult> {
  const envelope =
    '<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:ev="http://eori.ws.eos.dds.s/">' +
    "<soapenv:Body><ev:validateEORI><ev:eori>" + eori + "</ev:eori></ev:validateEORI></soapenv:Body>" +
    "</soapenv:Envelope>";
  const response = await fetch(EU_URL, {
    method: "POST",
    headers: { "Content-Type": "text/xml; charset=utf-8", SOAPAction: '""' },
    body: envelope,
    signal: AbortSignal.timeout(TIMEOUT_MS),
  });
  const xml = await response.text();

  // A fault is not an answer about the number. It arrives with HTTP 500.
  const fault = element(xml, "faultstring");
  if (fault) return { status: "Unavailable", eori, register: "EU", reason: fault };
  if (!response.ok) return { status: "Unavailable", eori, register: "EU", reason: "HTTP " + response.status };

  // Results come back in the service's own order, so match on the number.
  const results = xml.match(/<(?:[\w.-]+:)?result>[\s\S]*?<\/(?:[\w.-]+:)?result>/g) ?? [];
  const result = results.find((block) => element(block, "eori") === eori);
  if (!result) {
    const reason = element(xml, "errorDescription") ?? "no result for the number";
    return { status: "Unavailable", eori, register: "EU", reason };
  }

  const status = element(result, "status");
  if (status === "1") return { status: "Invalid", eori, register: "EU" };
  if (status !== "0") return { status: "Unavailable", eori, register: "EU", reason: "unexpected status " + status };

  // Build the address from the structured fields. The free-text address element can repeat the name.
  const locality = [element(result, "postalCode"), element(result, "city")].filter(Boolean).join(" ");
  const address = [element(result, "street"), locality, element(result, "country")].filter(Boolean).join(", ");
  return { status: "Valid", eori, register: "EU", name: element(result, "name"), address: address || null };
}

async function checkWithHmrc(eori: string): Promise<EoriResult> {
  const response = await fetch(HMRC_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/vnd.hmrc.1.0+json" },
    body: JSON.stringify({ eoris: [eori] }),
    signal: AbortSignal.timeout(TIMEOUT_MS),
  });
  if (!response.ok) return { status: "Unavailable", eori, register: "HMRC", reason: "HTTP " + response.status };

  const entries: unknown = await response.json();
  const entry = Array.isArray(entries) ? entries.find((candidate) => candidate?.eori === eori) : undefined;
  if (typeof entry?.valid !== "boolean") {
    return { status: "Unavailable", eori, register: "HMRC", reason: "no result for the number" };
  }
  if (!entry.valid) return { status: "Invalid", eori, register: "HMRC" };

  const details = entry.companyDetails?.address;
  const address = [details?.streetAndNumber, details?.cityName, details?.postcode].filter(Boolean).join(", ");
  return {
    status: "Valid",
    eori,
    register: "HMRC",
    name: entry.companyDetails?.traderName ?? null,
    address: address || null,
  };
}

export async function checkEori(input: string): Promise<EoriResult> {
  // Greek EORI numbers use GR. The EU register answers "not valid" for the VAT prefix EL.
  const eori = input.toUpperCase().replace(/[\s.-]/g, "").replace(/^EL(?=\d)/, "GR");

  // Both registers answer a malformed number with an error for the whole request,
  // so reject it here instead of spending a call.
  if (!/^[A-Z]{2}[A-Z0-9]{1,15}$/.test(eori)) {
    return { status: "Malformed", eori, reason: "expected two letters followed by 1 to 15 letters or digits" };
  }
  const isGb = eori.startsWith("GB");
  if (isGb && !/^GB[0-9]{12,15}$/.test(eori)) {
    return { status: "Malformed", eori, reason: "GB numbers are GB followed by 12 to 15 digits" };
  }

  const register = isGb ? "HMRC" : "EU";
  try {
    return isGb ? await checkWithHmrc(eori) : await checkWithEu(eori);
  } catch (error) {
    // A timeout or network failure says nothing about the number. Never record it as invalid.
    const reason = error instanceof Error ? error.message : String(error);
    return { status: "Unavailable", eori, register, reason };
  }
}

// Run with Node.js 20 or later: npx tsx check-eori.ts GB123456789000
const input = process.argv[2];
if (input) {
  checkEori(input).then((result) => console.log(JSON.stringify(result, null, 2)));
}

The EU service directly vs Avatcado

Calling the Commission directly costs nothing and suits a system that already speaks SOAP and only checks EU numbers. Avatcado puts the same register, and HMRC for GB numbers, behind one JSON endpoint.

ConcernEU service directlyAvatcado
ProtocolSOAP 1.1, XML in and outGET with a query parameter, JSON out
CoverageEU 27 and XI. A GB number gets a wrong Not validEU 27, XI and GB, routed by prefix
Malformed inputSOAP fault for the whole request422 invalid_eori_format naming the rule, not counted against the quota
OutageYour own retry and storageUp to three attempts, then the last stored answer flagged in the metadata, or a 503 that is not counted
Repeat checksEvery check calls the serviceCached, with the age visible in the metadata
CostFreeFree for 500 requests a month, shared with VAT validation
Avatcado · cURL
Ready to copy
# Set AVATCADO_API_KEY in this server environment first.
curl --max-time 30 \
  "https://api.avatcado.com/v1/eori/validate?eori_number=GB123456789000" \
  --header "Authorization: Bearer $AVATCADO_API_KEY"
Avatcado · JSON
Ready to copy
{
  "data": {
    "valid": true,
    "eori_number": "GB123456789000",
    "country_code": "GB",
    "company": {
      "name": "EXAMPLE TRADING LTD",
      "address": "1 HIGH STREET, LONDON, SW1A 1AA"
    },
    "requested_at": "2026-11-13T09:19:29.000Z"
  },
  "meta": {
    "request_id": "550e8400-e29b-41d4-a716-446655440000",
    "request_duration_ms": 312,
    "source": "hmrc",
    "source_status": "live",
    "cached": false,
    "stale": false
  }
}

company is null when the register publishes no details for a valid number. The same API key validates VAT numbers in 32 countries; an EORI check counts as one request.

Frequently asked questions

Is there an official EORI validation API for the EU?

Yes. The European Commission runs a SOAP web service with one operation, validateEORI, at ec.europa.eu/taxation_customs/dds2/eos/validation/services/validation. It needs no account or key. The Commission publishes the WSDL and no developer documentation, and it offers no REST or JSON version.

How many EORI numbers can one request validate?

Ten. On 13 November 2026 a request with 10 numbers returned 10 results, and a request with 11 returned HTTP 200 with an error description saying the maximum allowed number of EORI in one request is 10, and no results.

Why does a valid EORI number come back without a company name?

The register shows a trader's name and address only when the trader consented to publication. A result with status 0 and no name is a complete answer: the number is registered and no details are public. Of three valid numbers checked for this guide, two came back without a name.

Can the EU service validate GB or XI EORI numbers?

XI numbers, yes. GB numbers, no: the service answers Not valid for a GB number that HMRC lists as valid, so a GB number must be checked with HMRC's Check an EORI number API. Avatcado routes each number to the right register by its prefix.

Sources

Related guides