If you've ever uploaded a bulk payment file to a bank portal, you know what happens next. One payee has a mistyped IBAN, the bank rejects the whole batch, and you find out at 4:55pm on payday.
Most of these errors are catchable before you upload:
- Typos in the IBAN. Every IBAN has a mod-97 check digit pair, so a single wrong or swapped digit fails the checksum.
- A BIC from the wrong country. Someone pastes a UK BIC next to a German IBAN. Both are well-formed, but they can't belong together.
- Messy formatting. Spaces, lower case, a missing character.
In this post we'll write a small Python script that reads a payees CSV, checks each IBAN and BIC, flags rows that don't add up, and exits non-zero so you can run it in CI or before a cron job uploads the file.
The input
A typical export from an accounts system:
payee,iban,bic,amount
Acme Supplies Ltd,GB82 WEST 1234 5698 7654 32,NWBKGB2L,1250.00
Müller GmbH,DE89370400440532013000,DEUTDEFF,980.50
Typo Traders,GB82WEST12345698765433,NWBKGB2L,310.00
Wrong Bank BV,DE89370400440532013000,NWBKGB2L,75.00
Two of those rows are broken. Can you spot them by eye? (Neither could I.)
The checks
Rather than re-implementing the IBAN per-country length table and mod-97 maths, I'm using the /iban and /bic endpoints of ValidKit. Both take a single value query parameter.
curl -G "https://validkit-iban-vat-card-isbn-bic-postcode-validator.p.rapidapi.com/iban" \
--data-urlencode "value=GB82 WEST 1234 5698 7654 32" \
-H "X-RapidAPI-Key: YOUR_RAPIDAPI_KEY" \
-H "X-RapidAPI-Host: validkit-iban-vat-card-isbn-bic-postcode-validator.p.rapidapi.com"
{
"valid": true,
"country": "GB",
"formatted": "GB82 WEST 1234 5698 7654 32",
"normalized": "GB82WEST12345698765432",
"bank_code_hint": "WEST",
"input": "GB82 WEST 1234 5698 7654 32"
}
An invalid one tells you why:
{ "valid": false, "reason": "invalid checksum", "input": "GB82WEST12345698765433" }
/bic works the same way and returns bank and country for a valid code (DEUTDEFF gives "bank": "DEUT", "country": "DE"). Shorten it to DEUTDEF and you get "reason": "invalid length".
The country fields are the useful part: comparing the IBAN's country with the BIC's country catches the "right format, wrong bank" mistake that neither check catches on its own.
The script
import csv, os, sys, requests
HOST = "validkit-iban-vat-card-isbn-bic-postcode-validator.p.rapidapi.com"
BASE = f"https://{HOST}"
HEADERS = {"X-RapidAPI-Key": os.environ.get("RAPIDAPI_KEY", ""), "X-RapidAPI-Host": HOST}
def check(endpoint, value):
r = requests.get(f"{BASE}/{endpoint}", params={"value": value}, headers=HEADERS, timeout=10)
r.raise_for_status()
return r.json()
def main(src, dst):
problems = 0
with open(src, newline="") as f_in, open(dst, "w", newline="") as f_out:
rows = csv.DictReader(f_in)
out = csv.DictWriter(f_out, fieldnames=rows.fieldnames + ["iban_formatted", "status"])
out.writeheader()
for row in rows:
iban = check("iban", row["iban"])
issues = []
if not iban["valid"]:
issues.append(f"IBAN: {iban['reason']}")
if row.get("bic"):
bic = check("bic", row["bic"])
if not bic["valid"]:
issues.append(f"BIC: {bic['reason']}")
elif iban["valid"] and bic["country"] != iban["country"]:
issues.append(f"BIC country {bic['country']} != IBAN country {iban['country']}")
row["iban_formatted"] = iban.get("formatted", "")
row["status"] = "; ".join(issues) or "OK"
problems += bool(issues)
out.writerow(row)
print(f"{problems} row(s) need attention, see {dst}")
sys.exit(1 if problems else 0)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
Run it:
pip install requests
export RAPIDAPI_KEY=your_key_here
python check_payees.py payees.csv checked.csv
2 row(s) need attention, see checked.csv
And checked.csv:
payee,iban,bic,amount,iban_formatted,status
Acme Supplies Ltd,GB82 WEST 1234 5698 7654 32,NWBKGB2L,1250.00,GB82 WEST 1234 5698 7654 32,OK
Müller GmbH,DE89370400440532013000,DEUTDEFF,980.50,DE89 3704 0044 0532 0130 00,OK
Typo Traders,GB82WEST12345698765433,NWBKGB2L,310.00,,IBAN: invalid checksum
Wrong Bank BV,DE89370400440532013000,NWBKGB2L,75.00,DE89 3704 0044 0532 0130 00,BIC country GB != IBAN country DE
Row 3 has one digit wrong in the IBAN. Row 4 is a perfectly valid German IBAN paired with a NatWest BIC. Both would have bounced the batch.
As a bonus, iban_formatted gives you the IBAN in standard 4-character groups, which is what you want in a remittance email.
Wiring it in
Because the script exits with 1 when anything is wrong, it slots straight into whatever already moves the file:
-
Before a cron upload:
python check_payees.py payees.csv checked.csv && ./upload_to_bank.sh - In CI, if payee data lives in a repo (yes, people do this): add it as a step and the PR goes red.
-
In n8n or Make: call the same two endpoints from an HTTP Request node per row and route the
valid: falseitems to Slack.
What this doesn't do
A valid checksum means the IBAN is well-formed, not that the account exists or belongs to that payee. For that you need your bank's confirmation-of-payee or account verification service. Think of this as the cheap first filter that stops obvious typos reaching the expensive (or slow) step.
Doing it locally instead?
You can: python-stdnum and schwifty both validate IBANs offline, and for a one-off script that's a fine choice. The API is handy when the check needs to run from somewhere you'd rather not maintain dependencies (a no-code workflow, a serverless function, a spreadsheet script) or when you want IBAN, BIC, VAT and postcode checks to behave the same way across every service.
ValidKit has a free plan (200 calls/month), enough to check a typical monthly payment run.
Top comments (0)