Validate French VAT Numbers (TVA) in Node.js

29 May 2026

Validate French VAT Numbers (TVA) in Node.js

France is the second-largest EU economy and one of the markets most B2B SaaS teams need to support early. The French VAT number – numéro de TVA intracommunautaire – has a slightly unusual structure compared to most member states: a two-character key sits between the country code and the SIREN. That key is checksummable, which is useful, but the authoritative yes/no comes from VIES and nowhere else – the French business register can decorate that answer with company data, but it cannot replace it. Here is the full pipeline in Node.js.

What a French VAT number looks like

The French VAT ID is built on top of the company’s SIREN (the 9-digit national company identifier issued by INSEE). The full format is:

  • FR — country code
  • XX — two-character validation key (digits or, in some cases, letters)
  • NNNNNNNNN — the 9-digit SIREN

So FR40123456824 is FR + key 40 + SIREN 123456824. The DGFiP (Direction générale des Finances publiques) documents the format on its TVA intracommunautaire reference page and the SIREN identifier itself is described by INSEE.

A few things worth knowing before you write a regex:

  • The key is usually numeric and derived from the SIREN by a modulo-97 calculation.
  • The key is not always two digits. The official structure allows either or both of those two characters to be a letter – any letter except O and I, which are excluded to avoid confusion with 0 and 1. These are uncommon but entirely legitimate numbers that VIES accepts; DGFiP’s TVA intracommunautaire reference describes the alphanumeric structure. Do not reject letters outright in your regex.
  • The SIRET (14 digits – SIREN + 5-digit establishment code) is not the VAT number. If a customer pastes a SIRET, ask for the TVA number instead.

Step 1: format validation

Reject malformed input before any network call. As with the German VAT number guide, decide up-front which mode the validator runs in:

  • Strict (API mode) – your service exposes a documented FR + 2-char key + 9-digit SIREN format. Reject anything else.
  • Lenient (checkout mode) – accept what customers type and try to make it work. Spaces and a missing FR prefix are common; auto-correct and log the original input.
// Accept the full A-Z set for the 2-char key. Many third-party regexes exclude
// O and I; be permissive at the regex layer and let VIES / DGFiP perform the
// authoritative validation downstream.
const FR_VAT_PATTERN = /^FR[0-9A-Z]{2}\d{9}$/

function normaliseFrenchVatId(
  input: string,
  opts: { mode?: 'strict' | 'lenient' } = {}
): string | null {
  let cleaned = input.replace(/[\s.\-]/g, '').toUpperCase()
  // Lenient mode: prepend FR when the user typed only the key + SIREN
  if (opts.mode === 'lenient' && /^[0-9A-Z]{2}\d{9}$/.test(cleaned)) {
    cleaned = `FR${cleaned}`
  }
  return FR_VAT_PATTERN.test(cleaned) ? cleaned : null
}

// Strict (default)
normaliseFrenchVatId('FR 40 123 456 824') // → 'FR40123456824'
normaliseFrenchVatId('40123456824') // → null

// Lenient — for checkout forms
normaliseFrenchVatId('40123456824', { mode: 'lenient' }) // → 'FR40123456824'
normaliseFrenchVatId('FR4012345682', { mode: 'lenient' }) // → null (wrong length)

The principle here is the same Postel-style discipline that backs reasonable input handling everywhere: be liberal in what you accept at the regex layer, and strict in what you verify against an authoritative source. Accepting the full alpha set for the 2-char key produces a small theoretical risk of false positives (e.g., FRZZ123456789 will pass the regex), but VIES itself will reject those, so the cost is one extra round-trip on garbage input. Tightening the regex to exclude O/I or to reject letters entirely is more likely to produce false negatives – a real customer with a letter-keyed number who cannot complete checkout. The asymmetry of those two failure modes favours the looser regex.

Step 2: the modulo-97 checksum (useful, not authoritative)

Unlike the German MOD 11-10, the French numeric key is publicly documented by the DGFiP. For numeric keys, the formula is:

key = (12 + 3 × (SIREN mod 97)) mod 97

