Guide · Node.js · REST API

IBAN Validation in Node.js — mod-97 Algorithm Explained

IBANs look simple but have 76 country-specific formats and a two-step checksum that catches most transcription errors. Here's how it all fits together.

1. What is an IBAN?

An IBAN (International Bank Account Number) is a standardised way to identify a bank account internationally, defined by ISO 13616. Introduced in 1997 to streamline cross-border payments in Europe, IBANs are now used in over 80 countries — and are mandatory for SEPA credit transfers and direct debits in the Eurozone.

Despite the name, IBANs are not just European. Countries like Brazil, Saudi Arabia, Israel, and Pakistan have adopted the standard. When your payment form accepts IBANs from international users, you need a validator that knows the rules for every country.


2. IBAN anatomy — country code, check digits, BBAN

Every IBAN has the same four-component structure:

GB82 WEST 1234 5698 7654 32
GB = country82 = check digitsWEST…32 = BBAN

Country code (2 letters)

ISO 3166-1 alpha-2 country code. Tells you which country's banking system the account belongs to and determines the expected total length and BBAN format.

Check digits (2 digits)

Two numeric digits calculated using the mod-97 algorithm. They allow detection of single-character transpositions and most common transcription errors — the same principle used in LEI codes.

BBAN — Basic Bank Account Number

The domestic account number, whose format is country-specific. A German BBAN encodes the bank code (Bankleitzahl) and account number. A UK BBAN encodes the sort code and account number. The BBAN length and character set vary by country.

CountryTotal lengthExample
Norway15 charsNO93 8601 1117 947
Germany22 charsDE89 3704 0044 0532 0130 00
United Kingdom22 charsGB29 NWBK 6016 1331 9268 19
France27 charsFR76 3000 6000 0112 3456 7890 189
Poland28 charsPL61 1090 1014 0000 0712 1981 2874
Malta31 charsMT84 MALT 0110 0001 2345 MTLC AST0 01S

3. The mod-97 checksum — step by step

The check digits are validated by rearranging the IBAN and computing the remainder when divided by 97. A valid IBAN always gives a remainder of 1. Let's walk through it with GB82 WEST 1234 5698 7654 32:

Step 1 — Move the first 4 characters to the end

WEST12345698765432GB82

Step 2 — Replace each letter with its numeric value (A=10, B=11, … Z=35)

3214282912345698765432161182

W=32, E=14, S=28, T=29 · G=16, B=11

Step 3 — Compute the remainder modulo 97

Process in 9-digit chunks (the number is too large for a standard integer). The remainder must equal 1 for the IBAN to be valid.

3214282912345698765432161182 mod 97 = 1

Here's the algorithm implemented in JavaScript:

// mod97.js — implements the ISO 13616 IBAN checksum
function mod97(iban) {
  // Step 1: move first 4 chars to the end
  const rearranged = iban.slice(4) + iban.slice(0, 4);

  // Step 2: replace each letter with its numeric value
  const numeric = rearranged.replace(/[A-Z]/g, ch =>
    String(ch.charCodeAt(0) - 55)  // A=10, B=11, ..., Z=35
  );

  // Step 3: mod 97 in chunks (JavaScript can't handle the full integer)
  let remainder = 0;
  for (let i = 0; i < numeric.length; i += 9) {
    const chunk = String(remainder) + numeric.slice(i, i + 9);
    remainder = parseInt(chunk, 10) % 97;
  }

  return remainder; // must be 1 for a valid IBAN
}

const iban = 'GB82WEST12345698765432'; // no spaces
console.log(mod97(iban)); // → 1 ✓
ℹ️mod-97 detects all single-digit errors and most transpositions of two adjacent characters. It will catch accidental errors, but it is not designed to prevent deliberate forgery — a fake IBAN with a correct checksum is trivially constructible.

4. Why IBAN validation is harder than it looks

76 different lengths

Every country has a fixed total length for its IBANs, ranging from 15 (Norway) to 34 (Saint Lucia). A German IBAN is always 22 characters; a Polish IBAN is always 28. Passing mod-97 with the wrong length still means the IBAN is invalid.

Country-specific BBAN character sets

Some countries allow alphanumeric BBANs (e.g. UK sort codes include letters like WEST), while others are purely numeric (Germany, France). A BBAN with letters for a purely-numeric country is invalid even if the mod-97 check passes.

Formatting is not part of the standard

IBANs are printed in groups of four characters separated by spaces, but the spaces are for human readability only and must be stripped before validation. Users may also enter dashes or no separators at all.

// All of these represent the same IBAN — normalise first
'GB82 WEST 1234 5698 7654 32'  // paper/display format
'GB82-WEST-1234-5698-7654-32'  // dashes
'GB82WEST12345698765432'        // no separators (canonical)

New countries keep joining

The list of IBAN-participating countries grows over time. Hardcoding a country table in your app risks silently rejecting valid IBANs from newly-added countries. Maintaining that table requires ongoing attention.


5. The production-ready solution

The IsValid IBAN API handles format normalisation, country-length validation, and the mod-97 checksum in a single GET request — for 76 countries. The response also includes the parsed BBAN and an isEU flag for SEPA routing logic.

76
Countries
EU + non-EU
<20ms
Response time
pure algorithmic check
100/day
Free tier
no credit card

Get your free API key at isvalid.dev. The free tier includes 100 calls per day — enough for most development and low-volume production use.

Full parameter reference and response schema: IBAN Validation API docs →


6. Node.js code example

Using the native fetch API (Node 18+). No dependencies required.

// ibanValidator.js
const API_KEY = process.env.ISVALID_API_KEY;
const BASE_URL = 'https://api.isvalid.dev';

