Introduction
IBAN validation, bank lookup and SEPA scheme membership as one static binary.
What it does
GET /v2/iban/{iban} Validate and describe
POST /v2/iban:batch Up to 100 at once
GET /v2/banks?country=&bic=&name=&limit= Search banks
GET /v2/banks/{country}/{bankCode} One bank
GET /v2/banks/{country}/{bankCode}/logo.svg
GET /v2/countries The IBAN registry
GET /v2/data What is loaded, from where, how old
GET /healthz /readyz /openapi.yamlThe v1 routes of openiban.com are served unchanged alongside them, so an existing client only changes its base URL:
GET /validate/{iban}?validateBankCode=true&getBIC=true
GET /countries
GET /calculate/{countryCode}/{bankCode}/{accountNumber}
GET /v2/calculate/{countryCode}/{bankCode}/{accountNumber}A v2 answer for a real account:
{
"valid": false,
"iban": { "formatted": "DE49 5001 0517 9144 3556 68", "bankCode": "50010517" },
"checks": {
"length": { "ok": true, "message": "Correct length for DE (22 characters)" },
"bankCode": { "ok": true, "message": "Bank code 50010517 is valid" },
"accountNumber": { "ok": false, "method": "C1",
"message": "Account number 9144355668 has a wrong check digit" },
"ibanChecksum": { "ok": true, "message": "The IBAN checksum is correct" }
},
"bank": { "name": "ING-DiBa", "bic": "INGDDEFFXXX", "city": "Frankfurt am Main" },
"schemes": { "level": "institution", "schemes": { "sctInst": { "status": "participant" } } },
"dataAsOf": { "DE": "2026-09-02", "schemes": "2026-09-02" }
}Two things to know before integrating
ok can be null. A check that could not be performed is not a failure. The
account number of a bank whose check digit method is not implemented reports
"ok": null, and the overall valid stays true. Rendering null as a failure
would reject correct accounts.
Scheme membership is per institution, not per account. The EPC register
records that an institution joined a scheme. ING-DiBa is listed for SEPA B2B
direct debit and still does not offer it to retail customers. A status of
unknown means the BIC is absent from the register entirely, which is common
for savings and cooperative banks reachable through a central institution, so
it must not be read as "not supported".
Why a rewrite
The upstream is four repositories from before Go modules existed, untouched since August 2019. The rewrite addresses what that age produced.
Its bundled data froze on 2019-07-25. Two of the seven download URLs in its
sources.txt are dead today, and Germany's institution count has fallen from
about 17,000 to 13,806 since. Here, iban-pizza update resolves the official
endpoints at run time, /healthz reports the age of every dataset, and a
scheduled job opens a pull request every quarter so a missed refresh is visible
instead of silent.
fmt.Fprintf(w, value) received user controlled content, so a percent sign
in the IBAN parameter reached the format verb parser. All responses now go
through json.Encoder, and a test feeds format verbs at the routes.
The response cache was keyed on the raw request parameter with no length check, so arbitrary requests grew memory without bound. Input is now rejected on length before anything is allocated.
Account check digits were never verified. The upstream parsed the Bundesbank method identifier, stored it, and implemented no method. Eleven methods are implemented here, each verified against the test account numbers in the Bundesbank specification.
BBAN validation only compared total length, accepting any alphanumeric
content of the right size. Validation is now driven by the IBAN registry
structure per country, so DE89AB0400440532013000 is rejected rather than
accepted.
CORS was an unconditional wildcard. It is now configured, and closed by default.
Data
Bank data comes from official national registries.
- docs/country-sources.md has the status of all 36 SEPA countries: which publish a free machine readable bank code registry, which publish only a PDF, which sell it, and which are still unresearched.
- docs/data-sources.md has the working detail for the sources in use, including the encoding and layout traps.
Currently loaded: Germany (Bundesbank), Austria (OeNB), Czech Republic (CNB), plus the EPC Register of Participants for all six SEPA schemes. Six more countries have a verified free source waiting on a spreadsheet reader: Switzerland and Liechtenstein, Belgium, the Netherlands, Norway, Hungary and Latvia.
Resolving the bank code inside an IBAN needs a national registry. Pan-European sources such as the EPC register, the ECB MFI list and GLEIF are keyed by BIC or LEI and enrich the answer, but carry no national bank codes and cannot replace those registries. Countries without a loaded registry still get full structural IBAN validation.
There is no separate loader
The upstream split the work across four repositories, so a deployment meant
running goiban-service, populating a MySQL database with goiban-data-loader
as a second program, and keeping both in step. All four collapsed into this one
repository, and the loader became a subcommand of the same binary.
| Upstream repository | Here |
|---|---|
goiban | iban/, internal/checkdigit/ |
goiban-data | bankdata/, rewritten because the original carries no licence |
goiban-data-loader | internal/sources/ plus iban-pizza update |
goiban-service | internal/api/ plus iban-pizza serve |
Three ways data gets in
1. Do nothing. A snapshot of every registry is compiled into the binary,
so iban-pizza serve answers immediately. The whole dataset is about 110 KB
compressed for 4,423 institutions. This is the default and needs no network, no
file and no database.
2. Refresh into a file, when data should be newer than the binary:
# The file does not have to exist. update creates it, and the directory too.
iban-pizza update --write-snapshot /var/lib/iban-pizza/data.gz
iban-pizza serve -data-file /var/lib/iban-pizza/data.gzNo rebuild is involved. --countries DE,AT refreshes a subset, and --dry-run
downloads and parses without storing, which is the safe way to check whether a
registry has changed its format.
3. Refresh into PostgreSQL, for central maintenance across instances:
iban-pizza update -database-url postgres://user:pass@host/iban
iban-pizza serve -database-url postgres://user:pass@host/ibanThere is no separate step to enable PostgreSQL. Passing -database-url, or
setting IBAN_PIZZA_DATABASE_URL, is the whole switch. The schema is created on
connect, so an empty database is enough to start, and the loader has to run
once before the service can answer anything.
With Compose that is one command:
docker compose -f compose.yaml -f compose.postgres.yaml upwhich starts PostgreSQL, runs the loader once, and then starts the service against it. Refresh later without touching the service:
docker compose -f compose.yaml -f compose.postgres.yaml run --rm loaderIn every case iban-pizza update resolves the download links from the
publishers' pages at run time, refuses to replace existing data when a download
or parse fails, and swaps one country at a time so a broken registry cannot
take the others down with it.
4. Import a file you already have, per country, with no network:
iban-pizza import -country DE -file blz-aktuell.txt -database-url postgres://...Built in parsers cover the countries in the snapshot; every other country
accepts a generic CSV with a bankCode,name,... header. Details in the
deployment guide.
To refresh the snapshot that ships inside the binary, write it back to its source location and rebuild:
make update # writes internal/embedded/snapshot.jsonl.gz
make buildThe scheduled data-refresh workflow does exactly this every quarter and opens
a pull request with the result.
Running it
Container
docker run -p 8080:8080 ghcr.io/netzfabrikcom/iban-pizza:latestThe image is built FROM scratch, runs as uid 65534, and contains the binary
and certificate roots and nothing else.
Binary
iban-pizza serve # embedded snapshot, no configuration
iban-pizza serve -data-file data.gz # a snapshot refreshed without a rebuild
iban-pizza serve -database-url postgres://...Flags, each with an IBAN_PIZZA_-prefixed environment variable equivalent:
| Flag | Default | Purpose |
|---|---|---|
-addr | :8080 | listen address |
-data-file | snapshot file, overrides the embedded one | |
-database-url | PostgreSQL connection string | |
-scheme-file | directory of EPC exports, enables the schemes block | |
-cors-origins | none | comma separated origins, or * |
-rate-limit | 600 | requests per minute per client address, 0 disables |
-stale-after | 120 days | age at which data is reported stale |
-base-url | public URL, for absolute logo links | |
-log-format | json | json or text |
Refreshing the data
iban-pizza update --write-snapshot internal/embedded/snapshot.jsonl.gz \
--scheme-dir data/schemesDownload links are resolved at run time rather than hard coded, because the
Bundesbank path carries a content hash that changes with every quarterly
release. --dry-run downloads and parses without storing, and --countries DE
refreshes one registry.
Development
make test # go test ./... -race
make cover # coverage summary
make lint # gofmt and go vet
make vuln # govulncheck
make build # static binary into bin/
make update # refresh the data and the embedded snapshot
make docker # build the imageReleases are cut by pushing a tag. v1.2.3 builds static binaries for Linux,
macOS and Windows on amd64 and arm64 with a SHA256SUMS file, publishes a
multi architecture image to GHCR with an SBOM and provenance attestation, signs
it with cosign, and creates the GitHub release.