You can verify it locally before making a network call. This is a useful soft signal – if the key does not match, the input is almost certainly wrong – but it is not an authoritative answer. A number can pass the checksum and still be invalid (assigned then deregistered, never assigned by DGFiP, etc.), and a letter-keyed number will not match the numeric formula at all. The authoritative yes/no comes from VIES.

function frChecksumMatches(vatId: string): boolean | null {
  const match = /^FR(\d{2})(\d{9})$/.exec(vatId)
  if (!match) return null // letter key — formula does not apply
  const key = parseInt(match[1], 10)
  const siren = BigInt(match[2])
  const expected = Number((12n + 3n * (siren % 97n)) % 97n)
  return key === expected
}

frChecksumMatches('FR40123456824') // → true
frChecksumMatches('FR99123456824') // → false (likely typo)

Treat a checksum failure as a warning – surface it to the user as ‘this looks wrong, are you sure?’ – and treat a pass as permission to make the VIES call. Do not gate registration on the checksum.

Step 3: VIES – and how France behaves

When you query a FR number through VIES, the European Commission routes the request to the DGFiP system in France. The result your application sees is one of:

  • A valid/invalid answer (with trader name and address when valid and not opted out)
  • MS_UNAVAILABLE (the French node is temporarily unreachable)
  • SERVICE_UNAVAILABLE (VIES itself is degraded)
  • A timeout after ~10 seconds

VIES FR is generally one of the more reliable national nodes, but no national node is online 100% of the time – the VIES downtime guide walks through the failure modes you should design for across all member states. And when the FR node is down, France has no second source that can answer in its place – which is the part teams porting a fallback strategy from Poland or Romania get wrong.

Step 4: SIREN enrichment (not a validity fallback)

It is tempting to reach for the French business register – the SIREN/SIRENE data maintained by INSEE – the moment VIES FR goes down. Do not. That register cannot answer the question VIES answers, so it is not a fallback for validity – it is enrichment layered on top of a VIES verdict. Every active French company has a SIREN, and the register exposes the company’s registered name, address, legal form (nature juridique), NAF activity code, creation date, and administrative status. That is useful data – but it is company data, not VAT status.

Three things to be clear about up front:

  1. SIRENE is a company registry, not a VAT registry. It tells you whether a SIREN exists and is active – it does not confirm VAT registration. A French company can have a perfectly active SIREN and no TVA intracommunautaire number to validate at all, the common case being a small business under the franchise en base de TVA (thresholds are not quoted here; they have been in flux). ‘Active’ in SIRENE tracks the administrative existence of the legal unit, not its VAT regime. Presence there is therefore not an answer to ‘is this VAT number valid’ – deriving valid from it would produce false positives, precisely during an outage when you are least able to check them.
  2. The SIREN → VAT linkage runs one way. You can extract the SIREN from a French VAT number by stripping FR and the 2-char key, which makes the register trivially reachable. The reverse does not hold: an active SIREN tells you nothing about the 2-char key’s validity or the number’s VIES status.
  3. The data is genuinely open. Unlike the Dutch KVK or the Danish CVR wrappers, the French register is served free and without authentication through the Annuaire des Entreprises API at recherche-entreprises.api.gouv.fr, published by DINUM under an open licence. There is no API key, no OAuth token flow, and no quota negotiation. If you want the enrichment, France is the cheapest country in the EU to build it for.

So SIREN’s role is narrow but real: when VIES returns a valid answer, the register adds the company’s creation date, legal form, and NAF activity that VIES itself does not return. VIES stays the validity source of truth; SIRENE decorates the record. And the corollary is the honest part: when VIES FR is unavailable, there is no French validity fallback you can reach. Poland’s MF white list and Romania’s ANAF expose the VAT register itself and are open to anyone; France has no equivalent that is publicly accessible. A verified-VAT-number API does exist inside the French government’s api.gouv.fr ecosystem, but access is granted to authorized administrations through an eligibility process – not to businesses or developers. So SIRENE cannot fill in, and you get the outage.

