Validate Spanish VAT Numbers (NIF/CIF) in Node.js

26 August 2026

Validate Spanish VAT Numbers (NIF/CIF) in Node.js

Spain issues one 9-character local body under the ES prefix, but which kind of number it is depends entirely on the first character — and that first character also decides which checksum rule applies, if any applies at all. Get the family wrong and your regex either rejects legitimate business customers or lets typos through. Here's the format, the check-letter mechanics, and what actually happens when you hand an ES number to VIES.

What a Spanish VAT number looks like

Spain's EU VAT ID — commonly called the NIF (Número de Identificación Fiscal), though CIF is still the everyday term for the legal-entity variant — has a fixed shape:

  • ES — country code
  • 9 characters — a single leading character (digit or letter) followed by 8 more characters, ending in a check character

So ESB12345674 is ES + B12345674: leading letter B, seven body digits, one trailing check character. The whole thing is always 9 characters after the ES prefix — never more, never fewer.

What makes Spain different from most of the other EU formats is that the leading character isn't decorative. It tells you which of three distinct holder types you're looking at, and that in turn tells you which validation rule — if any — you can run locally.

The leading-character rule: three families, one shape

  • Leading digit (0–9): NIF, resident natural person. Based on the holder's DNI (Documento Nacional de Identidad). This is an individual — a sole trader or freelancer (autónomo) — registered in Spain.
  • Leading X, Y, or Z: NIE, foreign national. The NIE (Número de Identidad de Extranjero) is issued to non-Spanish individuals — EU citizens and others — who need a Spanish tax ID without holding a DNI.
  • Leading letter (other than X/Y/Z): CIF, legal entity. This is the "NIF de persona jurídica" — companies, cooperatives, associations, and other organizations. The letter itself encodes the entity type: A for sociedad anónima (S.A.), B for sociedad limitada (S.L.), F for a cooperative, N for a foreign entity, W for a permanent establishment of a non-resident, and so on for the rest of the set (C, D, E, G, H, J, P, Q, R, S, U, V). This mapping comes from Spanish tax regulation (Orden EHA/451/2008), not from VIES itself.

Three families, one 9-character shape, and each family carries a different checksum rule underneath. Build the branch before you write any regex:

type SpanishVatFamily = 'NIF' | 'NIE' | 'CIF'

function classifySpanishVatId(localBody: string): SpanishVatFamily | null {
  const first = localBody[0]
  if (/^\d$/.test(first)) return 'NIF'
  if (/^[XYZ]$/.test(first)) return 'NIE'
  if (/^[ABCDEFGHJNPQRSUVW]$/.test(first)) return 'CIF'
  return null
}

Node.js format validation before calling VIES

Reject malformed input before any network call. As with the sibling guides in this series — German and French — decide up front which mode you're running in:

  • Strict (API mode) — your service documents ES + 9 characters. Reject anything else.
  • Lenient (checkout mode) — accept what customers paste. Spaces, dots, and a missing ES prefix are common; normalize and log the original input.
// One pattern per family — a leading-character-blind regex would silently
// accept invalid leading letters (I, K, L, M, O, T are not valid CIF letters).
const NIF_PATTERN = /^ES\d{8}[A-Z]$/ // resident natural person
const NIE_PATTERN = /^ES[XYZ]\d{7}[A-Z]$/ // foreign national
const CIF_PATTERN = /^ES[ABCDEFGHJNPQRSUVW]\d{7}[0-9A-Z]$/ // legal entity

const ES_VAT_PATTERN = new RegExp(
  `(?:${NIF_PATTERN.source}|${NIE_PATTERN.source}|${CIF_PATTERN.source})`
)

function normaliseSpanishVatId(
  input: string,
  opts: { mode?: 'strict' | 'lenient' } = {}
): string | null {
  let cleaned = input.replace(/[\s.\-]/g, '').toUpperCase()
  if (opts.mode === 'lenient' && /^[0-9A-Z]{9}$/.test(cleaned)) {
    cleaned = `ES${cleaned}`
  }
  return ES_VAT_PATTERN.test(cleaned) ? cleaned : null
}

// Strict (default) — API consumers should send a fully-qualified ID
normaliseSpanishVatId('ES B1234567 4') // → 'ESB12345674'
normaliseSpanishVatId('B12345674') // → null