/**
 * Validate an IBAN using the IsValid API.
 *
 * @param {string} iban  - IBAN in any format (spaces, dashes, or none)
 * @returns {Promise<object>} Validation result
 */
async function validateIban(iban) {
  const params = new URLSearchParams({ value: iban });

  const response = await fetch(`${BASE_URL}/v0/iban?${params}`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(`IBAN API error ${response.status}: ${error.message ?? 'unknown'}`);
  }

  return response.json();
}

// ── Example usage ────────────────────────────────────────────────────────────

const result = await validateIban('GB82 WEST 1234 5698 7654 32');

if (!result.valid) {
  console.log('Invalid IBAN');
} else {
  console.log(`Valid IBAN — ${result.countryName} (${result.isEU ? 'SEPA' : 'non-SEPA'})`);
  console.log('BBAN:', result.bban);
  console.log('Formatted:', result.formatted);
  if (result.bankName) console.log('Bank:', result.bankName);
  if (result.bankBic) console.log('BIC:', result.bankBic);
  // → Valid IBAN — United Kingdom (non-SEPA)
  // → BBAN: WEST12345698765432
  // → Formatted: GB82 WEST 1234 5698 7654 32
  // → Bank: (name from registry, if available)
}

In a payment form handler, you might use it like this:

// routes/payment.js (Express)
app.post('/payment', async (req, res) => {
  const { iban, amount, ...rest } = req.body;

  let ibanCheck;
  try {
    ibanCheck = await validateIban(iban);
  } catch {
    return res.status(502).json({ error: 'IBAN validation service unavailable' });
  }

  if (!ibanCheck.valid) {
    return res.status(400).json({ error: 'Invalid IBAN' });
  }

  // Use isEU to route SEPA vs international wire
  const transferType = ibanCheck.isEU ? 'sepa' : 'international';
  await initiateTransfer({ iban, transferType, amount, ...rest });

  res.json({ success: true, transferType });
});
Use the isEU flag to automatically choose between a SEPA transfer (cheaper, 1 business day) and an international wire for non-Eurozone IBANs. The formatted field gives you the print-friendly grouped format to display back to the user.

7. cURL example

Validate a German IBAN:

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.isvalid.dev/v0/iban?value=DE89370400440532013000"

Spaces are handled automatically:

curl -G -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "value=GB82 WEST 1234 5698 7654 32" \
  "https://api.isvalid.dev/v0/iban"

With explicit country code (when the IBAN has no prefix):

curl -H "Authorization: Bearer YOUR_API_KEY" \
  "https://api.isvalid.dev/v0/iban?value=370400440532013000&countryCode=DE"

8. Understanding the response

Valid German IBAN:

{
  "valid": true,
  "countryCode": "DE",
  "countryName": "Germany",
  "bban": "370400440532013000",
  "isEU": true,
  "formatted": "DE89 3704 0044 0532 0130 00",
  "bankCode": "37040044",
  "bankName": "Commerzbank",
  "bankBic": "COBADEFFXXX"
}

Invalid IBAN (bad checksum):

{
  "valid": false
}
FieldTypeDescription
validbooleanFormat, length, and mod-97 checksum all pass
countryCodestring2-letter ISO 3166-1 country code from the IBAN prefix
countryNamestringFull country name
bbanstringThe domestic account number portion (after the 4-char IBAN header)
isEUbooleanWhether the country is an EU member state (SEPA eligible)
formattedstringHuman-readable IBAN grouped in blocks of 4 characters
bankCodestring | nullNational bank code extracted from the BBAN (e.g. 8-digit Bankleitzahl for DE, 8-digit numer rozliczeniowy for PL)
bankNamestring | nullName of the bank identified by the bank code, from official central bank registries
bankBicstring | nullBIC/SWIFT code of the bank if available from the national registry
⚠️A valid IBAN confirms that the number is structurally correct — it does not guarantee the account actually exists or is active. For payment execution, your bank or payment provider will confirm account existence through the payment network.

9. Edge cases to handle

SEPA vs. non-SEPA routing

Not all IBAN countries participate in SEPA. Norway, Iceland, Liechtenstein, and Switzerland have IBANs but are not EU members — though they are part of SEPA through EEA/bilateral agreements. Use the isEU flag as a starting point, but apply your own SEPA country list for precise routing.

Accounts without IBANs

The US, Canada, Australia, China, and many other countries do not use IBANs. When accepting international payments, always provide a fallback for account number + routing number / sort code / BSB input for non-IBAN countries.

function showCorrectAccountForm(country) {
  if (ibanCountries.has(country)) {
    showIbanInput();
  } else {
    showRoutingAndAccountInput(country); // ABA, sort code, BSB, etc.
  }
}

Input normalisation

Users paste IBANs from documents or emails in many formats. The API strips spaces and hyphens and uppercases the input automatically. Pass the raw user input — no need to pre-process it. The formatted field in the response gives you a clean display version to show back to the user.

Storing IBANs

Store the normalised form (no spaces, uppercase) in your database — e.g. GB82WEST12345698765432. Generate the display format on the fly from the formatted field when needed. This avoids inconsistencies from different input formats.


Summary

Do not validate IBANs with a single regex — lengths differ per country
Do not treat a valid IBAN as a guarantee the account exists
Normalise input first — strip spaces, dashes, uppercase
Check both country-specific length and mod-97 checksum
Use isEU to route SEPA vs. international wire
Display the formatted version back to the user for confirmation

See also

Validate IBANs instantly

Free tier includes 100 API calls per day. No credit card required. Supports 76 countries including all EU member states and major non-EU IBAN countries.