async function validateFrenchVat(vatId: string) {
  const cleaned = normaliseFrenchVatId(vatId)
  if (!cleaned) {
    return { valid: false, error: 'INVALID_FORMAT' }
  }

  let vies
  try {
    vies = await callVies(cleaned)
  } catch (e) {
    // VIES FR is down. There is NO French validity fallback — SIRENE cannot tell
    // you whether a number is VAT-registered. Surface the outage and queue a
    // re-check; do not invent a verdict from the company register.
    return { valid: null, error: 'VIES_UNAVAILABLE', requiresRecheck: true }
  }

  // VIES answered — it is the source of truth for `valid`.
  const result = {
    valid: vies.valid,
    name: vies.name,
    address: vies.address,
    source: 'VIES',
  }

  // Optional enrichment: only on a VALID number, and never a verdict.
  // This only ever adds fields; it never changes `valid` or `source`.
  if (result.valid) {
    const siren = cleaned.slice(4) // strip FR + 2-char key
    const company = await callAnnuaireEntreprises(siren).catch(() => null)
    if (company) {
      Object.assign(result, {
        registrationDate: company.date_creation,
        legalForm: company.nature_juridique,
        siren,
      })
    }
  }

  return result
}

There are three ways to run the FR pipeline, fastest first:

  1. Use vatnode. It runs VIES FR and layers the SIREN enrichment above onto whatever VIES returns, returns one stable response shape, and includes the consultation number. When VIES FR is down it tells you so honestly – no fabricated verdict. Free for low volume – 100 checks a month, no card – and low cost above that.
  2. Call VIES directly and queue retries on unavailability. Simplest to write. Fine only if your flow can tolerate a ‘we’ll get back to you’ UX during an FR-node outage.
  3. Build the SIREN enrichment yourself. France is the easy case – an unauthenticated GET against recherche-entreprises.api.gouv.fr and a mapping from INSEE’s nature juridique codes to labels you can show a user. The work is not the call; it is the code tables and keeping them current, multiplied by every other member state you eventually add.

Step 5: use vatnode and skip the boilerplate

vatnode runs the full pipeline above on every French request. If VIES FR is healthy, you get a VIES response with name, address, a consultation number, and SIREN-sourced enrichment (creation date, legal form, NAF activity, the formatted SIREN itself) layered on top. The source is always VIES, because VIES is what decides validity. If VIES FR is unavailable, vatnode surfaces that as an error rather than guessing from the company register – there is no French validity fallback to substitute.

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

const data = await res.json()
// {
//   "valid": true,
//   "countryCode": "FR",
//   "vatId": "FR40123456824",
//   "companyName": "Example SAS",
//   "companyAddress": "1 rue de Rivoli, 75001 Paris",
//   "source": "VIES",                  // always VIES — it decides validity
//   "companyRegistrationDate": "2011-03-14",  // SIREN enrichment, when available
//   "companyForm": "SAS",                     // SIREN enrichment, when available
//   "registryCode": "123 456 824",            // the SIREN, formatted
//   "registryCodeName": "numéro SIREN",
//   "taxId": "123 456 824",
//   "taxIdName": "numéro SIREN",
//   "consultationNumber": "WAPIAAAAX...",
//   "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",
//   "verifiedAt": "2026-05-29T08:30:00.000Z"
// }

The source is VIES because VIES is what decides validity; the SIREN enrichment fields simply decorate that verdict. The consultationNumber is the VIES-issued reference described in the VIES consultation number guide. It is issued only when your request includes a valid requester VAT number, and is documentary audit evidence – not a compliance guarantee or a safe harbour. Store it, because auditors may request proof of validation on intra-EU reverse-charge supplies under Council Directive 2006/112/EC.

if (data.valid) {
  // VIES confirmed the number. Use data.companyName and data.companyAddress on
  // the invoice, store consultationNumber, and keep any SIREN enrichment fields.
} else if (data.error === 'VIES_UNAVAILABLE') {
  // The FR node is down and there is no French validity fallback. Do NOT proceed
  // as if the number were valid — queue an asynchronous re-check instead.
}

Rate limiting, caching, and retries

