Card mistakes are cheap. The processor declines, the customer retypes, the sale goes through a minute later. Bank transfer mistakes are not. A payout to a malformed account number can take days to come back, with a return fee attached, and a payout to a well-formed but wrong account may never come back at all. The money went where you told it to.
That asymmetry is why marketplaces, payroll tools and refund flows validate bank details at the moment the person types them, while that person is still on the page and can fix a digit. Three identifiers cover most of the world:
- IBAN, the International Bank Account Number, used across Europe, the UK, much of the Middle East and beyond. It contains its own checksum.
- SWIFT/BIC, an 8 or 11 character code identifying a bank (and optionally a branch) for international wires.
- ABA routing number, the 9-digit US bank identifier, paired with a separate account number that has no public format.
Each has a different structure, a different checksum and different limits on what checking it can prove. This lesson works through all three, then combines them into a single payout-details check.
IBAN: two check digits that catch nearly every typo
An IBAN is a country code, two check digits, and a country-specific account part called the BBAN. GB29 NWBK 6016 1331 9268 19 breaks down as GB, check digits 29, then a UK BBAN made of a bank code (NWBK), a sort code (601613) and an account number (31926819).
The check digits come from a mod-97 calculation over the whole string. Move the first four characters to the end, turn letters into numbers (A=10 through Z=35), and the resulting very large integer must leave a remainder of 1 when divided by 97. That catches every single-character error and almost every transposition. On top of the checksum, each country fixes a total length (22 for the UK and Germany, 15 for Norway, up to 32 elsewhere) and a pattern for the BBAN, and some countries add their own national check digit inside the BBAN.
You could implement all of that. The checksum is easy; the per-country length and format table is the part that rots. IBAN Validator (ibanvalidator) keeps that table and runs every check for 2 credits a call.
curl "https://api.apiverve.com/v1/ibanvalidator?iban=GB29+NWBK+6016+1331+9268+19" \
-H "x-api-key: YOUR_API_KEY"Spaces and lower case are accepted. isValid is the verdict; checks says which tests passed.
Now change one check digit, from 29 to 28:
curl "https://api.apiverve.com/v1/ibanvalidator?iban=GB28NWBK60161331926819" \
-H "x-api-key: YOUR_API_KEY"isValid is false and errors explains why: the mod-97 checksum is wrong. checks.length and checks.format still pass.
The response separates the tests, and that separation is the useful part. checks.country, checks.length, checks.format and checks.checksum are booleans, checks.nationalCheckDigits is true, false or null (null where the country defines none), and errors is a list of human-readable reasons. Drop a digit from a German IBAN and you get three errors at once: wrong length for this country, the account part does not match the country's format, and a bad checksum. expectedLength tells you what the length should have been, which makes a far better form message than "invalid IBAN".
Paid plans add the decomposition: bban, bankCode, branchCode, accountNumber, expectedCheckDigits and isSEPA. isSEPA matters if you pay out in euros, because a SEPA-zone IBAN can receive a SEPA credit transfer, which is typically far cheaper than an international wire.
A seller enters an IBAN and the validator returns isValid: true. What has that established?
Answer the check above to continue.
One payout-details check
Here is the whole thing as an Express 4 route, ESM on Node 18+. It takes the details a seller submits, validates whichever identifiers are present, cross-checks countries, and returns field-level errors the form can display next to the right input.
import express from 'express';
const BASE = 'https://api.apiverve.com/v1';
async function verve(api, params) {
const res = await fetch(`${BASE}/${api}?${new URLSearchParams(params)}`, {
headers: { 'x-api-key': process.env.APIVERVE_API_KEY },
signal: AbortSignal.timeout(3000),
});
const body = await res.json().catch(() => null);
if (res.status === 404) return null; // "no such bank" is an answer
if (!res.ok || body?.status !== 'ok') {
const e = new Error(body?.error || `HTTP ${res.status}`);
e.status = res.status;
throw e;
}
return body.data;
}
function abaChecksum(r) {
if (!/^\d{9}$/.test(r)) return false;
const w = [3, 7, 1, 3, 7, 1, 3, 7, 1];
return [...r].reduce((s, d, i) => s + Number(d) * w[i], 0) % 10 === 0;
}
export async function checkPayout({ iban, bic, routing }) {
const errors = {};
const facts = {};
if (iban) {
const v = await verve('ibanvalidator', { iban });
if (!v.isValid) errors.iban = v.errors.join('; ') || 'Invalid IBAN';
else facts.ibanCountry = v.countryCode;
}
if (bic) {
const code = bic.toUpperCase().replace(/\s/g, '').replace(/XXX$/, '');
if (!/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(code)) errors.bic = 'A BIC is 8 or 11 letters and digits';
else {
const r = await verve('swiftlookup', { swift: code });
const exact = r?.banks?.find((b) => b.swift_code === code);
if (!exact) errors.bic = 'No bank uses this BIC';
else {
facts.bank = exact.bank;
facts.bicCountry = exact.country_code;
}
}
}
if (routing) {
if (!abaChecksum(routing)) errors.routing = 'Check the routing number for a typo';
else {
const r = await verve('routinglookup', { routing });
if (!r) errors.routing = 'No US bank uses this routing number';
else facts.bank = r.bank;
}
}
if (facts.ibanCountry && facts.bicCountry && facts.ibanCountry !== facts.bicCountry) {
errors.bic = `This BIC is for a bank in ${facts.bicCountry}, but the IBAN is from ${facts.ibanCountry}`;
}
return { ok: Object.keys(errors).length === 0, errors, facts };
}
const app = express();
app.use(express.json());
app.post('/api/payout-details/check', async (req, res) => {
try {
res.json(await checkPayout(req.body ?? {}));
} catch (e) {
console.error('payout check failed', e.status, e.message);
res.status(503).json({ ok: false, errors: { _: 'We could not check these details right now. Please try again.' } });
}
});
app.listen(3000, () => console.log('listening on :3000'));A few things in there are deliberate.
The BIC is normalised before lookup and matched exactly after. Without the exact match, NWBKGB2L followed by any three characters the user invented would sail through on the strength of the prefix.
The country cross-check uses countryCode from the IBAN response. That field is on every plan, so the cross-check works on a free key.
Lookup outages fail closed with a retry message. Unlike a signup gate, a payout form is not in a hurry. Nobody loses a sale because a bank form asked them to try again in a minute, and you very much lose money if unchecked details get saved and paid. That is the reverse of the judgment you make on a signup form, and it is the right call here.
Errors are per field. "Your IBAN's checksum is wrong" next to the IBAN input gets fixed. A banner saying "invalid bank details" gets a support ticket.
What validation does not cover
Everything above proves the details are well-formed and that the bank exists. It does not prove the account exists, that it belongs to the person you are paying, or that the person is someone you are allowed to pay. The first two are the bank's job, through test deposits, open banking, or a payee name check where the local scheme provides one. The third is a compliance question, and it is the subject of the next lesson.
Before you release money to a seller, contractor or business, you are generally expected to know that they are not on a sanctions list. Continue to Screen names against sanctions lists.