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

snippets/check-bank-details-before-a-transfer/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
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

snippets/check-bank-details-before-a-transfer/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * 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.

  1. 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.

  2. 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

  3. 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

  4. 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.

  5. 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.

  6. 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

  1. RecommendedN0ISO 13616 check digits and the French RIB key, against the IBAN registryFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. 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

Where the figures come from

Metadata