// Lenient — for checkout forms
normaliseSpanishVatId('b12345674', { mode: 'lenient' }) // → 'ESB12345674'

Matching the correct per-family pattern proves the string has a valid leading family marker for that family and the right length — it does not prove the check character is arithmetically correct, and it never proves the number is registered. Shape, then arithmetic, then VIES — three separate questions, and only the last one is authoritative.

The NIF and NIE check letter

For a NIF (8 digits + check letter), the check letter is derived with a fixed mod-23 table:

const NIF_CHECK_LETTERS = 'TRWAGMYFPDXBNJZSQVHLCKE'

// Digits only, no leading letter — for NIF this is the raw 8-digit DNI number.
function nifCheckLetter(eightDigits: string): string {
  const n = Number(eightDigits) % 23
  return NIF_CHECK_LETTERS[n]
}

nifCheckLetter('12345678') // → 'Z'

A NIE uses the same table, but first converts its leading X/Y/Z into a digit — X0, Y1, Z2 — and prepends it to the 7 following digits to form an 8-digit number before running the same mod-23 lookup:

function nieCheckLetter(nieBody: string): string | null {
  const prefixMap: Record<string, string> = { X: '0', Y: '1', Z: '2' }
  const lead = prefixMap[nieBody[0]]
  if (!lead) return null
  const eightDigits = lead + nieBody.slice(1, 8)
  return NIF_CHECK_LETTERS[Number(eightDigits) % 23]
}

nieCheckLetter('Y1234567X') // check letter derived from '11234567' mod 23

I, Ñ, O, and U never appear in this table — if you see one of those as a "check letter," the input is wrong before you even reach VIES.

The CIF check character

A CIF (letter + 7 digits + a final control character) uses a different, older algorithm computed over the 7-digit body: sum the digits in the even positions, then for each odd-position digit double it and, if the result is 10 or more, sum its own digits — add both totals together, take the units digit of that sum, and the control value is (10 − units digit) mod 10.

Whether the final character on the actual CIF is that digit or a letter depends on the entity type encoded in the leading letter — some legal forms carry a numeric control character, others carry a letter, mapped as 1=A, 2=B, 3=C, 4=D, 5=E, 6=F, 7=G, 8=H, 9=I, 0=J. Rather than publish a full table of which leading letters take which control-character type — that mapping has edge cases that aren't worth getting wrong in a blog post — treat the CIF check as: compute the digit, and accept either the digit or its letter equivalent as valid depending on what you observe for that entity type.

All three checksum rules above are format pre-filters, not proof of registration. A checksum-valid ES number can still come back invalid from VIES — unregistered, deregistered, or never issued. Never treat "passed the checksum" as equivalent to "VIES valid." Run the checksum to catch typos before a network call; run VIES to get the actual answer.

Calling VIES for Spain

VIES is a federation of national nodes. When you query an ES number, the European Commission routes the request to the Spanish tax authority (Agencia Tributaria, AEAT) and relays back whatever it says. For a live ES check you get one of:

  • A valid/invalid answer.
  • MS_UNAVAILABLE — the Spanish node is temporarily unreachable.
  • SERVICE_UNAVAILABLE — VIES itself is degraded.
  • A timeout after roughly 10 seconds.

Trader name and address disclosure is not guaranteed for any member state — some tax authorities withhold those fields even on a valid result — so don't assume a valid Spanish check always comes back with a company name attached. Build your UI to handle a valid verdict with or without enrichment data. The VIES downtime guide covers the failure modes worth designing around across every member state.

Why Spain has no national registry fallback today

This is the part worth being direct about. For some member states, when the VIES node for that country goes down, vatnode can fall back to a national tax authority or company registry to keep answering. Spain does not have that today — there is no national fallback or enrichment source wired into vatnode's pipeline for ES numbers. If VIES ES is unavailable, there is no second source to consult; the check simply reflects the VIES outage.

