Offline EU identifier
validation
Checksum-accurate VAT, IBAN, BSN and KvK validation that runs entirely on your machine. No network calls, no dependencies.
Three lines to a valid number
# Install
npm install @alosha/eu-validate
# Use — every validator returns a ValidationResult
import { validateVAT, validateIBAN, validateBSN } from '@alosha/eu-validate'
validateVAT('NL810433941B01').valid // → true
validateIBAN('NL91ABNA0417164300').valid // → true
const r = validateBSN('111222334')
r.valid // → false
r.errors // → ['CHECKSUM_FAILED'] (fails 11-proef)Everything you need to validate EU data
- Fully offlineEvery check runs locally with no network calls — no VIES round-trips, no rate limits, no data leaving your server.
- VAT for all 27 EU countriesCountry-specific VAT checksum validation for every EU member state — structure and check digits, not just a regex.
- IBAN validationISO 13616 IBAN checks with mod-97 verification and per-country length rules.
- BSN & KvKDutch BSN checksum validation (11-proef) plus KvK format validation (8 digits) — handy for NL-focused products. KvK has no public checksum, so it's format-only; use the Cloud client to confirm a number is registered.
- Zero dependenciesTiny, tree-shakeable, and dependency-free. Ships ESM + types, works in Node and the browser.
- Typed & testedWritten in TypeScript with full types and a green test suite covering every checksum path.
The business case for offline validation
Eliminate checkout friction and 5xx errors from external API networks
Calling VIES or a third-party VAT API in the hot path of a payment ties your checkout’s success rate to someone else’s uptime — and to the public internet on a bad day.
Risk of the network-based approach
- Latency
A live VAT/IBAN lookup adds 400–2000 ms to the request — paid for on every submit, right when the user is about to convert.
- Single point of failure
VIES has scheduled downtime and per-member-state outages; when it 5xxs or times out, your checkout inherits the failure.
- Rate limits
Government endpoints throttle aggressively, so traffic spikes — exactly when revenue is highest — get rejected.
With eu-validate: eu-validate runs the structural and checksum validation in-process (Modulo-97 for IBAN, 11-proef for BSN, country VAT rules) with zero network calls — instant, 100% uptime. You only ever spend a VIES round-trip on numbers that already passed the offline checksum, so live lookups become rare and non-blocking.
GDPR alignment: network-based VAT lookups leak user data
Every mid-checkout call to a third-party validation network ships personal and transactional metadata off your infrastructure — a data-egress event you have to account for under audit.
Risk of the network-based approach
- Data egress
The VAT/BSN/IBAN, plus the originating client IP and request timing, land in an external provider’s infrastructure logs.
- Extra DPAs
Each external validator becomes a sub-processor you must disclose, contract with, and defend in a data-protection review.
- Location leakage
Client IPs exposed to downstream networks reveal user location to parties outside your trust boundary.
With eu-validate: Because every core validator is a pure, synchronous function, sensitive identifiers never leave the user’s session or your server. There is no external sub-processor, no IP exposure and no egress to document — the simplest possible GDPR posture for identifier validation.
Network round-trip vs offline computation
Representative time added to a single validation. Offline checksums run in-process; network lookups pay for the public internet on every call.
~0 ms · no network
healthy, uncongested
normal load
throttled or degraded
Identifier + client IP leave your trust boundary, cross the public internet to an external validator, and land in its logs — added latency and a new sub-processor.
Identifier is validated in-process against the checksum algorithm and never leaves your environment — no network, no egress, instant result.
Production recipes
Reject bad VAT numbers before you ever call VIES
The problem: A B2B checkout must apply reverse-charge VAT, but VIES is slow, rate-limited, and rejects malformed input anyway.
import { validateVAT } from '@alosha/eu-validate'
// Hosted VIES lookups (@alosha/eu-validate/cloud) ship in Phase 3 — coming soon.
// The offline validateVAT() below works today; the createClient()/verifyVAT() calls
// are forward-looking. See "Confirm VAT registration…" below for graceful fallback.
import { createClient } from '@alosha/eu-validate/cloud'
const eu = createClient({ apiKey: process.env.ALOSHA_KEY! })
export async function resolveVat(input: string) {
// 1. Offline first — structure + checksum, zero network, instant. Works today.
const offline = validateVAT(input)
if (!offline.valid) {
return { ok: false, reason: offline.errors[0] } // e.g. 'CHECKSUM_FAILED'
}
// 2. Spend a VIES round-trip only on numbers that already pass the checksum.
// (Requires the hosted /cloud tier — coming soon.)
const live = await eu.verifyVAT(offline.normalized!)
return { ok: live.registered, company: live.name }
}Why it works: The offline checksum filters out typos and fabricated numbers for free, so the slow, rate-limited VIES call only ever runs on structurally valid input. You cut checkout latency and stop burning your VIES quota on garbage. (The hosted VIES step uses @alosha/eu-validate/cloud, which ships in Phase 3 — the offline validation works today.)
Validate Dutch BSN and IBAN without sending PII anywhere
The problem: An onboarding form collects a BSN and IBAN, but shipping those to a third-party validation API is a GDPR data-egress problem.
import { validateBSN, validateIBAN } from '@alosha/eu-validate'
// Pure, synchronous, offline — the values never leave the user's session.
export function validateOnboarding(form: { bsn: string; iban: string }) {
const bsn = validateBSN(form.bsn)
const iban = validateIBAN(form.iban)
return {
valid: bsn.valid && iban.valid,
fields: {
bsn: bsn.valid ? null : bsn.errors[0], // e.g. 'CHECKSUM_FAILED'
iban: iban.valid ? null : iban.errors[0] // e.g. 'INVALID_FORMAT'
}
}
}Why it works: Every validator is a pure function with no network call, so sensitive identifiers like a BSN never reach an external processor. You get instant inline form feedback and one fewer data-processing agreement to sign.
Confirm VAT registration when you can, fall back gracefully when you can’t
The problem: A live VIES registration check on top of the offline checksum can fail for reasons that have nothing to do with the VAT number — the hosted endpoint isn’t live yet, a timeout, a bad response — and none of those should look like "this VAT is invalid."
import { validateVAT } from '@alosha/eu-validate'
import { createClient, CloudNotAvailableError, CloudTimeoutError, CloudApiError } from '@alosha/eu-validate/cloud'
const eu = createClient({ apiKey: process.env.ALOSHA_KEY! })
export async function checkVat(input: string) {
const offline = validateVAT(input)
if (!offline.valid) {
return { status: 'invalid' as const, reason: offline.errors[0] }
}
try {
const live = await eu.verifyVAT(offline.normalized!)
return { status: (live.registered ? 'registered' : 'not_registered') as const, company: live.name }
} catch (err) {
if (err instanceof CloudNotAvailableError) {
// Hosted lookups aren't live yet — the offline checksum already passed, so degrade
// instead of failing the request.
return { status: 'format_valid_unconfirmed' as const, reason: 'cloud_not_available' }
}
if (err instanceof CloudTimeoutError || err instanceof CloudApiError) {
// Transient — don't tell the user their VAT number is wrong because VIES hiccuped.
return { status: 'format_valid_unconfirmed' as const, reason: 'cloud_error' }
}
throw err
}
}Why it works: verifyVAT() throws typed errors instead of a generic Error, so a Cloud outage or the not-yet-shipped Phase 3 endpoint never gets confused with "the VAT number is wrong." The offline checksum already did the hard rejection work, so every Cloud failure mode here degrades to "unconfirmed" instead of blocking the user.
Reject malformed identifiers at the edge of your API
The problem: Every route that accepts a VAT, IBAN or BSN re-implements the same `if (!result.valid) return res.status(400)...` boilerplate, and it's easy for one route to forget a field.
import express from 'express'
import { validateIBAN, validateVAT, assertValid, ValidationError } from '@alosha/eu-validate'
const app = express()
app.use(express.json())
app.post('/payouts', (req, res) => {
try {
const iban = assertValid(validateIBAN(req.body.iban))
const vat = assertValid(validateVAT(req.body.vat))
return res.json({ ok: true, iban: iban.normalized, vat: vat.normalized })
} catch (err) {
if (err instanceof ValidationError) {
return res.status(400).json({ ok: false, type: err.result.type, errors: err.result.errors })
}
throw err
}
})Why it works: assertValid() turns the usual "check .valid, then branch" dance into a single throw, so one catch block at the route boundary handles every identifier field the same way. ValidationError carries the full failing ValidationResult, so the 400 response tells the caller exactly which field and error code failed — no per-route boilerplate.
Validate a full Dutch company-onboarding form in one pass
The problem: A B2B signup form for the Netherlands collects four different identifier types — KvK, BSN, IBAN, VAT — and hand-wiring four separate validateX() calls means the field list and the validator list drift apart as the form grows.
import { validate, type ValidateOptions } from '@alosha/eu-validate'
const ONBOARDING_FIELDS: Record<string, ValidateOptions> = {
kvkNumber: { type: 'kvk' },
bsn: { type: 'bsn' },
iban: { type: 'iban' },
vatNumber: { type: 'vat' }
}
export function validateOnboardingForm(form: Record<string, string>) {
const fields = Object.fromEntries(
Object.entries(ONBOARDING_FIELDS).map(([field, options]) => [
field,
validate(form[field] ?? '', options)
])
)
return {
valid: Object.values(fields).every((r) => r.valid),
fields // each entry is a full ValidationResult — keep `errors` for inline form feedback
}
}Why it works: The dispatcher means the field list is the single source of truth — add a row to ONBOARDING_FIELDS and the loop picks it up, instead of a fifth hand-written validateX() call drifting out of sync with the form. Every field still gets the same typed ValidationResult, so existing per-field error rendering keeps working unchanged.
Clean up a customer VAT list before a VIES batch run
The problem: A finance team exports thousands of customer VAT numbers for a quarterly VIES re-verification, and running every row through VIES — typos, copy-paste artifacts and all — wastes the rate-limited quota on input that was never going to pass.
import { readFileSync, writeFileSync } from 'node:fs'
import { validateVAT } from '@alosha/eu-validate'
const rows = readFileSync('customers.csv', 'utf8')
.trim()
.split('\n')
.slice(1) // drop header
.map((line) => line.split(','))
const clean: string[] = ['customer_id,vat_number']
const rejected: string[] = ['customer_id,vat_number,error']
for (const [customerId, vat] of rows) {
const result = validateVAT(vat)
if (result.valid) {
clean.push(`${customerId},${result.normalized}`)
} else {
rejected.push(`${customerId},${vat},${result.errors[0]}`)
}
}
writeFileSync('clean.csv', clean.join('\n'))
writeFileSync('rejected.csv', rejected.join('\n'))
console.log(`${clean.length - 1} clean, ${rejected.length - 1} rejected — only clean.csv needs a VIES call.`)Why it works: The checksum pass is synchronous and free, so a list of 10,000 numbers is sorted into "worth a VIES call" and "already known bad" in milliseconds, with the specific error code attached to every rejected row for the finance team to act on. You spend VIES quota only on numbers that have a chance of being real.
Built to pass a dependency review
| Metric / concern | What ships |
|---|---|
| Performance | 0 ms network · synchronous |
| Data isolation | No network in core |
| Bundle size | ~3 KB min+gzip |
| Dependencies | 0 runtime deps |
| Type safety | Ships .d.ts (ESM + CJS) |
| Licensing | MIT (core) |
| Live lookups | Optional /cloud, API-key |
Need more than the open-source core?
- Hosted VIES VAT registration lookups via @alosha/eu-validate/cloud (coming soon)
- KvK company-register lookups with a single API key
- Priority bug fixes and answers straight from the maintainer
- Custom validators or extra country coverage on request