Open source · Free npm package

Offline EU identifier
validation

Checksum-accurate VAT, IBAN, BSN and KvK validation that runs entirely on your machine. No network calls, no dependencies.

Latest npm versionMinified + gzipped bundle sizeTypeScript types includedMonthly npm downloadsLicense

Three lines to a valid number

Import the check you need and call it. Synchronous, offline, done.
# 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

Built for products that handle European business and personal identifiers.
  • Fully offline
    Every check runs locally with no network calls — no VIES round-trips, no rate limits, no data leaving your server.
  • VAT for all 27 EU countries
    Country-specific VAT checksum validation for every EU member state — structure and check digits, not just a regex.
  • IBAN validation
    ISO 13616 IBAN checks with mod-97 verification and per-country length rules.
  • BSN & KvK
    Dutch 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 dependencies
    Tiny, tree-shakeable, and dependency-free. Ships ESM + types, works in Node and the browser.
  • Typed & tested
    Written in TypeScript with full types and a green test suite covering every checksum path.

The business case for offline validation

Why engineering leads at EU fintech, billing and B2B-checkout teams move identifier validation off the network.

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.

eu-validate (offline checksum)~0 ms

~0 ms · no network

VIES lookup — best case400 ms

healthy, uncongested

VIES lookup — typical900 ms

normal load

VIES lookup — under load / retry2,000 ms

throttled or degraded

Network-based lookup
Your trust boundary
Your app
ID + client IP leave the boundary
External validator (VIES / API)

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.

Offline validation (eu-validate)
Your trust boundary
Your app
validate()
nothing leaves the boundary

Identifier is validated in-process against the checksum algorithm and never leaves your environment — no network, no egress, instant result.

Production recipes

Real problems your product hits in EU markets — solved with the published API.

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

The questions a CTO asks before adding a package to production — answered up front.
Metric / concern What ships
Performance0 ms network · synchronous
Data isolationNo network in core
Bundle size~3 KB min+gzip
Dependencies0 runtime deps
Type safetyShips .d.ts (ESM + CJS)
LicensingMIT (core)
Live lookupsOptional /cloud, API-key

Need more than the open-source core?

eu-validate is free and MIT-licensed. When you need a hosted feature, a custom build, or a fast answer from the person who wrote it, there is a paid path — backed by the founder, not a ticket queue.
  • 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