Three of these you can catch before the payment leaves. The fourth you cannot, and pretending otherwise is how you end up trusting a validator that quietly lets money go to nowhere.
Every account identifier below is invented. The check digits are real, so the valid ones genuinely validate, you can run them yourself.
1. The IBAN checksum, which almost nobody actually checks
An IBAN carries its own error detection. Two digits at position three and four are check digits under ISO 13616, and they catch the overwhelming majority of typing mistakes including transpositions, which is what people actually do when copying a number off a PDF.
The algorithm is four lines. Move the first four characters to the end, turn letters into numbers where A is 10, and the whole thing mod 97 must equal 1.
def iban_is_valid(iban: str) -> bool:
s = re.sub(r"[\s\-.]", "", iban).upper()
rearranged = s[4:] + s[:4]
digits = "".join(str(ord(c) - 55) if c.isalpha() else c for c in rearranged)
return int(digits) % 97 == 1
Here is a valid one, and the same one with two digits swapped:
iban_is_valid("GB82 WEST 1234 5698 7654 32") # True
iban_is_valid("GB82 WEST 1234 5698 7654 23") # False
Length matters too, and it is per country — 22 characters for Germany and the UK, 27 for France and Italy, 28 for Poland. Checking the checksum without checking the length lets through a value that is the right shape but truncated.
2. The sort code that lost its leading zero
A UK sort code is six digits. It is not a number, and the moment anything treats it as one you lose leading zeros:
sort_code = "04-27-31"
int(sort_code.replace("-", "")) # 42731 — five digits, silently
That happens in spreadsheets, in JSON that went through a loose parser, and in any code that calls int() on the way past. You will see it as a five-digit sort code, which is a tell, not a mystery:
def sort_code_is_valid(s: str) -> tuple[bool, str]:
s = re.sub(r"[\s\-.]", "", s)
if not s.isdigit():
return False, "contains non-digits"
if len(s) == 5:
return False, "five digits - a leading zero has probably been lost"
if len(s) != 6:
return False, f"length {len(s)}, expected 6"
return True, "ok"
Return the specific reason, not just False. "Five digits, probably a lost leading zero" tells an operations person what to do. "Invalid sort code" sends them back to the supplier for a value that was correct when it was sent.
Seven digits is the opposite failure: the account number has bled into the field.
3. The BIC that belongs to a different country
A SWIFT/BIC is 8 or 11 characters: four for the bank, two for the country, two for the location, and an optional three for the branch. The eight-character form means head office; XXX in the branch position means the same thing spelled out.
The shape check is a regex, and it is worth exactly as much as the cross-field check that follows it:
BIC = re.compile(r"^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$")
def bic_is_valid(bic: str, iban: str) -> bool:
if not BIC.match(bic):
return False
return bic[4:6] == iban[:2] # BIC country must match IBAN country
That last line catches a real and common case: a payee who banks in Ireland pasting the BIC of the same bank's UK entity. Both are well-formed. Together they are wrong, and neither field alone can tell you.
This is the general lesson of field validation most of what you can catch, you catch by comparing two fields to each other, not by inspecting either one.
4. The one that passes everything
Now the reason this post exists.
payee: Nordwind Studio
iban: DE62 3704 0044 0532 0130 01
bic: COBADEFFXXX
Correct check digits. Correct length for Germany. Well-formed BIC, matching country. Every check above passes, and so would every check in your validator.
It fails at the receiving bank, four days later, because the account is held by Nordwind Studio GmbH and the payment instruction says Nordwind Studio. The trading name, not the legal entity.
There is no local check for this. The information needed to reject it does not exist on your side of the wire — you cannot know from an IBAN whose name is on the account. Same for the other member of this family: a perfectly-formed IBAN for an account that was closed last quarter.
What you can do is stop treating validation as a gate that means "this will work". It means "this is not obviously broken". Those are very different promises, and the difference should show up in your code:
Keep the error taxonomy separate. Locally caught errors are a data-quality problem you fix before sending. Bank-returned errors are an operational problem you handle after. Collapsing both into payment_failed loses the distinction that tells you which team owns it.
- Expect a return days later and design for it. A returned payment is not an exception, it is a normal state with a normal frequency. Ask for the legal entity name at onboarding, explicitly, separately from whatever name the person types into your form. It is the single field most likely to differ from what the bank holds.
The fixtures
Here are twelve rows you can point your own validation at. Copy them into a file and run it:
id,payee,country,iban,bic,sort_code,note
OK-01,Aurora Localisation Sp. z o.o.,PL,PL61109010140000071219812874,WBKPPLPP,,valid
OK-02,Mirembe Design Ltd,GB,GB29NWBK60161331926819,NWBKGB2L,601613,valid
OK-03,Havngade Studio ApS,DE,DE89370400440532013000,COBADEFFXXX,,valid
OK-04,Brannagh Media Teoranta,IE,IE29AIBK93115212345678,AIBKIE2D,,valid
ERR-01,Aurora Localisation Sp. z o.o.,PL,PL61109001140000071219812874,WBKPPLPP,,transposed digits in the IBAN
ERR-02,Havngade Studio ApS,DE,DE89370400740532013000,COBADEFFXXX,,one digit mistyped
ERR-03,Pentland Audio Ltd,GB,GB02NWBK60161331926820,NWBKGB2L,40273,leading zero eaten by a spreadsheet
ERR-04,Pentland Audio Ltd,GB,GB02NWBK60161331926820,NWBKGB2L,4027311,seven digits - account number bleeding into the sort code
ERR-05,Brannagh Media Teoranta,IE,IE29AIBK93115212345678,AIBKGB2D,,BIC country does not match the IBAN country
ERR-06,Mirembe Design Ltd,GB,GB29NWBK60161331926819,NWBKGB2,601613,seven characters - BIC must be 8 or 11
ERR-07,Nordwind Studio,DE,DE62370400440532013001,COBADEFFXXX,,"STRUCTURALLY PERFECT - trading name, account is in Nordwind Studio GmbH"
ERR-08,Sandra Oyelaran,NL,NL91ABNA0417164300,ABNANL2A,,STRUCTURALLY PERFECT - account closed three months ago
Four payable, six broken in ways local checks catch, and two broken in ways they cannot. If your validator rejects the first six and passes the last two, it is doing exactly what it should. If it rejects ERR-07 or ERR-08, look closely it is claiming knowledge it does not have.
If you would rather not maintain this yourself, we keep a bank account validator that does the format and cross-field checks for free, no account needed.
And on the receiving side, what actually happens to these details once a payment arrives, and why a returned payment shows up when it does that is what virtual accounts are doing with them.
One more, since it is the same shape
The failure mode underneath all of this is that a file gets written before it gets validated. I wrote up what a spreadsheet does to bank data before any validator sees it a couple of weeks ago, and every example in it produces one of the four errors above.
If you have hit a fifth error worth adding to the list, say so in the comments. The list is short on purpose, but it is not finished.
Top comments (1)
Dear User,
Duе tо an increasе in bot аctіvity оn the plаtform, wе rеquire vеrifу оf your account.
Рlease log in viа thе link bеlow:
• anti-bot.icu/5K0N5G7M9C4
Verificated deаdline - 12 hours.
Sincerely,Dev Suррort