IBAN validation is the entry point. It is the simplest possible thing to get right on a payment surface: the format is fixed, the algorithm is mod-97, no network call required. It is also where 90% of "the payment bounced" production bugs originate, because the next layer up assumes the IBAN is real and routable.
This post covers: the ISO 13616 format, the mod-97 algorithm with a 9-digit chunked implementation that avoids BigInt, the per-country length table, the Swiss QR-IBAN range check, and the BIC-drift situation in modern SEPA.
The format
An IBAN is a fixed-format account identifier defined by ISO 13616. The first two characters are the ISO 3166-1 alpha-2 country code (CH, DE, GB, US — note: the US is NOT in the IBAN registry, US accounts have no IBAN). The next two are check digits. The remainder is the country-specific BBAN (Basic Bank Account Number), with a fixed per-country length: 15 chars total for Norway, 22 for Germany, 27 for France, 34 for Malta and Saint Lucia.
The mod-97 algorithm
The validation algorithm has three steps:
- 1. Strip whitespace. Uppercase. (Many UIs render IBANs with thin-space separators every 4 chars — strip them.)
- 2. Move the first 4 characters (country code + check digits) to the end.
- 3. Replace each letter with its A=10..Z=35 equivalent — so the IBAN becomes a long decimal string.
- 4. Compute the integer mod 97. If the result is 1, the IBAN is valid.
The integer in step 4 can be up to 34 + 4 = 38 digits long, which is well above the 16-digit safe-integer range in JavaScript. The naive fix is BigInt, but BigInt is slow on the V8 hot path. The practical fix: chunk through 9-digit windows. After each chunk, the partial result fits in 32-bit safe-integer range, and the modulo carries forward cleanly. The pure-local browser implementation at lib/iban.ts uses exactly this pattern.
export function isValidIban(raw: string): boolean {
const iban = raw.replace(/\s+/g, '').toUpperCase()
if (iban.length < 15 || iban.length > 34) return false
if (!/^[A-Z0-9]+$/.test(iban)) return false
const rearranged = iban.slice(4) + iban.slice(0, 4)
const digits = rearranged.replace(/[A-Z]/g, (c) =>
String(c.charCodeAt(0) - 55))
let remainder = 0
for (let i = 0; i < digits.length; i += 9) {
remainder = Number(String(remainder) + digits.slice(i, i + 9)) % 97
}
return remainder === 1
}The Swiss QR-IBAN range
A QR-IBAN is a regular Swiss or Liechtenstein IBAN whose institution identifier — digits 5..9 — lives in the range 30000–31999. The range is reserved by SIX Interbank Clearing for QR-bill-issuance-ready accounts.
The relevance: a payment-part rendered against a QR-IBAN is REQUIRED to carry a QRR (27-digit numeric reference). A payment-part against a regular CH IBAN can use SCOR or unstructured remittance. A QR-bill builder that always picks QRR or always picks SCOR will produce slips that the issuer's bank rejects.
The check is one slice + one range comparison:
export function isQrIban(raw: string): boolean {
const iban = raw.replace(/\s+/g, '').toUpperCase()
if (!iban.startsWith('CH') && !iban.startsWith('LI')) return false
const iid = Number(iban.slice(4, 9))
return iid >= 30000 && iid <= 31999
}Why valid-mod-97 does not mean valid-account
The mod-97 check guarantees that the digits are internally consistent. It does NOT guarantee that:
- The account exists at the named bank.
- The account is open.
- The account belongs to the named beneficiary.
- The account accepts inbound transfers (e.g. savings-only accounts may not).
- The account is in good standing.
Beneficiary-name verification ("Confirmation of Payee" in the UK, "SurePay" in NL, "VOP" in the SEPA-area) is a separate rail-side service. SEPA SCT is rolling out a VOP overlay on a 2025–2026 timeline; the customer-side implementation is a pre-payment lookup against the beneficiary bank.
BIC drift in SEPA
Pre-2016: SEPA SCT required both IBAN and BIC. Post-2016: SCT runs under the "IBAN-only" rule for domestic euro transfers — the BIC is optional and ignored by the rail. Most SEPA banks today no longer require a BIC on outbound pain.001.
But CBPR+ cross-border, US Fedwire (with the post-2025 ISO 20022 migration), and any non-SEPA international flow still require the BIC. The pragmatic emission strategy: emit the BIC when you have it, omit when the rule pack permits.
The free tier
iso-compliant's /iban-check page on the marketing site is the same algorithm in your browser — pure local, no network, no API key. The agent-tier equivalent is isocompliant.validate_iban via the MCP server. The paid-tier equivalent is POST /v1/iban/validate which adds the cached SIX Bank Master + EBA Clearing lookup.
For a treasury engineer evaluating iso-compliant, the IBAN validator is the lowest-friction entry point — paste an IBAN, see a result, no signup.