VIES is not a high-throughput service. Both the European Commission and individual member-state nodes will throttle aggressive callers without explicit published limits, and at SaaS scale a few patterns are worth committing to before you hit a problem:

  • Positive cache: 24 hours. A successful VIES validation is good for at least a day in practice – VAT registrations rarely change intraday. Store the response (including consultationNumber) and serve repeat lookups from cache. Many teams cache for 7 days; pick the window that matches your audit posture.
  • Negative cache: short and explicit. If VIES returned invalid, cache that for 5–15 minutes – long enough to absorb retries from the same checkout session, short enough that a customer who just got their VAT ID issued is not blocked for a day. If VIES returned MS_UNAVAILABLE, cache for only 1–2 minutes; that is a transport signal, not a verdict.
  • Request deduplication. During a busy checkout flow, the same VAT ID can be looked up several times within seconds (form blur, server-side re-validation, webhook). Coalesce concurrent in-flight requests for the same vatId to a single VIES call – Redis SETNX with a short TTL works, as does a per-process in-memory promise map.
  • Retry with exponential backoff, then queue. On MS_UNAVAILABLE / SERVICE_UNAVAILABLE / timeout, retry up to 2–3 times with backoff (e.g. 500ms, 2s, 5s). Because there is no French validity fallback, once retries are exhausted you queue for asynchronous re-check rather than blocking the request – you cannot substitute a verdict from SIRENE.
  • Bound the per-customer rate. A customer hammering your form will hammer VIES through you. Apply a per-customer or per-IP soft limit (e.g. 10 lookups/minute) before the upstream call.

These are operational defaults, not legal requirements – they exist to keep your validation pipeline healthy under realistic SaaS traffic.

What to store in your database

For French VAT IDs specifically, your validation log should include the cleaned VAT ID, the extracted SIREN, whether VIES answered, and – when VIES was unavailable – a flag telling a background job to re-check. Validity always comes from VIES, so there is no second source value to record; SIREN data, when present, is enrichment stored alongside.

A minimal Postgres schema:

CREATE TABLE vat_checks_fr (
  id               uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  vat_id           text NOT NULL,
  siren            text NOT NULL,          -- last 9 digits, extracted locally
  valid            boolean,                -- NULL when VIES was unavailable (no verdict yet)
  vies_status      text NOT NULL,          -- 'OK' | 'MS_UNAVAILABLE' | 'SERVICE_UNAVAILABLE' | 'TIMEOUT'
  consultation_no  text,                   -- VIES-issued; never from SIRENE
  trader_name      text,
  trader_address   text,
  registration_date date,                  -- SIREN enrichment
  legal_form       text,                   -- SIREN enrichment (nature juridique)
  requires_recheck boolean NOT NULL DEFAULT false,  -- set when VIES was unavailable
  checked_at       timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON vat_checks_fr (vat_id);
CREATE INDEX ON vat_checks_fr (requires_recheck) WHERE requires_recheck;

Store the SIREN as text, never as an integer – a leading zero silently disappears the moment the value becomes a number. When vies_status was not OK, the row has no verdict (valid stays NULL); a background job picks it up when VIES recovers, re-validates, and updates valid, vies_status, consultation_no, and requires_recheck. The broader Node.js VAT validation guide covers the cross-country schema vatnode customers use for this.

Common gotchas

  • Customer pastes a SIRET (14 digits). Detect the length early; the SIRET is the SIREN plus a 5-digit establishment code and is not a VAT ID. Ask for the numéro de TVA intracommunautaire.
  • Customer omits the FR prefix. In checkout flows, use the lenient mode shown above so a bare 11-character (key + SIREN) input is auto-prefixed. In strict API mode, reject and log the original input.
  • Letter-based key. Real, rare, and your checksum function will return null for these. Don’t reject – let VIES decide.
  • Spaces. French speakers write VAT IDs with spaces (FR 40 123 456 824). Strip them in both modes.
  • VIES FR returns MS_UNAVAILABLE. This is not ‘invalid’. Treat it as a transient error and queue for retry – there is no French validity fallback, so never substitute a SIRENE result and never block checkout on it. The general pattern across all member states is covered in the VIES alternative with automatic fallback write-up.
  • Treating SIRENE as a fallback. The register confirms a company exists; it does not confirm VAT registration. It is enrichment on top of a valid VIES answer, never a substitute when VIES is down.

Validate FR VAT numbers without wiring up SIREN enrichment yourself

vatnode handles VIES FR plus SIREN enrichment in one call, returns a stable response shape with a source field, and includes the VIES consultation number for your audit trail – and when VIES FR is down it surfaces a transient error instead of guessing. Free plan, 100 requests/month.

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