# Your AI agent found a new IBAN on an invoice. Should it pay? (IBAN checks in Python, and their limits)

> Source: <https://dev.to/fast_data_asap/your-ai-agent-found-a-new-iban-on-an-invoice-should-it-pay-iban-checks-in-python-and-their-p53>
> Published: 2026-11-12 07:45:33+00:00

An invoice arrives. Same supplier, same amount pattern, but the IBAN is different. If an AI agent prepares your payments, what should it do?

Bank-detail changes are a classic invoice-fraud pattern, and agents make it more interesting: they read every PDF, never get tired and will happily "fix" vendor master data if the instructions say so. So I tested what an IBAN check actually catches.

*Disclosure: I built the [IBAN Validator & Bank Lookup](https://apify.com/fast_data_asap/iban-validator-bank-lookup) on Apify, which I use below. The lesson applies to any validator.*

Nine inputs: a known-good German IBAN, the same IBAN with one typo, three IBANs where I changed the account number **and recomputed the two check digits** (what a fraudster would do), plus Dutch, French, Austrian and a truncated one.

``` python
import os, requests

URL = ("https://api.apify.com/v2/acts/fast_data_asap~iban-validator-bank-lookup"
       "/run-sync-get-dataset-items")
HEADERS = {"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"}

ibans = [
    "DE89370400440532013000",        # original
    "DE89 3704 0044 0532 0130 01",   # one digit typo
    "DE62370400440532013001",        # changed account + recomputed check digits
    "DE40370400440532013009",
    "DE18370400440532013017",
    "NL91ABNA0417164300", "FR1420041010050500013M02606", "AT611904300234573201",
    "DE12345",
]
resp = requests.post(URL, headers=HEADERS, timeout=120,
                     json={"ibans": ibans, "includeBankInfo": True})
resp.raise_for_status()
for r in resp.json():
    print(r["input"], r["valid"], r["checksumValid"], r["nationalChecksumValid"], r["bankName"], r["error"])
```

| Input | Valid | MOD-97 | National check | Bank | 
|---|---|---|---|---|
| DE89 …013000 (original) | yes | ok | ok | Commerzbank | 
| DE89 …013001 (typo) | **no** | fails | ok | Commerzbank | 
| DE62 …013001 (changed, recomputed) | **yes** | ok | ok | Commerzbank | 
| DE40 …013009 (changed, recomputed) | **yes** | ok | ok | Commerzbank | 
| DE18 …013017 (changed, recomputed) | **yes** | ok | ok | Commerzbank | 
| NL91 ABNA… | yes | ok | – | ABN AMRO | 
| FR14 2004… | yes | ok | ok | La Banque Postale | 
| AT61 1904… | yes | ok | – | (no bank data) | 
| DE12345 | **no** | fails | – | wrong length | 

Real output, run on 12 November 2026. Malformed rows are not charged.

Two takeaways:

A validator answers "is this a well-formed IBAN, and which bank is it?". It cannot answer "does this account belong to my supplier?".

The useful signal is not `valid`, it is the **comparison with what you already know**:

| Signal | Source | What the agent should do | 
|---|---|---|
| `valid: false` | validator | reject, ask for corrected invoice | 
| IBAN differs from vendor master data | your ERP | **hold payment** , never update master data automatically | 
| `bankName` /`bic` differs from the known bank | validator + ERP | hold, flag as high risk | 
| country of the bank differs from the supplier's country | validator + ERP | hold, flag as high risk | 
| new IBAN confirmed by the supplier through a known channel | human | release | 

Here is that rule as code:

``` php
def iban_decision(invoice_iban: str, vendor: dict, check: dict) -> tuple[str, str]:
    """vendor = your master data, check = one row from the validator."""
    if not check["valid"]:
        return "reject", check["error"]
    if check["iban"].replace(" ", "") == vendor["iban"].replace(" ", ""):
        return "pay", "IBAN matches master data"
    reasons = ["IBAN differs from master data"]
    if check.get("bic") and vendor.get("bic") and check["bic"][:8] != vendor["bic"][:8]:
        reasons.append(f"bank changed: {vendor.get('bank')} -> {check.get('bankName')}")
    if check["countryCode"] != vendor["iban"][:2]:
        reasons.append("bank country changed")
    return "hold", "; ".join(reasons) + " - confirm with supplier via known phone number"
```

The important design choice: **the agent can never move from "hold" to "pay" on its own.** It can prepare the evidence (old vs new bank, invoice, sender address), but the release needs a human or a bank-side confirmation.

Since October 2025, EU payment service providers have to offer **Verification of Payee** for euro credit transfers: before you send money, your bank checks whether the payee name matches the account holder of that IBAN and tells you "match", "close match" or "no match". That is the check that would expose the fraudulent IBANs above, because the name on the account would not be your supplier's.

So the full chain for an accounts-payable agent looks like this:

[IBAN Validator & Bank Lookup on Apify](https://apify.com/fast_data_asap/iban-validator-bank-lookup): bulk IBAN validation with MOD-97 and national checksums, SEPA flag, bank name and BIC (German banks from the Bundesbank file), $0.0005 per IBAN, runs offline inside the actor. For the full supplier check (company register, VAT ID, IBAN) see our free Agent Skill [verify-supplier-de](https://github.com/ichkifokkus/data-recipes/blob/main/skills/verify-supplier-de/SKILL.md).

This is about payment hygiene, not legal advice.

**Question:** if you let an agent prepare payments, where do you draw the line: may it ever update vendor bank details, or is that always a human step?
