Check bank details before a transfer
Check that an IBAN, typed or pasted, is well-formed, before the transfer goes out.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the check is two arithmetic identities and a table, and because what is at stake — a transfer that leaves once — needs exactly what determinism gives: the same answer on the same input, and the carry that says which of the two keys failed. The RIB key is not an ornament: across five hundred French IBANs, the ISO key alone lets 31 of 6,882 lookalike letters typed for a digit through — 0.45 % — because the French account number is alphanumeric in the registry, and the RIB key closes all 6,882 measured cases. The three levels above are shut for the same reason, and the question that remains — is this really your supplier’s account — is now put to the payment provider, not to a model.
ISO 13616 check digits and the French RIB key, against the IBAN registry
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Check an IBAN before a transfer leaves, offline and exactly.
Rung N0. ISO 13616 puts two check digits in positions 3 and 4 of every IBAN,
computed over the rest of the number by the modulo 97 of ISO 7064. The country
code fixes the length — 27 characters in France, 22 in Germany, 16 in Belgium —
and the shape of what follows. All of that is a table and an arithmetic
identity: the answer is exact and free.
The table is the part you do not write. `python-stdnum` here and `ibantools` in
JavaScript both carry the IBAN registry, revised as countries join.
On top of ISO 13616, several countries put a second key inside the national
part, and the two libraries do not cover the same ones — measured, not assumed:
`python-stdnum` carries no French module, so `iban.is_valid` checks the ISO key
and the registry format and nothing else, while `ibantools` does refuse
`FR0630006000011234567890188`, whose RIB key is wrong. Two libraries that
answer differently on the same number are two snippets that would answer
differently on the same page, so this one computes the RIB key itself, in both
languages. Elsewhere, a number that passes here can still be refused by the
receiving bank.
Measured, each on its own set: not one of the 20 700 digit-for-digit slips of a
hundred generated French IBANs gets through, nor one of their 1 973 adjacent
transpositions, nor one of the 6 882 lookalike-letter slips of five hundred of
them. The ISO key alone lets 31 of those 6 882 pass — 0.45 % — because the
French account number is alphanumeric by registry and `I` for `1` is a legal
character there; the RIB key is what closes that. The JavaScript side generates
its own numbers and measures 32 of 6 892, which is 0.46 %.
And none of it says whose account it is. That question is answered by the payee
verification service every euro-area provider has had to offer since 9 October
2025, not by a longer computation.
"""
from __future__ import annotations
from stdnum import iban
from stdnum.exceptions import ValidationError
# What people paste around an IBAN: the printed grouping in spaces of every
# kind, and the hyphens a word processor inserts. Anything else left in the
# string is refused rather than dropped, because dropping it would turn a
# mistyped account into a different valid one.
SEPARATORS = " -‐‑–"
# ISO 13616 caps an IBAN at 34 characters and no country is under 15. Sixty-four
# leaves room for every separator and refuses a pasted paragraph.
MIN_LENGTH, MAX_LENGTH = 15, 34
MAX_CHARACTERS = 64
# The countries whose national part carries the RIB key. Monaco uses the French
# banking standard: its national part is twenty-three characters too and
# satisfies the same modulo 97.
RIB_COUNTRIES = ("FR", "MC")
# The letter-to-digit table of the French RIB key, from the French banking
# standard: A and J are 1, B, K and S are 2, and so on to I, R and Z at 9.
RIB_LETTERS = {c: str((i % 9) + 1) for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ".replace("S", ""))}
RIB_LETTERS.update({c: str(i + 2) for i, c in enumerate("STUVWXYZ")})
def national_key_ok(country: str, bban: str) -> bool | None:
"""
The country's own key inside the account number, or None when this snippet
has none for that country. France and Monaco: letters become digits, and
the whole twenty-three-character national part, key included, is a multiple
of 97.
"""
if country not in RIB_COUNTRIES:
return None
digits = "".join(RIB_LETTERS.get(c, c) for c in bban)
return int(digits) % 97 == 0
def check_bank_details(raw, *, expected_country: str | None = None) -> dict:
"""
Say whether `raw` carries a well-formed IBAN, and for which country.
`expected_country` is the two-letter code the caller is expecting, from the
contract or the invoice. Supplied, a mismatch is refused: the country
changing between the quote and the payment details is the shape the
redirected-invoice fraud takes, and it is the one thing on this page that
code can still see.
`country` comes back whether the number is valid or not, so the caller can
decide about reachability — a Brazilian IBAN is perfectly well-formed and
no SEPA transfer will get there.
Nothing raises: a validator that throws in a request path leaves the payer
with no answer. Every refusal is `valid: False` and a `reason` in words.
"""
if not isinstance(raw, str):
return _report(False, None, "", None, f"an IBAN is text, not {type(raw).__name__}")
if len(raw) > MAX_CHARACTERS:
return _report(False, None, "", None, f"longer than {MAX_CHARACTERS} characters")
# Upper case is the electronic format of ISO 13616; lower case carries no
# other reading, so it is raised rather than refused.
compact = "".join(c for c in raw if c not in SEPARATORS).upper()
if not compact.isascii() or not compact.isalnum():
return _report(False, None, compact, None, "not letters, digits and separators only")
if not (MIN_LENGTH <= len(compact) <= MAX_LENGTH):
return _report(False, None, compact, None,
f"{len(compact)} characters: an IBAN has {MIN_LENGTH} to {MAX_LENGTH}")
country = compact[:2]
if not country.isalpha() or not compact[2:4].isdigit():
return _report(False, None, compact, None, "an IBAN starts with two letters then two digits")
if expected_country and country != expected_country.upper():
return _report(False, country, compact, None,
f"expected a {expected_country.upper()} account, this one is {country}")
try:
# The country modules are left out on purpose: the two libraries do not
# have the same ones, and the national key is computed below instead.
iban.validate(compact, check_country=False)
except ValidationError:
return _report(False, country, compact, None,
"ISO 13616 check digits, length or registry format do not match")
key = national_key_ok(country, compact[4:])
if key is False:
return _report(False, country, compact, key,
"the RIB key inside the account number does not match")
return _report(True, country, compact, key, None)
def _report(valid: bool, country: str | None, compact: str,
national_key: bool | None, reason: str | None) -> dict:
# The printed grouping in fours, which is how a payer reads a number back.
printed = " ".join(compact[i:i + 4] for i in range(0, len(compact), 4))
return {"valid": valid, "country": country, "compact": compact,
"printed": printed, "national_key": national_key, "reason": reason}JavaScript
/**
* Check an IBAN before a transfer leaves, offline and exactly.
*
* Rung N0. ISO 13616 puts two check digits in positions 3 and 4 of every
* IBAN, computed over the rest of the number by the modulo 97 of ISO 7064.
* The country code fixes the length — 27 characters in France, 22 in Germany,
* 16 in Belgium — and the shape of what follows. All of that is a table and an
* arithmetic identity: the answer is exact and free.
*
* The table is the part you do not write. `ibantools` here and `python-stdnum`
* in Python both carry the IBAN registry, revised as countries join.
*
* On top of ISO 13616, several countries put a second key inside the national
* part, and the two libraries do not cover the same ones — measured, not
* assumed: `ibantools` does refuse `FR0630006000011234567890188`, whose RIB
* key is wrong, while `python-stdnum` carries no French module and accepts it.
* Two libraries that answer differently on the same number are two snippets
* that would answer differently on the same page, so this one computes the RIB
* key itself, in both languages. Elsewhere, a number that passes here can
* still be refused by the receiving bank.
*
* Measured, each on its own set: not one of the 20 700 digit-for-digit slips
* of a hundred generated French IBANs gets through, nor one of their 2 000
* adjacent transpositions, nor one of the 6 892 lookalike-letter slips of five
* hundred of them. The ISO key alone lets 32 of those 6 892 pass — 0.46 % —
* because the French account number is alphanumeric by registry and `I` for
* `1` is a legal character there; the RIB key is what closes that. The Python
* side generates its own numbers and measures 31 of 6 882, which is 0.45 %.
*
* And none of it says whose account it is. That question is answered by the
* payee verification service every euro-area provider has had to offer since
* 9 October 2025, not by a longer computation.
*/
import { validateIBAN, ValidationErrorsIBAN } from 'ibantools';
// What people paste around an IBAN: the printed grouping in spaces of every
// kind, and the hyphens a word processor inserts. Anything else left in the
// string is refused rather than dropped, because dropping it would turn a
// mistyped account into a different valid one.
export const SEPARATORS = ' -‐‑–';
// ISO 13616 caps an IBAN at 34 characters and no country is under 15.
// Sixty-four leaves room for every separator and refuses a pasted paragraph.
export const MIN_LENGTH = 15;
export const MAX_LENGTH = 34;
export const MAX_CHARACTERS = 64;
// The countries whose national part carries the RIB key. Monaco uses the
// French banking standard: its national part is twenty-three characters too
// and satisfies the same modulo 97.
export const RIB_COUNTRIES = ['FR', 'MC'];
// The letter-to-digit table of the French RIB key, from the French banking
// standard: A and J are 1, B, K and S are 2, and so on to I, R and Z at 9.
export const RIB_LETTERS = Object.fromEntries([
...[...'ABCDEFGHIJKLMNOPQRTUVWXYZ'].map((c, i) => [c, String((i % 9) + 1)]),
...[...'STUVWXYZ'].map((c, i) => [c, String(i + 2)]),
]);
/**
* The country's own key inside the account number, or null when this snippet
* has none for that country. France and Monaco: letters become digits, and the
* whole twenty-three-character national part, key included, is a multiple of
* 97.
*/
export function nationalKeyOk(country, bban) {
if (!RIB_COUNTRIES.includes(country)) return null;
const digits = [...bban].map((c) => RIB_LETTERS[c] ?? c).join('');
return BigInt(digits) % 97n === 0n;
}
/**
* Say whether `raw` carries a well-formed IBAN, and for which country.
*
* `expectedCountry` is the two-letter code the caller is expecting, from the
* contract or the invoice. Supplied, a mismatch is refused: the country
* changing between the quote and the payment details is the shape the
* redirected-invoice fraud takes, and it is the one thing on this page that
* code can still see.
*
* `country` comes back whether the number is valid or not, so the caller can
* decide about reachability — a Brazilian IBAN is perfectly well-formed and no
* SEPA transfer will get there.
*
* Nothing throws: a validator that throws in a request path leaves the payer
* with no answer. Every refusal is `valid: false` and a `reason` in words.
*
* @param {string} raw
* @param {{expectedCountry?: string}} [options]
*/
export function checkBankDetails(raw, { expectedCountry } = {}) {
if (typeof raw !== 'string') {
return report(false, null, '', null, `an IBAN is text, not ${kindOf(raw)}`);
}
if (raw.length > MAX_CHARACTERS) {
return report(false, null, '', null, `longer than ${MAX_CHARACTERS} characters`);
}
// Upper case is the electronic format of ISO 13616; lower case carries no
// other reading, so it is raised rather than refused.
const compact = [...raw].filter((c) => !SEPARATORS.includes(c)).join('').toUpperCase();
if (!/^[A-Z0-9]+$/.test(compact)) {
return report(false, null, compact, null, 'not letters, digits and separators only');
}
if (compact.length < MIN_LENGTH || compact.length > MAX_LENGTH) {
return report(false, null, compact, null,
`${compact.length} characters: an IBAN has ${MIN_LENGTH} to ${MAX_LENGTH}`);
}
const country = compact.slice(0, 2);
if (!/^[A-Z]{2}$/.test(country) || !/^[0-9]{2}$/.test(compact.slice(2, 4))) {
return report(false, null, compact, null, 'an IBAN starts with two letters then two digits');
}
if (expectedCountry && country !== expectedCountry.toUpperCase()) {
return report(false, country, compact, null,
`expected a ${expectedCountry.toUpperCase()} account, this one is ${country}`);
}
// The library's own national checks are left out on purpose: the two
// libraries do not have the same ones, and the national key is computed
// below instead.
const { errorCodes } = validateIBAN(compact);
const national = ValidationErrorsIBAN.WrongAccountBankBranchChecksum;
if (errorCodes.some((code) => code !== national)) {
return report(false, country, compact, null,
'ISO 13616 check digits, length or registry format do not match');
}
const key = nationalKeyOk(country, compact.slice(4));
if (key === false) {
return report(false, country, compact, key,
'the RIB key inside the account number does not match');
}
return report(true, country, compact, key, null);
}
const kindOf = (value) => (value === null ? 'null' : typeof value);
function report(valid, country, compact, nationalKey, reason) {
// The printed grouping in fours, which is how a payer reads a number back.
const printed = (compact.match(/.{1,4}/g) ?? []).join(' ');
return { valid, country, compact, printed, national_key: nationalKey, reason };
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
Every output is the snippet’s own, run when the site is built. The numbers shown are built for this page: they are valid and belong to nobody.
A French IBAN pasted from an invoice, grouped in fours
Given
FR76 3000 6000 0112 3456 7890 189
What it returns
{ "valid": true, "country": "FR", "compact": "FR7630006000011234567890189", "printed": "FR76 3000 6000 0112 3456 7890 189", "national_key": true, "reason": null }
What it concludes
Well-formed IBAN — FR
Both keys pass: the ISO 13616 one, and the French RIB key.
None of these keys says who owns the account.
The same number, one account digit mistyped
Given
FR76 3000 6000 0112 3456 7891 189
What it returns
{ "valid": false, "country": "FR", "compact": "FR7630006000011234567891189", "printed": "FR76 3000 6000 0112 3456 7891 189", "national_key": null, "reason": "ISO 13616 check digits, length or registry format do not match" }
What it concludes
Refused
ISO 13616 check digits, length or registry format do not match
The same number, a letter slipped into the account
Given
FR76 3000 6000 0112 3456 7A90 189
What it returns
{ "valid": false, "country": "FR", "compact": "FR7630006000011234567A90189", "printed": "FR76 3000 6000 0112 3456 7A90 189", "national_key": false, "reason": "the RIB key inside the account number does not match" }
What it concludes
Refused
the RIB key inside the account number does not match
A German IBAN, with no national key computed here
Given
DE89 3704 0044 0532 0130 00
What it returns
{ "valid": true, "country": "DE", "compact": "DE89370400440532013000", "printed": "DE89 3704 0044 0532 0130 00", "national_key": null, "reason": null }
What it concludes
Well-formed IBAN — DE
The ISO 13616 key passes. This country has no national key computed here.
None of these keys says who owns the account.
A Brazilian IBAN, perfectly well-formed
Given
BR18 0036 0305 0000 1000 9795 493C 1
What it returns
{ "valid": true, "country": "BR", "compact": "BR1800360305000010009795493C1", "printed": "BR18 0036 0305 0000 1000 9795 493C 1", "national_key": null, "reason": null }
What it concludes
Well-formed IBAN — BR
The ISO 13616 key passes. This country has no national key computed here.
None of these keys says who owns the account.
Here it breaksThe number passes, and no SEPA transfer will ever reach Brazil. The key checks the shape, not reachability: that is why the report returns the country even when it says yes.
Another account at the same branch
Given
FR76 3000 6000 0109 8765 4321 028
What it returns
{ "valid": true, "country": "FR", "compact": "FR7630006000010987654321028", "printed": "FR76 3000 6000 0109 8765 4321 028", "national_key": true, "reason": null }
What it concludes
Well-formed IBAN — FR
Both keys pass: the ISO 13616 one, and the French RIB key.
None of these keys says who owns the account.
Here it breaksSame country, same bank code, same branch, and both keys pass: this is the shape the redirected-invoice fraud takes. Nothing in the number says which of the two accounts is your supplier’s — that is what payee verification, mandatory in the euro area since 9 October 2025, is for.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the IBAN is checked inside your own process and is sent to nobody
- An IBAN is personal data as soon as it names an individual: checking it locally discloses nothing, sending it to an online service discloses it
- Does not replace the payee verification your payment provider has to offer you: that is a service of the provider, not a check you run
Breaking point
The two keys say a number is well-formed, never who owns the account: FR76 3000 6000 0112 3456 7890 189 and FR76 3000 6000 0109 8765 4321 028 both pass, same bank, same branch — exactly what a redirected-invoice fraud looks like. Witness in the same test: not one of the 20,700 single-digit slips of a hundred IBANs gets through.
When to move up a level
There is no level above this one. The day you need to know the account really is your supplier’s, the answer is the payee verification every euro-area provider has had to offer since 9 October 2025, and you ask the provider for it, not a model.
The four options
- RecommendedN0ISO 13616 check digits and the French RIB key, against the IBAN registryFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is nothing to learn. An IBAN’s check digits are a function of the rest of the number, published by ISO 13616, and the RIB key is a second one; a classifier trained on labelled IBANs would approximate two identities that two modulos compute exactly. Nor would it know what the IBAN registry holds, and that registry changes with every country that joins the list — it is a table, not a regularity.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with a service to keep running on top. And the decisive objection is about what is being checked: a transfer leaves once, and what is wanted from a pre-flight check is that it always gives the same answer and can say which of the two keys failed. A small specialised model returns a score, not a carry you can read back in an audit log.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
A general-purpose model returns a sentence about a computation it does not perform, and it sends that sentence to a third party: on a payment path, that is a cost per transfer, latency added before confirmation, and bank details leaving your infrastructure for an answer two modulos give offline. The only question left to ask — is this account the right payee’s — has had a regulated answer since 9 October 2025, and it is the payment provider who gives it.
Further reading
- Wikipédia — International Bank Account Number, structure et calcul de la clé
- Wikipédia — relevé d’identité bancaire, et le calcul de la clé RIB
- python-stdnum — le module IBAN et le registre qu’il porte
- ibantools — la bibliothèque JavaScript, dépôt du projet
- Règlement (UE) 2024/886 sur les virements instantanés, article 5 quater
Where the figures come from
- Règlement (UE) 2024/886, article 5 quater (consulté) : le prestataire du payeur offre un service de vérification du bénéficiaire « immédiatement après que le payeur a fourni les informations pertinentes » ; applicable au 9 octobre 2025 dans les États membres de la zone euro, au 9 juillet 2027 dans les autres
- python-stdnum sur PyPI (consulté) : version 2.2, publiée le 4 janvier 2026, licence LGPL v2 ou ultérieure
- ibantools, dépôt du projet (consulté) : version 4.5.4 publiée le 10 avril 2026, licence MIT ou MPL 2.0, aucune dépendance
- python-stdnum, module stdnum.iban (consulté) : le registre des IBAN, la longueur et la structure par pays, et les modules nationaux optionnels
- Wikipédia, relevé d’identité bancaire (consulté) : la table de conversion des lettres en chiffres et la clé RIB comme modulo 97 de la partie nationale
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.