This isn't a design choice specific to Spain's risk profile — it's a coverage gap, and it's worth planning around rather than assuming a fallback exists. If your business does meaningful volume with Spanish counterparties, build your checkout and onboarding flows to tolerate a VIES_UNAVAILABLE response gracefully (queue and retry, don't block) rather than assuming a fallback will quietly cover the gap the way it might for some other member states.

Full working example with the vatnode API

vatnode runs format normalization, the checksum pre-filter, and a requester-qualified VIES call to the Spanish node in one request:

const res = await fetch('https://api.vatnode.dev/v1/vat/ESB12345674', {
  headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` },
})

if (!res.ok) {
  const { code } = await res.json()
  throw new Error(`VAT check failed: ${code}`)
}

const data = await res.json()
// {
//   "valid": true,
//   "vatId": "ESB12345674",
//   "countryCode": "ES",
//   "countryName": "Spain",
//   "companyName": null,               // not always disclosed for ES
//   "companyAddress": null,
//   "source": "VIES",
//   "consultationNumber": "WAPIAAAAX...",
//   "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",
//   "verifiedAt": "2026-08-26T08:30:00.000Z"
// }

source will always read "VIES" for a Spanish check today — there is no ES national fallback source to branch on, unlike some other member states covered in this series (see the Dutch and German guides). If valid is false, that's VIES telling you the number is not currently registered — investigate with the customer before you invoice without VAT under reverse charge. (This article is informational, not tax advice — whether reverse charge actually applies depends on your specific transaction and both parties' status; confirm with a qualified advisor.) The consultationNumber is the VIES-issued reference described in the consultation number guide; it's issued only when your account has a requester VAT number configured, and it's your primary piece of documentary evidence — not a compliance guarantee.

Get a free API key at vatnode.dev/register — 100 requests/month, no card — and the full field reference is in the API docs. The Spain VAT API reference documents the endpoint end to end if you want the country-specific page instead of this walkthrough, and the Node.js VAT validation guide covers the storage schema across every member state.

Error handling and retries

Treat "we couldn't get an answer" as a distinct state from "invalid" — this matters more for Spain than for countries with a fallback, because there's no second source to quietly absorb a VIES ES outage. The vatnode API returns explicit error codes:

  • INVALID_FORMAT (400) — the string isn't a well-formed ES VAT ID. Your input; surface it inline, don't retry.
  • INVALID_REQUESTER (422) — your configured requester VAT number was rejected by VIES. Fix it in dashboard settings.
  • RATE_LIMITED (429) — you've spent your quota. Retryable on a longer horizon.
  • VIES_UNAVAILABLE (503) — the Spanish node (or VIES itself) is down. Transient — queue a retry, never mark the number invalid.
  • VIES_ERROR (502) / UPSTREAM_TIMEOUT (504) — transient upstream faults. Retry with backoff.
  • INTERNAL_ERROR (500) — retry, alert if it persists.

Because there's no ES fallback to absorb a VIES_UNAVAILABLE, retry with exponential backoff (for example 500ms, 2s, 5s) and, if it still fails, queue the check for a background re-run rather than blocking checkout. The full error taxonomy and retry table is in handling VIES error codes.

FAQ

What is the difference between a NIF, a CIF, and a NIE?

They're the same 9-character body issued to three different holder types. NIF (digit-leading) is a resident natural person, using their DNI. NIE (X/Y/Z-leading) is a foreign national. CIF (letter-leading) is a legal entity — a company, cooperative, or similar. Spain unified the underlying tax-ID scheme, so all three now share one "NIF de persona jurídica o física" concept even though the CIF label is still common in practice.

Does a checksum-valid Spanish VAT number mean it's registered?

No. The check letter or check digit only proves the number is internally consistent — that no digit was mistyped. It says nothing about whether the number is currently registered for intra-EU VAT. Only a live VIES check (or, for domestic Spanish purposes, the AEAT register) proves that.

Why doesn't vatnode have a national fallback for Spain?

Spain has no national registry integrated into vatnode's fallback path today. When VIES ES is unavailable, there's no second source to query — the check simply reflects the VIES outage. This is a coverage gap specific to Spain, not a general limitation; other member states do have a fallback or enrichment source wired in.

Can I validate a Spanish VAT number without calling VIES?

You can validate the format and, for NIF/NIE, the check letter offline — that's a useful pre-filter to catch typos before you spend a network call. But it's not proof of registration. Treat it as a shape check only, and always confirm the real answer against VIES before you rely on it for reverse charge.

Validate Spanish VAT numbers without building the checksum tables yourself

vatnode normalizes the input, runs a requester-qualified VIES call to the Spanish node, and returns a stable response with the consultation number for your audit trail. Free plan, 100 requests/month.

Get a free API key · API reference · Spain VAT API reference