Validate Polish VAT Numbers (NIP) in Node.js
17 July 2026 · 8 min read

Poland is one of the largest B2B markets in Central Europe, and its VAT number is refreshingly simple to parse: the EU VAT ID is just PL followed by the 10-digit NIP. The format check and the mod-11 checksum are things you can do locally in a few lines of Node.js, but neither of them tells you whether a number is actually registered — the authoritative yes/no comes from VIES, with the Polish Ministry of Finance white list as a fallback. Here is the full pipeline.
What a Polish VAT number looks like
The Polish VAT ID is built directly on the NIP (Numer Identyfikacji Podatkowej), the taxpayer identification number. There is no separate validation key or establishment code layered on top — the EU VAT number is simply:
PL— country codeNNNNNNNNNN— the 10-digit NIP (the same digits, no extra identifier)
So PL7342867148 is PL + the NIP 7342867148. The NIP is often printed with cosmetic groupings like 734-286-71-48 or 734-28-67-148, but those hyphens carry no meaning — validate the raw 10-digit string.
A couple of things worth knowing before you write a regex:
- Unlike France, there is no SIRET-style confusion here — the NIP is the domestic identifier that becomes the VAT number. But Poland does issue other registry numbers that customers paste by mistake: REGON (9 or 14 digits, the statistical business register number) and KRS (the National Court Register number for companies). Neither is a VAT ID. If someone sends you a 9-digit number, they almost certainly gave you a REGON, not a NIP.
- The 10 digits are all you validate. Strip spaces and dashes first.
Step 1: format validation
Reject malformed input before any network call. As with the German VAT number guide and the French guide, decide up-front which mode the validator runs in:
- Strict (API mode) — your service exposes a documented
PL+ 10-digit NIP format. Reject anything else. - Lenient (checkout mode) — accept what customers type and try to make it work. Spaces, dashes, and a missing
PLprefix are common; auto-correct them and log the original input.
const PL_VAT_PATTERN = /^PL\d{10}$/
function normalisePolishVatId(
input: string,
opts: { mode?: 'strict' | 'lenient' } = {}
): string | null {
let cleaned = input.replace(/[\s.\-]/g, '').toUpperCase()
// Lenient mode: prepend PL when the user typed only the 10-digit NIP
if (opts.mode === 'lenient' && /^\d{10}$/.test(cleaned)) {
cleaned = `PL${cleaned}`
}
return PL_VAT_PATTERN.test(cleaned) ? cleaned : null
}
// Strict (default) — API consumers should send a fully-qualified ID
normalisePolishVatId('PL 734-286-71-48') // → 'PL7342867148'
normalisePolishVatId('7342867148') // → null
// Lenient — for checkout forms where customers paste the NIP
normalisePolishVatId('7342867148', { mode: 'lenient' }) // → 'PL7342867148'
normalisePolishVatId('PL734286714', { 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. The regex proves shape only — it never proves registration. A ten-digit string that passes PL_VAT_PATTERN is just well-formed input, nothing more.
Step 2: the mod-11 checksum (useful, not authoritative)
The NIP carries a mod-11 check digit that you can verify locally before spending a network round-trip. Weights [6, 5, 7, 2, 3, 4, 5, 6, 7] are applied to the first nine digits; you sum the products, take sum % 11, and that result must equal the tenth digit.
There is one detail that catches almost everyone: if sum % 11 === 10, the NIP is invalid. You must not map 10 back to 0 the way some checksum schemes do — a NIP whose remainder is 10 simply cannot exist, so reject it outright.
function nipChecksumMatches(vatId: string): boolean | null {
const match = /^PL(\d{10})$/.exec(vatId)
if (!match) return null // not a well-formed PL NIP
const nip = match[1]
const weights = [6, 5, 7, 2, 3, 4, 5, 6, 7]
const sum = weights.reduce((acc, w, i) => acc + w * Number(nip[i]), 0)
const remainder = sum % 11
// A remainder of 10 can never be a valid check digit — do NOT map it to 0.
if (remainder === 10) return false
return remainder === Number(nip[9])
}
nipChecksumMatches('PL7342867148') // → true
nipChecksumMatches('PL7342867149') // → false (likely typo)
Like the French modulo-97 key, this is a useful soft signal, not an answer. A number can pass the checksum and still be invalid — assigned then deregistered, never issued by the tax office, or belonging to an entity that is registered but not for intra-Community VAT. The authoritative yes/no comes from VIES.
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 Poland behaves
When you query a PL number through VIES, the European Commission routes the request to the tax administration in Poland. The result your application sees is one of:
- A valid/invalid answer. For Polish numbers, VIES typically returns the trader name and address when valid — but treat that as observed behaviour, not a guarantee. Any member state may withhold trader details, and the PL node can be unavailable or time out.
MS_UNAVAILABLE(the Polish node is temporarily unreachable)SERVICE_UNAVAILABLE(VIES itself is degraded)- A timeout after ~10 seconds
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 PL is unavailable, Poland offers a useful fallback that not every country does: the Ministry of Finance white list.
Step 4: MF white list ("Wykaz podatników VAT") fallback
The Polish Ministry of Finance (Ministerstwo Finansów) publishes the VAT white list — Wykaz podatników VAT, colloquially the biała lista — as a public JSON REST API. The base URL is https://wl-api.mf.gov.pl (https://wl-test.mf.gov.pl for testing), and it is documented on the official gov.pl API page.
A few things about this API differ from a plain "is this valid right now?" call, and you have to design around them:
- A query requires a date. Every request takes a
data=YYYY-MM-DDparameter — you always ask "what was the status on this day?", there is no dateless "just now" lookup. statusVathas three values:Czynny(active VAT payer),Zwolniony(registered but VAT-exempt), andNiezarejestrowany(not registered / removed). A successful response also carries the entity name, REGON, KRS, address, and the entity's registered bank accounts.- Rate limits are strict. The
searchmethod allows 100 queries/day with at most 30 entities per query; thecheckmethod allows up to 5000 entities. Exceed the limit and you are blocked until midnight.
The white list's primary legal purpose is confirming domestic VAT status and whether a bank account belongs to the entity (for Polish split-payment and expense-deductibility rules). It is not the source of truth for intra-Community validity — VIES is. Its data also refreshes once per business day, so it can lag same-day changes. Treat a white-list "Czynny" as a fallback signal and queue a VIES re-check.
That caveat shapes how you wire the fallback: exactly like the French SIRENE path, the white list confirms the entity exists and is domestically active, which is a reasonable basis to proceed while a VIES re-check is queued — not a cross-border verdict.
async function validatePolishVat(vatId: string) {
const cleaned = normalisePolishVatId(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 the MF white list
}
// VIES PL unavailable — query the white list on the NIP, as of today
const nip = cleaned.slice(2) // strip the PL prefix
const today = new Date().toISOString().slice(0, 10) // YYYY-MM-DD
const wl = await callMfWhiteList(nip, today)
return {
valid: wl.statusVat === 'Czynny',
name: wl.name,
address: wl.address,
statusVat: wl.statusVat, // Czynny | Zwolniony | Niezarejestrowany
source: 'MF_PL',
note: 'domestic status from MF white list; VIES re-check queued',
}
}
You have three honest options for the PL fallback path:
- Build the white-list integration directly. Handle the mandatory date parameter, the
searchvscheckrate limits, and the domestic-vs-cross-border caveat in your own code. Doable, 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 Polish request. If VIES PL is healthy, you get a VIES response with name, address, and a consultation number. If VIES PL is unavailable, vatnode queries the MF white list on the NIP and returns an MF_PL-sourced response, with a VIES re-check queued.
const res = await fetch('https://api.vatnode.dev/v1/vat/PL7342867148', {
headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` },
})
const data = await res.json()
// {
// "valid": true,
// "countryCode": "PL",
// "vatNumber": "7342867148",
// "name": "Example Sp. z o.o.",
// "address": "ul. Przykładowa 1, 00-001 Warszawa",
// "source": "VIES", // or "MF_PL"
// "consultationNumber": "WAPIAAAAX...",
// "checkId": "018ef2a1-7c1d-7a3b-8f4e-1b2c3d4e5f60",
// "verifiedAt": "2026-07-17T08:30:00.000Z"
// }
The source field is what lets your invoicing logic know which path served the response — VIES for the cross-border verdict, MF_PL for the Ministry of Finance white-list fallback. 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.source === 'VIES') {
// Use data.name and data.address on the invoice; store consultationNumber
} else if (data.source === 'MF_PL') {
// White list confirmed domestic status —
// proceed, but flag the record for a VIES re-check
}
Rate limiting, caching, and retries
VIES is not a high-throughput service, and the MF white list will hard-block you at midnight if you blow past its daily quota. 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 NIP registered 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
vatIdinto a single upstream call — RedisSETNXwith a short TTL works, as does a per-process in-memory promise map. This matters doubly for the white list, where every avoided call preserves your 100/daysearchbudget. - 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 the white list. 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 — and your white-list quota — 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 Polish VAT IDs specifically, your validation log should include the cleaned VAT ID, the extracted NIP, the source that answered, and — when only the white list answered — a flag telling a background job to re-check against VIES:
CREATE TABLE vat_checks_pl (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
vat_id text NOT NULL,
nip text NOT NULL,
valid boolean NOT NULL,
source text NOT NULL CHECK (source IN ('VIES', 'MF_PL')),
consultation_no text,
entity_name text,
entity_address text,
status_vat text, -- Czynny | Zwolniony | Niezarejestrowany (MF_PL only)
requires_recheck boolean NOT NULL DEFAULT false,
checked_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON vat_checks_pl (vat_id);
CREATE INDEX ON vat_checks_pl (requires_recheck) WHERE requires_recheck;
Store the NIP as text, never as an integer — a NIP with a leading zero silently loses that digit the moment it becomes a number. If source was MF_PL, 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
- Leading-zero NIP stored as a number. A NIP starting with
0loses the digit if it ever touches an integer column or a JavaScriptNumber. Keep it as a string end to end. - Customer pastes a REGON or KRS. A 9-digit number is almost always a REGON, not a NIP. Detect the length early and ask for the NIP.
- Spaces and dashes. Poles write the NIP grouped (
734-286-71-48). Strip separators in both modes before matching. sum % 11 === 10. This remainder can never be a valid check digit — reject it. Do not map 10 back to 0.- The white list is daily, not real-time. A
Czynnyresult reflects the last business-day refresh, so treat it as a fallback signal and queue a VIES re-check for the cross-border answer. - VIES PL returns
MS_UNAVAILABLE. This is not "invalid". Treat it as a transient error, fall back to the white list 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 PL VAT numbers without building the white-list fallback yourself
vatnode handles VIES PL plus the MF white list 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.