Validate French VAT Numbers (TVA) in Node.js
29 May 2026 · 8 min read

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 still comes from VIES or, when VIES FR is degraded, from French national sources. 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 codeXX— 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.
- A subset of older or specially-issued numbers uses a letter-based key (two alphabetic characters, with
OandItypically excluded to avoid confusion with0and1). These are rare but legitimate cases — DGFiP's TVA intracommunautaire reference describes the alphanumeric structure and VIES will accept them. 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
FRprefix 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. When VIES FR is unavailable, you have a useful fallback that not every country offers: the SIRENE database.
Step 4: INSEE / SIRENE fallback
SIRENE is the French national company registry, maintained by INSEE. Every active French company has a SIREN, and SIRENE exposes a public API (api.insee.fr) that returns the company's registered name, address, NAF activity code, and — crucially — its administrative status (active, ceased).
Two things to be clear about up front:
- SIRENE is a company registry, not a VAT registry. It tells you whether a SIREN exists and is active, not whether the company is currently registered for intra-EU VAT. A French company can have an active SIREN and not be VAT-registered (small businesses below the franchise en base de TVA threshold), or be VAT-registered domestically but not for intra-EU operations.
- SIRENE is therefore a fallback signal, not an answer. If VIES FR is down and SIRENE confirms the SIREN is active and the registered name matches what your customer entered, you have a reasonable basis to proceed and queue a VIES re-check. If SIRENE says the SIREN does not exist or the company has ceased trading, you almost certainly should not.
INSEE's API requires registering for an API key on api.insee.fr. It is free for moderate use and well-documented.
async function validateFrenchVat(vatId: string) {
const cleaned = normaliseFrenchVatId(vatId)
if (!cleaned) {
return { valid: false, error: 'INVALID_FORMAT' }
}
try {
const vies = await callVies(cleaned)
if (vies.status === 'OK') {
return {
valid: vies.valid,
name: vies.name,
address: vies.address,
source: 'VIES',
}
}
} catch (e) {
// fall through to SIRENE
}
// VIES FR unavailable — try SIRENE on the SIREN portion
const siren = cleaned.slice(4) // strip FR + 2-char key
const sirene = await callSirene(siren)
return {
valid: sirene.active,
name: sirene.denomination,
address: sirene.address,
source: 'SIRENE',
note: 'company is active in SIRENE; VIES re-check queued',
}
}
You have three honest options for the FR fallback path:
- Build the INSEE/SIRENE integration directly. Register for an INSEE API key, handle the OAuth-style token flow, map the response. A weekend of work plus ongoing maintenance.
- Queue retries against VIES without a fallback. Simplest. Fine if your flow can tolerate a "we'll get back to you" UX.
- Use a VAT validation provider that already runs the pipeline. Several exist; the rest of this article shows how vatnode does it.
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 and address and a consultation number. If VIES FR is unavailable, vatnode automatically queries SIRENE on the SIREN portion and returns a SIRENE-sourced response, with a follow-up VIES re-check queued.
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",
// "vatNumber": "40123456824",
// "name": "Example SAS",
// "address": "1 rue de Rivoli, 75001 Paris",
// "source": "VIES", // or "SIRENE"
// "consultationNumber": "WAPIAAAAX...",
// "checkedAt": "2026-05-29T08:30:00.000Z"
// }
The source field is what lets your invoicing logic know which path served the response. The consultationNumber is the VIES-issued reference described in the VIES consultation number guide — store it as audit evidence, because auditors may request proof of validation on intra-EU reverse-charge supplies under Council Directive 2006/112/EC.
if (data.source === 'VIES') {
// Use data.name and data.address on the invoice; store consultationNumber
} else if (data.source === 'SIRENE') {
// SIRENE confirmed the company exists and is active —
// proceed, but flag the record for a VIES re-check
}
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 returnedMS_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
vatIdto a single VIES call — RedisSETNXwith a short TTL works, as does a per-process in-memory promise map. - Retry with exponential backoff, capped. On
MS_UNAVAILABLE/SERVICE_UNAVAILABLE/ timeout, retry up to 2–3 times with backoff (e.g. 500ms, 2s, 5s), then fall over to SIRENE. Beyond that, queue for asynchronous re-check rather than blocking the request. - 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 (uppercase, no separators)
- The extracted SIREN (last 9 digits)
valid(boolean)source(VIESorSIRENE)consultationNumber(if from VIES)nameandaddress(whichever source returned them)checkedAttimestamprequiresRecheck(boolean — true when only SIRENE answered)
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,
valid boolean NOT NULL,
source text NOT NULL CHECK (source IN ('VIES', 'SIRENE')),
consultation_no text,
trader_name text,
trader_address text,
requires_recheck boolean NOT NULL DEFAULT false,
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;
If source was SIRENE, a background job picks up the row when VIES recovers, re-validates, and updates source, 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
FRprefix. 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
nullfor 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, fall back to SIRENE or queue for retry, and never block checkout on it. The general pattern across all member states is covered in the VIES alternative with automatic fallback write-up.
Validate FR VAT numbers without building the SIRENE fallback yourself
vatnode handles VIES FR plus INSEE/SIRENE fallback in one call, returns a stable response shape with a source field, and includes the VIES consultation number for your audit trail. Free plan, 100 requests/month.