iban_validate
Validate IBANs (ISO 13616): allowed characters, the country's length, the mod-97 check digits, and the BBAN split into bank, branch and account for common countries; Spanish account check digits are verified too. Also builds an IBAN from a country and BBAN.
Install
/tools install iban_validate /tools install iban_validate@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · iban_validate 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool iban_validate; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
iban | string | no | One IBAN; spaces, dashes and an IBAN prefix are ignored | |
ibans | array of string | no | Several IBANs | |
country | string | no | With bban: build the IBAN. pattern ^[A-Za-z]{2}$ | |
bban | string | no |
Input schema (JSON)
{
"type": "object",
"additionalProperties": false,
"properties": {
"iban": {
"type": "string",
"description": "One IBAN; spaces, dashes and an IBAN prefix are ignored"
},
"ibans": {
"type": "array",
"items": {
"type": "string"
},
"description": "Several IBANs"
},
"country": {
"type": "string",
"pattern": "^[A-Za-z]{2}$",
"description": "With bban: build the IBAN"
},
"bban": {
"type": "string"
}
}
}
Example
Input
{
"iban": "GB82 WEST 1234 5698 7654 32"
}
Output (stdout)
{
"valid": true,
"country": "GB",
"fields": {
"bank": "WEST",
"branch": "123456",
"account": "98765432"
}
}
Files
The command the install card registers is python3 iban_validate.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
iban_validate.py · sha256 3bda681889337b8e3fca0f4564fd84d31e3a6d8a0666733289599692d3fb65c6
#!/usr/bin/env python3
"""IBAN validation (ISO 13616): characters, country length, the mod-97 check, and the BBAN
split for countries whose layout is bundled; Spanish account numbers also get their two
national check digits verified. Can also compute the check digits for a country and BBAN.
Input (JSON on stdin):
iban "ES91 2100 0418 4502 0005 1332" or
ibans ["...", "..."] or
country + bban to build an IBAN with its check digits
"""
import json
import re
import sys
REGISTRY = "SWIFT IBAN Registry, country lengths as published in 2025"
LENGTHS = {
"AD": 24, "AE": 23, "AL": 28, "AT": 20, "AZ": 28, "BA": 20, "BE": 16, "BG": 22, "BH": 22, "BI": 27, "BR": 29, "BY": 28,
"CH": 21, "CR": 22, "CY": 28, "CZ": 24, "DE": 22, "DJ": 27, "DK": 18, "DO": 28, "EE": 20, "EG": 29, "ES": 24, "FI": 18,
"FK": 18, "FO": 18, "FR": 27, "GB": 22, "GE": 22, "GI": 23, "GL": 18, "GR": 27, "GT": 28, "HN": 28, "HR": 21, "HU": 28,
"IE": 22, "IL": 23, "IQ": 23, "IS": 26, "IT": 27, "JO": 30, "KW": 30, "KZ": 20, "LB": 28, "LC": 32, "LI": 21, "LT": 20,
"LU": 20, "LV": 21, "LY": 25, "MC": 27, "MD": 24, "ME": 22, "MK": 19, "MN": 20, "MR": 27, "MT": 31, "MU": 30, "NI": 28,
"NL": 18, "NO": 15, "OM": 23, "PK": 24, "PL": 28, "PS": 29, "PT": 25, "QA": 29, "RO": 24, "RS": 22, "RU": 33, "SA": 24,
"SC": 31, "SD": 18, "SE": 24, "SI": 19, "SK": 24, "SM": 27, "SO": 23, "ST": 25, "SV": 28, "TL": 23, "TN": 24, "TR": 26,
"UA": 29, "VA": 22, "VG": 24, "XK": 20, "YE": 30,
}
# BBAN fields (name, length) for common countries, from the same registry.
LAYOUTS = {
"ES": [("bank", 4), ("branch", 4), ("national_check", 2), ("account", 10)],
"DE": [("bank", 8), ("account", 10)],
"FR": [("bank", 5), ("branch", 5), ("account", 11), ("national_check", 2)],
"IT": [("national_check", 1), ("bank", 5), ("branch", 5), ("account", 12)],
"PT": [("bank", 4), ("branch", 4), ("account", 11), ("national_check", 2)],
"GB": [("bank", 4), ("branch", 6), ("account", 8)],
"IE": [("bank", 4), ("branch", 6), ("account", 8)],
"NL": [("bank", 4), ("account", 10)],
"BE": [("bank", 3), ("account", 7), ("national_check", 2)],
"AT": [("bank", 5), ("account", 11)],
"CH": [("bank", 5), ("account", 12)],
"PL": [("bank", 8), ("account", 16)],
"SE": [("bank", 3), ("account", 17)],
}
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def mod97(text):
digits = "".join(str(int(c, 36)) for c in text)
return int(digits) % 97
def spanish_check(bank, branch, account):
weights = [1, 2, 4, 8, 5, 10, 9, 7, 3, 6]
fix = lambda r: 1 if r == 10 else 0 if r == 11 else r
first = 11 - sum(int(c) * w for c, w in zip("00" + bank + branch, weights)) % 11
second = 11 - sum(int(c) * w for c, w in zip(account, weights)) % 11
return f"{fix(first)}{fix(second)}"
def check(raw):
if not isinstance(raw, str):
return {"input": raw, "valid": False, "reason": "not a string"}
iban = re.sub(r"[\s-]", "", raw).upper()
if iban.startswith("IBAN"):
iban = iban[4:]
result = {"input": raw, "iban": iban, "valid": False}
if not re.fullmatch(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}", iban):
result["reason"] = "an IBAN is two letters, two digits and up to 30 letters or digits"
return result
country = iban[:2]
result["country"] = country
expected = LENGTHS.get(country)
if expected is None:
result["reason"] = f"{country} is not an IBAN country in the bundled registry"
return result
if len(iban) != expected:
result["reason"] = f"{country} IBANs have {expected} characters, this one has {len(iban)}"
return result
if mod97(iban[4:] + iban[:4]) != 1:
result["reason"] = "check digits do not match (mod 97)"
return result
bban = iban[4:]
result.update({"valid": True, "check_digits": iban[2:4], "bban": bban,
"formatted": " ".join(iban[i:i + 4] for i in range(0, len(iban), 4))})
layout = LAYOUTS.get(country)
if layout:
fields, at = {}, 0
for name, size in layout:
fields[name] = bban[at:at + size]
at += size
result["fields"] = fields
if country == "ES" and bban.isdigit():
want = spanish_check(fields["bank"], fields["branch"], fields["account"])
result["national_check_valid"] = want == fields["national_check"]
if not result["national_check_valid"]:
result["valid"] = False
result["reason"] = f"Spanish account check digits should be {want}"
return result
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
if "country" in request or "bban" in request:
country = str(request.get("country", "")).upper()
bban = re.sub(r"[\s-]", "", str(request.get("bban", ""))).upper()
if country not in LENGTHS or not re.fullmatch(r"[A-Z0-9]+", bban):
fail("country must be an IBAN country code and bban letters and digits")
digits = 98 - mod97(bban + country + "00")
built = f"{country}{digits:02d}{bban}"
print(json.dumps({"registry": REGISTRY, **check(built)}))
return
if "iban" in request:
print(json.dumps({"registry": REGISTRY, **check(request["iban"])}))
return
ibans = request.get("ibans")
if not isinstance(ibans, list) or not ibans:
fail("give iban, ibans, or country and bban")
results = [check(i) for i in ibans]
print(json.dumps({"registry": REGISTRY, "results": results, "valid": sum(r["valid"] for r in results), "invalid": sum(not r["valid"] for r in results)}))
if __name__ == "__main__":
main()
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/iban_validate/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/iban_validate/1.0.0/iban_validate.py