CTN Identifier Enrichment & Verification Architecture
Automated Legal Entity Identifier Discovery and Validation
Overview
Section titled “Overview”The CTN Association Register implements a comprehensive identifier enrichment system that automatically discovers and validates legal entity identifiers from multiple external registries across European countries. This system reduces manual data entry, improves data quality, and ensures regulatory compliance through automated verification.
Purpose: Enrich member legal entity records with verified identifiers from authoritative sources (KVK, KBO, Handelsregister, GLEIF, VIES, Peppol).
Scope: Covers NL (Netherlands), BE (Belgium), DE (Germany), and all EU countries for global identifiers (LEI, EUID, Peppol, VAT).
Table of Contents
Section titled “Table of Contents”- System Overview
- Key Principles
- Building Block View
- Country-Specific Enrichment Flows
- External Registry Services
- Data Sources & Rate Limits
- Identifier Type Matrix
- Service Architecture
- API Response Format
- Status Values & State Machine
- Quality Requirements
- Batch Enrichment of Existing Records (Learnings 2026-06)
- Risks & Technical Debt
System Overview
Section titled “System Overview”Design Goals
Section titled “Design Goals”- Automated Enrichment - Minimize manual data entry through automatic identifier discovery
- Source Authority - Validate identifiers against authoritative external registries
- Country Extensibility - Modular architecture supports adding new countries without code duplication
- Global Standards - Support pan-European identifiers (EUID, LEI, Peppol) for all EU member states
- Defensive Programming - Handle API failures gracefully, continue partial enrichment on errors
- Rate Limit Compliance - Respect external API rate limits through throttling and caching
Key Features
Section titled “Key Features”- Multi-country support: NL, BE, DE with extensible architecture for additional countries
- Global identifier discovery: LEI, EUID, Peppol work across ALL EU countries
- Modular services: Each enrichment type in separate service file (~100-200 lines vs 900+ monolithic)
- Smart fallback logic: Registration number first, company name search as fallback (LEI, Peppol)
- Caching layer: Store external API responses to reduce repeated calls and costs
- Validation status tracking: Clear result-based terminology: VALID (confirmed), INVALID (failed), PENDING (not checked), EXPIRED, NOT_VERIFIABLE
Key Principles
Section titled “Key Principles”1. Global Identifiers Apply to ALL EU Countries
Section titled “1. Global Identifiers Apply to ALL EU Countries”EUID, LEI, and Peppol are not country-specific - they work for all EU member states and should be attempted for every legal entity regardless of country.
Example: A French company can have:- LEI from GLEIF (global)- EUID: FR.SIREN.123456789 (generated from SIREN)- Peppol: 0009:12345678901234 (SIRET scheme)- VAT: FR12345678901 (EU-wide)2. Registration Number + Country First, Then Company Name Fallback
Section titled “2. Registration Number + Country First, Then Company Name Fallback”For LEI and Peppol searches:
- Primary search: Use national registration number (KVK, KBO, HRB, SIREN) + country code
- Fallback search: If primary fails, search by company name + country code
- Name matching logic: Single result → use it; Multiple results → try exact match → try starts-with → return not found
3. Country-Specific Flows for National Identifiers
Section titled “3. Country-Specific Flows for National Identifiers”Each country has unique identifier systems:
- NL: RSIN → VAT derivation (only for “rechtspersonen”, not “eenmanszaken”)
- DE: Handelsregister (HRB/HRA) scraping, manual VAT entry required
- BE: KBO → VAT derivation (simple: BE + KBO number)
4. Modular Service Architecture
Section titled “4. Modular Service Architecture”Each enrichment type is a separate service:
NlEnrichmentService.java- RSIN and VAT derivation for NetherlandsDeEnrichmentService.java- Handelsregister scraping for GermanyBeEnrichmentService.java- KBO lookup and VAT derivation for BelgiumLeiEnrichmentService.java- GLEIF LEI lookup (ALL countries)PeppolEnrichmentService.java- Peppol Directory search (ALL countries)EuidEnrichmentService.java- EUID generation (ALL EU countries)ViesEnrichmentService.java- VAT verification against VIES (ALL EU countries)
Benefits: Testability, readability, separation of concerns, no 900-line God classes.
5. Two-Level Identity Model: Legal Entity vs Establishment
Section titled “5. Two-Level Identity Model: Legal Entity vs Establishment”(Added 2026-06 — see Batch Enrichment of Existing Records.)
Identifiers live at two distinct levels that must not be conflated:
- Legal entity / rechtspersoon — KVK number, HRB/HRA, KBO, SIREN, UID (CHE). Entity-level identifiers attach here: LEI, RSIN, VAT, EUID. One value per company, regardless of how many physical locations it has.
- Establishment / vestiging — a physical location of that entity with its own address but no own LEI/VAT. It carries a location-level identifier: vestigingsnummer (NL, 12 digits), SIRET (FR, = SIREN + NIC), P-nummer (DK), vestigingseenheidsnummer (BE).
Consequences for data modelling and matching:
- A single legal entity may correspond to many rows/locations; those rows share the same entity identifiers and differ only by establishment ID + address.
- DE and CH branches are not separately numbered in most cases — a Zweigniederlassung refers back to the parent HRB/UID. There the geocoded location code (Plus Code / Open Location Code) is the practical per-location key.
- Recommended model:
legal_entity (1) ──< establishment (N), with identifiers split by scope.
Building Block View
Section titled “Building Block View”Level 1: System Context
Section titled “Level 1: System Context”flowchart TB
subgraph ASR["ASR System"]
API[ASR API
Kubernetes/AKS]
DB[(PostgreSQL 15
asr_dev)]
end
subgraph External["External Registries"]
KVK["KVK API
Netherlands"]
KBO["KBO Public Search
Belgium"]
HR["Handelsregister
Germany"]
GLEIF["GLEIF API
Global LEI"]
VIES["VIES API
EU VAT"]
PEPPOL["Peppol Directory
EU e-Invoicing"]
end
USER[Admin/Member
User] --> API
API --> DB
API --> KVK
API --> KBO
API --> HR
API --> GLEIF
API --> VIES
API --> PEPPOL
Level 2: Enrichment Orchestrator
Section titled “Level 2: Enrichment Orchestrator”flowchart TD
START([POST /v1/legal-entities/{id}/enrich])
ORCH[Enrichment Orchestrator
EnrichmentService.java]
subgraph Country["Country-Specific"]
NL[NL Enrichment
RSIN, VAT derivation]
DE[DE Enrichment
Handelsregister]
BE[BE Enrichment
KBO, VAT derivation]
end
subgraph Global["Global Identifiers"]
EUID[EUID Generation
All EU countries]
LEI[LEI Lookup
GLEIF API]
PEPPOL[Peppol Lookup
Directory API]
end
DB[(Database
Registry Cache)]
START --> ORCH
ORCH --> NL
ORCH --> DE
ORCH --> BE
ORCH --> EUID
ORCH --> LEI
ORCH --> PEPPOL
NL --> DB
DE --> DB
BE --> DB
EUID --> DB
LEI --> DB
PEPPOL --> DB
Level 3: Service Dependencies
Section titled “Level 3: Service Dependencies”The diagram below shows each country-specific enrichment service with its dedicated API client and registry data table.
Netherlands (NL) Flow
Section titled “Netherlands (NL) Flow”flowchart LR
NL[nlEnrichmentService] --> KVK[kvkService] --> KVK_DB[(kvk_registry_data)]
NL --> VIES[viesService] --> VIES_DB[(vies_registry_data)]
NL --> NUM[(legal_entity_number)]
Belgium (BE) Flow
Section titled “Belgium (BE) Flow”flowchart LR
BE[beEnrichmentService] --> KBO[kboService] --> BE_DB[(belgium_registry_data)]
BE --> VIES[viesService] --> VIES_DB[(vies_registry_data)]
BE --> NUM[(legal_entity_number)]
Germany (DE) Flow
Section titled “Germany (DE) Flow”flowchart LR
DE[deEnrichmentService] --> HR[handelsregisterService] --> BUNDES[bundesApiService]
BUNDES --> DE_DB[(german_registry_data)]
DE --> LEI[leiService] --> GLEIF_DB[(gleif_registry_data)]
DE --> NUM[(legal_entity_number)]
Cross-Country Services
Section titled “Cross-Country Services”flowchart LR
subgraph Global["Global Enrichment Services"]
LEI_SVC[leiEnrichmentService]
PEPPOL_SVC[peppolEnrichmentService]
EUID_SVC[euidEnrichmentService]
VIES_SVC[viesEnrichmentService]
end
subgraph Clients["API Clients"]
LEI_CLIENT[leiService]
PEPPOL_CLIENT[peppolService]
VIES_CLIENT[viesService]
end
subgraph Storage["Database Tables"]
GLEIF_DATA[(gleif_registry_data)]
VIES_DATA[(vies_registry_data)]
PEPPOL_DATA[(peppol_registry_data)]
NUMBERS[(legal_entity_number)]
end
LEI_SVC --> LEI_CLIENT --> GLEIF_DATA
PEPPOL_SVC --> PEPPOL_CLIENT -.->|identifier only| NUMBERS
VIES_SVC --> VIES_CLIENT --> VIES_DATA
EUID_SVC --> NUMBERS
Complete Data Flow Summary
Section titled “Complete Data Flow Summary”| Enrichment Service | API Client(s) | Registry Data Table |
|---|---|---|
| nlEnrichmentService | kvkService, viesService | kvk_registry_data, vies_registry_data |
| beEnrichmentService | kboService, viesService | belgium_registry_data, vies_registry_data |
| deEnrichmentService | handelsregisterService, bundesApiService, leiService | german_registry_data, gleif_registry_data |
| leiEnrichmentService | leiService | gleif_registry_data |
| peppolEnrichmentService | peppolService | (identifier stored in legal_entity_number) |
| viesEnrichmentService | viesService | vies_registry_data |
| euidEnrichmentService | (local generation) | (identifier stored in legal_entity_number) |
Country-Specific Enrichment Flows
Section titled “Country-Specific Enrichment Flows”Netherlands (NL) - KVK → RSIN → VAT
Section titled “Netherlands (NL) - KVK → RSIN → VAT”RSIN Availability: RSIN (Rechtspersonen en Samenwerkingsverbanden Informatienummer) is only available for legal entities registered as “rechtspersoon” (legal person). Eenmanszaken (sole proprietorships) do not have RSIN because they are not separate legal entities—the business and owner are legally the same person. For eenmanszaken, only the KVK number is available, and VAT cannot be auto-derived.
flowchart TD
START([Start NL Enrichment])
subgraph KVK["KVK → RSIN Flow"]
CHECK_KVK{KVK exists?}
CHECK_CACHED{Cache exists?}
FETCH_KVK[Call KVK API]
EXTRACT_RSIN[Extract RSIN from response]
STORE_KVK[Store kvk_registry_data]
STORE_RSIN[Store RSIN identifier]
NO_RSIN["No RSIN
eenmanszaak"]
end
subgraph VAT["RSIN → VAT Flow"]
HAS_RSIN{RSIN available?}
DERIVE_VAT["Derive: NL + RSIN + B01"]
CALL_VIES[Verify via VIES API]
VIES_OK{Valid?}
TRY_B0X["Try B02, B03, B04..."]
B0X_OK{Valid?}
STORE_VAT[Store VAT as VALID]
VAT_NA[VAT not available]
end
subgraph EUID["EUID Generation"]
GEN_EUID["Generate: NL.KVK.{kvkNumber}"]
STORE_EUID[Store as VALID]
end
START --> CHECK_KVK
CHECK_KVK -->|Yes| CHECK_CACHED
CHECK_CACHED -->|No| FETCH_KVK
CHECK_CACHED -->|Yes| EXTRACT_RSIN
FETCH_KVK --> STORE_KVK
STORE_KVK --> EXTRACT_RSIN
EXTRACT_RSIN -->|Found| STORE_RSIN
EXTRACT_RSIN -->|Not found| NO_RSIN
STORE_RSIN --> HAS_RSIN
NO_RSIN --> HAS_RSIN
HAS_RSIN -->|Yes| DERIVE_VAT
HAS_RSIN -->|No| VAT_NA
DERIVE_VAT --> CALL_VIES
CALL_VIES --> VIES_OK
VIES_OK -->|Yes| STORE_VAT
VIES_OK -->|No| TRY_B0X
TRY_B0X --> B0X_OK
B0X_OK -->|Yes| STORE_VAT
B0X_OK -->|No| VAT_NA
CHECK_KVK --> GEN_EUID
GEN_EUID --> STORE_EUID
API Endpoint: https://api.kvk.nl/api/v1/basisprofielen/{kvkNumber}
Data Extracted:
- RSIN from
_embedded.eigenaar.rsin - Company name, address, legal form
- Trade names (handelsnamen)
Rate Limits: Per API key tier (varies by KVK subscription)
Germany (DE) - Handelsregister → HRB/HRA
Section titled “Germany (DE) - Handelsregister → HRB/HRA”VAT for German Companies: VAT numbers MUST be provided manually. The Handelsregister does not contain VAT data, and VIES API can only validate existing VAT numbers—it cannot derive them. This is a fundamental difference from Dutch companies.
flowchart TD
START([Start DE Enrichment])
subgraph HR["Handelsregister Flow"]
CHECK_CACHED{Cache exists?}
HAS_HRB{HRB/HRA exists?}
SEARCH_HRB[Search by HRB number]
SEARCH_NAME[Search by company name]
FOUND{Company found?}
STORE_DATA[Store german_registry_data]
STORE_HRB[Store HRB/HRA identifier]
end
subgraph EUID["EUID Generation"]
HAS_COURT{Court code available?}
GEN_EUID["Generate: DE{court}.{type}{number}"]
STORE_EUID[Store as VALID]
end
START --> CHECK_CACHED
CHECK_CACHED -->|No| HAS_HRB
HAS_HRB -->|Yes| SEARCH_HRB
HAS_HRB -->|No| SEARCH_NAME
SEARCH_HRB --> FOUND
SEARCH_NAME --> FOUND
FOUND -->|Yes| STORE_DATA
STORE_DATA --> STORE_HRB
STORE_HRB --> HAS_COURT
CHECK_CACHED -->|Yes| HAS_COURT
HAS_COURT -->|Yes| GEN_EUID
GEN_EUID --> STORE_EUID
Data Sources:
- BundesAPI (handelsregister.de) - Web scraping, 60 requests/hour rate limit
- GLEIF (fallback) - Only for companies with LEI
- OffeneRegister.de (bulk open data, added 2026-06) - Full German register (5M+ companies) as JSONL/SQLite, CC-BY, no rate limit. Best for batch enrichment of existing lists. Each record’s
company_numberis already in court-code formK1101R_HRB150148, so the DE EUID is derivable directly (DEK1101R.HRB150148) without a separate court-code lookup — solving the “missing court code” gap of the live scraper. Files:de_companies_ocdata.jsonl.bz2(248 MB),handelsregister.db(3.5 GB SQLite),openregister.db.gz(737 MB SQLite).
Court Codes (Examples):
D4601R- Amtsgericht NeussK1101R- Amtsgericht HamburgM1301R- Amtsgericht MünchenR2210R- Amtsgericht Koblenz
EUID Format: DE{courtCode}.{type}{number} → DEK1101R.HRB116737
Rate Limits: 60 requests/hour (1 per minute enforced via minRequestInterval)
Belgium (BE) - KBO → VAT
Section titled “Belgium (BE) - KBO → VAT”VAT for Belgian Companies: Belgian VAT is directly derived from KBO (Kruispuntbank van Ondernemingen) number. Format: BE + 10-digit KBO number (without dots).
Example: KBO 0439.291.125 → VAT BE0439291125
Data Sources:
- KBO Public Search (kbopub.economie.fgov.be) - Free web scraping, always available
- KBO API (api.kbodata.app) - Paid subscription, richer data (contacts, roles, financials) - Currently DISABLED
flowchart TD
START([Start BE Enrichment])
subgraph KBO["KBO Lookup Flow"]
HAS_KBO{KBO exists?}
CHECK_API{KBO API enabled?}
CALL_API[Call KBO API]
API_OK{Data found?}
SCRAPE[Scrape KBO Public Search]
SCRAPE_OK{Company found?}
STORE_DATA[Store belgium_registry_data]
NO_KBO["Cannot enrich
without KBO"]
end
subgraph VAT["VAT Derivation"]
DERIVE["Derive: BE + KBO number"]
VERIFY_VIES[Verify via VIES]
VIES_OK{Valid?}
STORE_VERIFIED[Store as VALID]
STORE_DERIVED[Store as VALID]
end
subgraph EUID["EUID Generation"]
GEN_EUID["Generate: BE.KBO.{kboNumber}"]
STORE_EUID[Store as VALID]
end
START --> HAS_KBO
HAS_KBO -->|No| NO_KBO
HAS_KBO -->|Yes| CHECK_API
CHECK_API -->|Yes| CALL_API
CHECK_API -->|No| SCRAPE
CALL_API --> API_OK
API_OK -->|Yes| STORE_DATA
API_OK -->|No| SCRAPE
SCRAPE --> SCRAPE_OK
SCRAPE_OK -->|Yes| STORE_DATA
STORE_DATA --> DERIVE
DERIVE --> VERIFY_VIES
VERIFY_VIES --> VIES_OK
VIES_OK -->|Yes| STORE_VERIFIED
VIES_OK -->|No| STORE_DERIVED
STORE_VERIFIED --> GEN_EUID
STORE_DERIVED --> GEN_EUID
GEN_EUID --> STORE_EUID
KBO API Status: DISABLED (requires subscription at https://kbodata.app)
To Enable KBO API:
- Subscribe to plan (Search, Medium, or Large)
- Set environment variables:
KBO_API_KEY,KBO_API_ENABLED=true - Restart API service
Rate Limits:
- Public scraping: No documented limit (be respectful)
- KBO API: Per subscription tier
Global Identifiers (ALL EU Countries)
Section titled “Global Identifiers (ALL EU Countries)”LEI Lookup (GLEIF API)
Section titled “LEI Lookup (GLEIF API)”Critical: GLEIF API uses specific search parameters that MUST be followed.
Primary Search: Registration Number + Country
GET https://api.gleif.org/api/v1/lei-records ?filter[entity.registeredAs]={registrationNumber} &filter[entity.legalAddress.country]={countryCode}Parameters:
filter[entity.registeredAs]- Just the registration number (e.g.,33031431for KVK)filter[entity.legalAddress.country]- Two-letter country code (e.g.,NL,DE)
Important Notes:
- DO NOT use combined formats like
NL-KVK/12345678 - The API does NOT support
filter[registeredAt.id](RA code filtering) - Use country code filtering instead (works reliably for all countries, including Germany’s ~100 local court RA codes)
- The API response contains
registeredAt.id(RA code likeRA000463for NL-KVK) - we store this for reference only
Fallback Search: Company Name + Country
GET https://api.gleif.org/api/v1/lei-records ?filter[entity.legalName]={companyName}* &filter[entity.legalAddress.country]={countryCode} &page[size]=20Name Matching Logic:
- Single result → Use it
- Multiple results → Try exact match (normalized, alphanumeric only)
- No exact match → Try starts-with match
- No match → Return not_found
Learning (2026-06): The
filter[entity.legalName]parameter performs a near-exact match, not a prefix/fuzzy search — a trailing*does little. For name-only discovery (no registration number, e.g. enriching an existing list) use the fuzzy-completion endpoint instead:GET https://api.gleif.org/api/v1/fuzzycompletions?field=entity.legalName&q={name}, collect the candidate LEIs, then batch-fetch full records viafilter[lei]=lei1,lei2,...and rank by name similarity +legalAddress.country. Always enforce a same-country constraint so a foreign sister/subsidiary LEI is never attached to a branch row.
Peppol Lookup (Directory API)
Section titled “Peppol Lookup (Directory API)”Peppol is a pan-European e-invoicing network. Companies registered in Peppol Directory can send/receive electronic invoices.
Search Strategy:
- Search by national registry number (KVK for NL, KBO for BE, etc.)
- Then search by VAT number
- Fallback: search by company name (less reliable)
flowchart TD
START([Start Peppol Enrichment])
CHECK{PEPPOL exists?}
HAS_COC{CoC number exists?}
SEARCH_COC["Search by CoC:
NL: scheme=0106 (KVK)
BE: scheme=0208 (KBO)
DE: N/A (no scheme)"]
COC_FOUND{Found?}
HAS_VAT{VAT exists?}
SEARCH_VAT["Search by VAT:
NL: scheme=9944
BE: scheme=9925
DE: scheme=9930"]
VAT_FOUND{Found?}
SEARCH_NAME["Search by company name
(fallback)"]
NAME_FOUND{Found?}
STORE[Store PEPPOL identifier]
NOT_FOUND["Not in Peppol Directory"]
START --> CHECK
CHECK -->|No| HAS_COC
HAS_COC -->|Yes| SEARCH_COC
HAS_COC -->|No| HAS_VAT
SEARCH_COC --> COC_FOUND
COC_FOUND -->|Yes| STORE
COC_FOUND -->|No| HAS_VAT
HAS_VAT -->|Yes| SEARCH_VAT
HAS_VAT -->|No| SEARCH_NAME
SEARCH_VAT --> VAT_FOUND
VAT_FOUND -->|Yes| STORE
VAT_FOUND -->|No| SEARCH_NAME
SEARCH_NAME --> NAME_FOUND
NAME_FOUND -->|Yes| STORE
NAME_FOUND -->|No| NOT_FOUND
Peppol Identifier Schemes:
| Country | CoC Scheme | VAT Scheme | Example |
|---|---|---|---|
| NL | 0106 (KVK) | 9944 | 0106:12345678 |
| BE | 0208 (KBO) | 9925 | 0208:0439291125 |
| DE | - | 9930 | 9930:DE123456789 |
| FR | 0009 (SIRET) | 9957 | 0009:12345678901234 |
| GB | 0088 (CRN) | 9932 | 0088:12345678 |
| DK | 0184 (CVR) | 9902 | 0184:12345678 |
Rate Limit: 2 requests/second (500ms minimum between requests)
Batch alternative (2026-06): for bulk enrichment do not loop over the search API — download the Peppol Directory full export instead and match offline (no rate limit). See Peppol via bulk export under Batch Learnings.
EUID Generation
Section titled “EUID Generation”EUID (European Unique Identifier) is available for ALL EU member states via BRIS system.
Format by Country:
| Country | Format | Example | Source |
|---|---|---|---|
| NL | NL.KVK.{number} | NL.KVK.12345678 | KVK |
| BE | BE.KBO.{number} | BE.KBO.0123456789 | KBO/BCE |
| DE | DE{court}.{type}{number} | DEK1101R.HRB116737 | HRB/HRA |
| FR | FR.SIREN.{number} | FR.SIREN.123456789 | SIREN |
| AT | AT.FB.{number} | AT.FB.123456A | Firmenbuch |
| IT | IT.REA.{number} | IT.REA.RM-123456 | REA |
| ES | ES.CIF.{number} | ES.CIF.A12345678 | CIF |
| DK | DK.CVR.{number} | DK.CVR.12345678 | CVR |
Registry: https://e-justice.europa.eu/489/EN/business_registers
Ultimate Parent / Corporate Hierarchy (GLEIF Level-2)
Section titled “Ultimate Parent / Corporate Hierarchy (GLEIF Level-2)”Establishes the corporate group head (“global ultimate”) an entity belongs to, so that many legal entities and establishments of one group (e.g. all DSV rows) can be rolled up to a single concern. This is not the AML UBO (Ultimate Beneficial Owner = the natural person): the NL UBO register is access-restricted, and a listed group such as DSV A/S has no single >25% owner. For rolling up locations the corporate parent is the correct key; the UBO is neither available nor needed.
Source: GLEIF Level-2 relationship data (accounting-consolidation parents), free, authoritative. Only available for entities that have an LEI and whose parent reported the relationship.
GET https://api.gleif.org/api/v1/lei-records/{lei}/ultimate-parent # global ultimateGET https://api.gleif.org/api/v1/lei-records/{lei}/direct-parent # immediate parentResolution logic (per LEI):
ultimate-parent→ use it (parent LEI + legal name).- else
direct-parent→ use it. - else (HTTP 404 on both) → the entity is its own group head / non-consolidating → use its own LEI+name.
Notes & limitations:
- A single
ultimate-parentcall does not always reach the absolute top (inconsistent group reporting), e.g. Containerships – CMA CGM GmbH instead of CMA CGM S.A. Optionally climb transitively (resolve the parent’s parent until 404) for a cleaner top. - Coverage equals LEI coverage: in a 1.202-row cross-client test only 55 unique LEIs existed; 27 of those carried a reported ultimate parent. Rows without an LEI get no GLEIF parent → fall back to a name/brand canonicalisation as a full-coverage proxy (see Batch Learnings).
- For 100 % hierarchy coverage (incl. non-LEI entities, full chain) a commercial source is required: D&B “Global Ultimate” (DUNS), Bureau van Dijk / Orbis, or OpenCorporates (API key).
Example results (cross-client test, 2026-06):
| Brand | GLEIF ultimate parent | Parent LEI |
|---|---|---|
| DSV | DSV A/S | 529900X41C0BSLK67H70 |
| Kühne+Nagel | Kühne + Nagel International AG | — |
| DB Schenker | Deutsche Bahn AG | — |
| FrieslandCampina | Koninklijke FrieslandCampina N.V. | — |
| Maersk | A.P. Møller Holding A/S | — |
External Registry Services
Section titled “External Registry Services”Service Overview
Section titled “Service Overview”flowchart LR
subgraph Services["Quarkus Services (Java)"]
KVK_SVC[KvkService for NL]
KBO_SVC[KboService for BE - scraping]
KBO_API_SVC[KboApiService for BE - API]
VIES_SVC[ViesService]
VIES_ENRICH[ViesEnrichmentService]
LEI_SVC[LeiService]
HR_SVC[HandelsregisterService for DE]
PEPPOL_SVC[PeppolService]
EUID_SVC[EuidService]
end
subgraph APIs["External APIs"]
KVK_API["KVK API
api.kvk.nl"]
KBO_WEB["KBO Public Search
kbopub.economie.fgov.be
(web scraping - free)"]
KBO_API["KBO API
api.kbodata.app
(paid - DISABLED)"]
VIES_API["VIES API
ec.europa.eu/taxation_customs/vies"]
GLEIF_API["GLEIF API
api.gleif.org/api/v1"]
PEPPOL_API["Peppol Directory
directory.peppol.eu"]
HR_WEB["Handelsregister.de
(web scraping)"]
end
subgraph Data["Registry Cache Tables"]
KVK_DATA[(kvk_registry_data)]
BE_DATA["(belgium_registry_data)
PLANNED - not deployed"]
VIES_DATA[(vies_registry_data)]
GLEIF_DATA[(gleif_registry_data)]
DE_DATA[(german_registry_data)]
PEPPOL_DATA["(peppol_registry_data)
EXISTS - not used"]
end
KVK_SVC --> KVK_API
KBO_SVC --> KBO_WEB
KBO_API_SVC -.->|disabled| KBO_API
VIES_SVC --> VIES_API
VIES_ENRICH --> VIES_API
LEI_SVC --> GLEIF_API
HR_SVC --> HR_WEB
PEPPOL_SVC --> PEPPOL_API
KVK_API --> KVK_DATA
KBO_WEB -.->|pending| BE_DATA
KBO_API -.->|pending| BE_DATA
VIES_API --> VIES_DATA
GLEIF_API --> GLEIF_DATA
HR_WEB --> DE_DATA
PEPPOL_API -.->|not stored| PEPPOL_DATA
Notes:
- KBO API (kbodata.app) is disabled pending subscription
peppol_registry_datatable exists but is not used (only identifier stored inlegal_entity_number)
Data Sources & Rate Limits
Section titled “Data Sources & Rate Limits”Web Scraping Services
Section titled “Web Scraping Services”⚠️ Important: These services use web scraping rather than official APIs. They have rate limits to avoid being blocked and may be more fragile than API-based services.
German Handelsregister (DE)
Section titled “German Handelsregister (DE)”| Service | URL | Method | Rate Limit |
|---|---|---|---|
| BundesAPI | handelsregister.de | Web scraping | 60 requests/hour (1 per minute) |
| GLEIF (fallback) | api.gleif.org | REST API | No hard limit (fair use) |
Implementation: BundesApiService.java
// Rate limit: 60 queries/hour per Terms of Useprivate static final Duration MIN_REQUEST_INTERVAL = Duration.ofMinutes(1);Belgian KBO Public Search (BE)
Section titled “Belgian KBO Public Search (BE)”| Service | URL | Method | Rate Limit |
|---|---|---|---|
| KBO Public | kbopub.economie.fgov.be | Web scraping | No documented limit (be respectful) |
| KBO API (paid) | api.kbodata.app | REST API | Per subscription tier |
Implementation: KboService.java
Notes:
- Public search requires enterprise number (KBO) - name search not supported
- For high-volume production use, paid KBO API is recommended
Official API Services
Section titled “Official API Services”| Service | API | Rate Limit | Auth Required |
|---|---|---|---|
| KVK (NL) | api.kvk.nl | Per API key tier | Yes (API key) |
| GLEIF | api.gleif.org | Fair use policy | No |
| VIES | ec.europa.eu/taxation_customs/vies | Fair use policy | No |
| Peppol Directory | directory.peppol.eu | 2 req/second | No |
Peppol Rate Limiting: PeppolService.java
// Rate limit: 2 queries per second (500ms minimum between requests)private static final long MIN_REQUEST_INTERVAL_MS = 500;Identifier Type Matrix
Section titled “Identifier Type Matrix”Country-Specific Identifiers
Section titled “Country-Specific Identifiers”| Country | Identifier | Source | Derivation Logic |
|---|---|---|---|
| NL | KVK | Input | Manual entry or application |
| NL | RSIN | KVK API | Extracted from _embedded.eigenaar.rsin |
| NL | VAT | Generated | NL + RSIN + B01 (or B02, B03, B04 for fiscal units) |
| NL | EUID | Generated | NL.KVK.{kvkNumber} |
| DE | HRB/HRA | Handelsregister | Scraped or manual entry |
| DE | EUID | Generated | DE{courtCode}.{type}{number} |
| DE | VAT | Manual | Cannot be auto-derived |
| BE | KBO/BCE | Input/KBO | Belgian business register (10 digits) |
| BE | VAT | Generated | BE + KBO number (without dots) |
| BE | EUID | Generated | BE.KBO.{kboNumber} |
Global Identifiers (ALL EU Countries)
Section titled “Global Identifiers (ALL EU Countries)”| Identifier | Source | Lookup Strategy |
|---|---|---|
| EUID | Generated | From national identifier (KVK, KBO, HRB, SIREN, etc.) |
| LEI | GLEIF | 1. Registration number + country 2. Company name + country fallback |
| Ultimate parent | GLEIF Level-2 | lei-records/{lei}/ultimate-parent → else direct-parent → else self (corporate group head, not AML UBO) |
| PEPPOL | Peppol Directory | 1. CoC/VAT by country scheme 2. Company name + country fallback |
VAT Derivation Rules
Section titled “VAT Derivation Rules”| Country | Rule | Example | Auto-Derivable? |
|---|---|---|---|
| NL | NL + RSIN + B01 (try B02, B03 if invalid) | NL861785721B01 | ✅ Yes (if RSIN available) |
| BE | BE + KBO number (10 digits, no dots) | BE0439291125 | ✅ Yes (if KBO available) |
| DE | Manual entry only | DE123456789 | ❌ No (Handelsregister has no VAT) |
| Other | Manual entry | Country-specific | ❌ No (no derivation rule) |
Supported CoC Types for LEI Lookup
Section titled “Supported CoC Types for LEI Lookup”| Type | Country | GLEIF Registration Authority |
|---|---|---|
| KVK | NL | RA000463 (Kamer van Koophandel) |
| HRB/HRA | DE | RA000197-RA000296 (~100 local courts) |
| KBO/BCE | BE | RA000025 (Kruispuntbank) |
| RCS/SIREN | FR | RA000192, RA000189 (Infogreffe, SIRENE) |
| CRN | GB | RA000585-RA000587 (Companies House) |
| REA | IT | RA000407 (Registro Delle Imprese) |
| CIF | ES | RA000533, RA000780 (Registro Mercantil) |
| CVR | DK | RA000170 (Central Business Register) |
Service Architecture
Section titled “Service Architecture”Enrichment Services (Modular Design)
Section titled “Enrichment Services (Modular Design)”| Service | File | Purpose | Scope |
|---|---|---|---|
| Orchestrator | nl.ctn.asr.enrichment.EnrichmentService | Main coordinator - enrichLegalEntity() | ALL |
| EUID Enrichment | nl.ctn.asr.enrichment.EuidEnrichmentService | EUID generation | ALL EU countries |
| NL Enrichment | nl.ctn.asr.enrichment.NlEnrichmentService | RSIN, VAT derivation, KVK registry | NL only |
| DE Enrichment | nl.ctn.asr.enrichment.DeEnrichmentService | HRB/HRA, registry scraping | DE only |
| BE Enrichment | nl.ctn.asr.enrichment.BeEnrichmentService | KBO, VAT derivation | BE only |
| LEI Enrichment | nl.ctn.asr.enrichment.LeiEnrichmentService | GLEIF lookup | ALL countries |
| Peppol Enrichment | nl.ctn.asr.enrichment.PeppolEnrichmentService | Peppol Directory lookup | ALL countries |
| VIES Enrichment | nl.ctn.asr.enrichment.ViesEnrichmentService | Batch VAT verification | ALL EU countries |
| Types | nl.ctn.asr.enrichment.EnrichmentContext / EnrichmentResult | Shared records | - |
External API Services
Section titled “External API Services”| Service | File | Purpose | Method | Status |
|---|---|---|---|---|
| KVK | nl.ctn.asr.enrichment.KvkService | Dutch CoC lookup | REST API | ✅ Active |
| KBO (scraping) | nl.ctn.asr.enrichment.KboService | Belgian KBO public search | Web Scraping | ✅ Active |
| KBO API | nl.ctn.asr.enrichment.KboApiService | Belgian KBO official API | REST API | ⏸️ Disabled (requires subscription) |
| VIES | nl.ctn.asr.enrichment.ViesService | EU VAT validation | SOAP/REST | ✅ Active |
| LEI | nl.ctn.asr.enrichment.LeiService | GLEIF LEI lookup + storage | REST API | ✅ Active |
| Handelsregister | nl.ctn.asr.enrichment.HandelsregisterService | German registry coordinator | Mixed | ✅ Active |
| BundesAPI | nl.ctn.asr.enrichment.BundesApiService | Handelsregister.de scraping | Web Scraping (60/hr) | ✅ Active |
| Peppol | nl.ctn.asr.enrichment.PeppolService | Peppol directory lookup | REST API (2/sec) | ✅ Active |
| EUID | nl.ctn.asr.enrichment.EuidService | EUID format generation | Local generation | ✅ Active |
Service Structure
Section titled “Service Structure”src/main/java/nl/ctn/asr/enrichment/├── EnrichmentService.java # Main orchestrator - enrichLegalEntity()├── EnrichmentContext.java # record (input context)├── EnrichmentResult.java # record (result)├── EuidEnrichmentService.java # enrichEuid() - ALL EU├── NlEnrichmentService.java # enrichRsin(), enrichVat() - NL├── DeEnrichmentService.java # enrichGermanRegistry() - DE├── BeEnrichmentService.java # enrichBelgianRegistry() - BE├── LeiEnrichmentService.java # enrichLei() - ALL via CoC/name├── PeppolEnrichmentService.java # enrichPeppol() - ALL via CoC/VAT/name├── ViesEnrichmentService.java # verifyVatAgainstVies() - batch├── KvkService.java # KVK API client├── KboService.java # KBO scraping (free)├── KboApiService.java # KBO API (paid, DISABLED)├── ViesService.java # VIES API client├── LeiService.java # GLEIF API + storage├── HandelsregisterService.java # Handelsregister logic├── BundesApiService.java # Handelsregister scraping├── PeppolService.java # Peppol Directory client└── EuidService.java # EUID generationEnrichment Context
Section titled “Enrichment Context”public record EnrichmentContext( UUID legalEntityId, // UUID of entity String companyName, // For fallback searches (nullable) String countryCode, // Two-letter code List<ExistingIdentifier> existingIdentifiers, // Already stored Set<String> existingTypes // Quick lookup) { }
public record EnrichmentResult( String identifier, // e.g., "LEI", "VAT", "EUID" EnrichmentStatus status, // ADDED | EXISTS | ERROR | NOT_AVAILABLE String value, // Identifier value if found (nullable) String message // Human-readable status (nullable)) { }The PostgreSQL connection is injected via Quarkus/Panache, not passed in the context.
Benefits of Modular Architecture
Section titled “Benefits of Modular Architecture”- Separation of concerns - Each enrichment type in its own service
- Testability - Services can be unit tested independently
- Readability - ~100-200 lines per service vs 900+ lines inline
- Global scope - EUID, LEI, Peppol work for ALL EU countries
- Extensibility - Easy to add new country-specific formats
- Maintainability - Bug fixes isolated to specific services
API Response Format
Section titled “API Response Format”Enrichment Endpoint
Section titled “Enrichment Endpoint”POST /v1/legal-entities/{id}/enrich
Response:
{ "success": true, "added_count": 3, "company_details_updated": true, "updated_fields": ["primary_legal_name", "city", "address_line1"], "german_registry_fetched": false, "results": [ { "identifier": "RSIN", "status": "added", "value": "123456789" }, { "identifier": "VAT", "status": "added", "value": "NL123456789B01" }, { "identifier": "EUID", "status": "added", "value": "NL.KVK.12345678" }, { "identifier": "LEI", "status": "exists", "value": "724500Y6YT5CU09QUU37" }, { "identifier": "PEPPOL", "status": "not_available", "message": "No Peppol participant found" } ], "summary": { "added": ["RSIN: 123456789", "VAT: NL123456789B01", "EUID: NL.KVK.12345678"], "already_exists": ["LEI"], "not_available": ["PEPPOL (No Peppol participant found)"], "errors": [], "company_fields_updated": ["primary_legal_name", "city", "address_line1"] }}Status Values & State Machine
Section titled “Status Values & State Machine”Enrichment Status (API Response)
Section titled “Enrichment Status (API Response)”When enrichment runs, each identifier returns one of these statuses:
| Enrichment Status | Meaning | Maps to Validation Status |
|---|---|---|
added | New identifier found and stored | VALID |
exists | Identifier already in database | (unchanged) |
not_available | Identifier cannot be found/derived | (not stored) |
error | API or processing error occurred | (not stored) |
Validation Status (Database)
Section titled “Validation Status (Database)”The validation_status field in legal_entity_number table:
| Status | Meaning | Set When |
|---|---|---|
VALID | Confirmed valid by registry API or derivation rules | LEI from GLEIF, VAT from VIES, RSIN from KVK, EUID generated, Belgian VAT derived |
INVALID | Registry confirmed identifier does not exist or failed validation | VIES returned invalid, GLEIF not found |
PENDING | Not yet checked/verified | Manual entry, awaiting API check |
EXPIRED | Was valid but verification has expired | Periodic re-verification needed |
NOT_VERIFIABLE | Cannot be verified (no registry API exists) | Identifier types without registry APIs |
Status Mapping
Section titled “Status Mapping”| Source | Enrichment Status | Validation Status |
|---|---|---|
| LEI from GLEIF API | added | VALID |
| VAT from VIES API | added | VALID |
| RSIN from KVK API | added | VALID |
| EUID auto-generated | added | VALID |
| Peppol from Directory | added | VALID |
| Belgian VAT (derived from KBO) | added | VALID |
| KBO from Belgian registry | added | VALID |
| HRB/HRA from German registry | added | VALID |
| Manual entry | N/A | PENDING |
| VIES validation failed | N/A | INVALID |
State Transition Diagram
Section titled “State Transition Diagram”stateDiagram-v2
[*] --> PENDING: Manual entry
[*] --> VALID: API confirms / Derivation succeeds
PENDING --> VALID: API verification succeeds
PENDING --> INVALID: Verification fails
VALID --> EXPIRED: Time-based expiry
EXPIRED --> VALID: Re-verification succeeds
EXPIRED --> INVALID: Re-verification fails
INVALID --> VALID: Manual correction + re-verification
Quality Requirements
Section titled “Quality Requirements”Performance
Section titled “Performance”| Requirement | Target | Measurement |
|---|---|---|
| Enrichment Duration | < 5 seconds per entity | Total API call time |
| Rate Limit Compliance | 100% adherence | No 429 HTTP errors |
| Cache Hit Rate | > 80% for repeated queries | Registry cache usage |
| Parallel Processing | ALL identifiers attempted | No sequential blocking (defensive try-catch) |
Reliability
Section titled “Reliability”| Requirement | Target | Measurement |
|---|---|---|
| Partial Success | Continue on individual failures | At least 1 identifier added if available |
| Error Handling | Graceful degradation | No cascading failures |
| Retry Logic | 3 retries with exponential backoff | HTTP 5xx errors |
| Cache Staleness | 90 days | Re-fetch after threshold |
Data Quality
Section titled “Data Quality”| Requirement | Target | Measurement |
|---|---|---|
| Verification Coverage | > 90% VALID status | % of identifiers confirmed via API or derivation |
| Validation Accuracy | 100% format compliance | Regex pattern matches |
| Duplicate Prevention | 0 duplicate identifiers | Unique constraint enforcement |
| Audit Trail | 100% registry responses stored | Cache table completeness |
Maintainability
Section titled “Maintainability”| Requirement | Target | Measurement |
|---|---|---|
| Service Size | < 250 lines per service | Lines of code |
| Cyclomatic Complexity | < 10 per function | Static analysis |
| Test Coverage | > 80% | Unit tests |
| API Documentation | Complete OpenAPI spec | Swagger validation |
Batch Enrichment of Existing Records (Learnings 2026-06)
Section titled “Batch Enrichment of Existing Records (Learnings 2026-06)”The flows above describe per-entity, on-demand enrichment (registration number known, single POST /enrich). A second, complementary mode emerged from enriching an existing list of ~380 customer rows that had only name + address + country and no registration numbers. The learnings below extend the architecture for this bulk / discovery mode.
Discovery is name-first, and registers beat LEI for coverage
Section titled “Discovery is name-first, and registers beat LEI for coverage”LEI is opt-in and exists only at legal-entity level, so it is a poor primary key: in the test set 127 LEIs mapped to only 35 unique legal entities — the rest of the rows were establishments of the same companies. National registers are mandatory and therefore the right primary source. Strategy: register-first, with LEI/EUID/Peppol as supplementary entity identifiers, and a geocoded Plus Code as a universal location fallback.
Bulk / discovery data sources
Section titled “Bulk / discovery data sources”| Country | Identifier obtained | Source | Auth | Notes |
|---|---|---|---|---|
| DE | HRB/HRA + Amtsgericht + EUID | OffeneRegister.de bulk (offline) | none | 5M+ companies; no rate limit; EUID derivable from company_number |
| FR | SIREN + SIRET (establishment) | recherche-entreprises.api.gouv.fr | none | Free; code_postal filter resolves the branch SIRET per location |
| CH | UID (CHE-…) | Zefix PublicREST | account required | Now needs free Basic-auth account (request via zefix@bj.admin.ch); alternatives: LINDAS SPARQL register.ld.admin.ch, opendata.swiss bulk |
| NL | vestigingsnummer (12) | KVK /basisprofielen/{kvk}/vestigingen | API key | Matched to the row address by postcode |
| ALL | Plus Code (Open Location Code) | Nominatim/Photon geocode + local OLC | none | Universal per-location identifier when no register ID exists |
| ALL | Peppol participant ID | Peppol Directory bulk export (offline) | none | 4.4M participants; daily refresh; also yields registration numbers (e.g. BE KBO) as by-catch |
Environment caveats observed: the KVK Zoeken (name-search) endpoint was network-blocked in the batch environment while
basisprofielen/vestigingenworked; the Google Geocoding API returnedREQUEST_DENIED(billing disabled); Photon refused connections and Nominatim throttles hard above 1 req/s. Plan geocoding as a slow, resumable, single-threaded job.
Batch name-matching technique (and pitfalls)
Section titled “Batch name-matching technique (and pitfalls)”Matching a source name to a registry name needs to be direction-aware and location-aware:
- Strip legal forms (GmbH, AG, SA, B.V., …) and compare on core brand tokens.
- Require the registry name to contain all brand tokens of the source row (the source name is usually the more specific one). A registry entry that is a subset of the source (fewer tokens) is the clean parent entity → good; a superset with extra qualifiers is likely a different subsidiary → reject or flag.
- Two-pass on location-in-name: first match with the full name (keeps a city that is part of the legal name, e.g. Rhenus Port Logistics Weil am Rhein GmbH); only if that fails, strip the row’s city token and retry (for Name + branch-city like Kühne & Nagel Koblenz).
- Use city/postcode only as a tie-breaker, never to override a weak name match — otherwise a same-city but unrelated company wins.
- Enforce same-country; never attach a foreign entity’s identifier to a branch row.
Source-data quality matters: ~12 rows were labelled DE but were actually foreign (Rhenus Singapore/China/Dubai, DSV Pfister Basel = CH, Schenker Nederland = NL). The country label should be validated/cleaned before matching.
Transliteration & normalisation. Match on a normalised form, never mutate source data. Three layers proved necessary in practice:
- Diacritic folding (NFKD → ASCII: é→e, ø→o) — but German has its own convention (ü→ue, ß→ss: Müller ≈ Mueller); Swiss registers (Zefix/LINDAS) only match the ue-variant, so generate both forms.
- Exonyms — Copenhagen ≠ København, Gothenburg ≠ Göteborg for an exact matcher; try English and local place names (bit us in UN/LOCODE assignment).
- Non-Latin scripts — Cyrillic/Han/Greek sources need true transliteration: a St. Petersburg address only geocoded with the Cyrillic query, and for non-EU entities GLEIF publishes a transliterated ASCII legal name (
transliteratedOtherEntityNames) that should be used as the match key. For systematic handling use an ICU-based transliterator rather than ad-hoc tables. Also watch concatenation variants: Friesland Campina vs FrieslandCampina — token-set matching misses these; compare a whitespace-squashed form as well.
VAT learning: fiscale eenheid
Section titled “VAT learning: fiscale eenheid”For NL, deriving NL + RSIN + B0x and validating via VIES can return INVALID for every B0x suffix even though the RSIN is correct. This is expected when the entity sits in a fiscale eenheid (VAT group) — it has no own VIES-registered VAT. Treat this as a valid outcome (no own VAT — fiscal unity), not an error.
Implementation notes
Section titled “Implementation notes”- Decompressing the 248 MB OffeneRegister
.bz2(≈5M lines) exceeds a single batch window; splitting withbzip2recoverinto blocks and filtering per batch on target brand tokens is a workable offline approach. The 737 MB / 3.5 GB SQLite variants are simpler to query ad-hoc. openpyxlgotcha:ws.cell(row, col, value=None)does not clear a cell (theNonedefault means “no value passed”); usews.cell(row, col).value = None.
Result (test set of 378 rows)
Section titled “Result (test set of 378 rows)”| Identifier level | Rows | Source |
|---|---|---|
| Legal entity (LEI / HRB / SIREN / KVK / UID) | 220 | GLEIF + national registers |
| Establishment (SIRET / vestigingsnummer) | 24 | recherche-entreprises, KVK |
| Location only (Plus Code) | 67 | geocoding |
| None | 67 | CH (Zefix account pending), non-EU/mislabelled rows |
≈82% of rows received a unique identifier, up from 33% (LEI-only). Largest remaining gap: CH (needs a Zefix account) and cleaning mislabelled country codes.
Peppol via bulk export (Learnings 2026-06)
Section titled “Peppol via bulk export (Learnings 2026-06)”For batch enrichment the per-query Directory API (2 req/s) is the wrong tool. The Peppol Directory publishes full exports, refreshed daily, linked from the footer of https://directory.peppol.eu/public (“Export data”):
| Export | URL path | Size (2026-06) | Use |
|---|---|---|---|
| BusinessCards XML without doctypes | /export/businesscards-xml-no-doc-types | 1.08 GB | preferred for matching |
| BusinessCards CSV (incl. doctypes) | /export/businesscards-csv | 4.7 GB | doctype analysis only |
| Participant IDs CSV/XML/JSON | /export/participants-csv | 178 MB | ID-existence checks only (no names) |
Pipeline: download → flatten to TSV (participant ID, country, names, geo, extra <id> elements) with a streaming regex parser (ElementTree.iterparse leaks memory on the root element unless root.clear() is called; a chunked regex over the raw bytes is ~3× faster) → invert the matching: build small lookup sets from the customer rows, then stream the 4.4M Peppol rows once against them. Whole run incl. download ≈ 3 minutes, no rate limits.
Match keys, in order of confidence:
- Hard identifiers against the participant scheme and the business-card
<id>elements: KVK→0106, KBO→0208, CVR→0184, SIREN(ET)→0002/0009/0225, NO org→0192, CHE-UID→0183/9927(bare 9 digits, noCHEprefix, often with suffixes likech02), VAT DE→9930, AT→9914/9915, NL→9944, BE→9925, SE→0007. Free-text<id>schemes (VAT=CHE-184.676.741 MWST) need tolerant parsing. - Normalised name + same country (legal forms stripped) — exact, then prefix match with a length-ratio guard (≥ 0.55) labelled separately as “loose”.
Results (1,202-row cross-client test): 177 rows matched (93 hard-ID, 66 exact-name, 18 loose). Coverage is bounded by Peppol adoption, not matching: the directory is dense for BE (2.07M), FR (0.87M), NO/SE/DK and NL (106k) but thin for DE (10.5k), AT (477), CH (280) — zero CH/AT hits despite full CHE-UID coverage on the input side.
By-catch: a Peppol name-match returns the participant’s registered <id> values, so for BE it backfills the KBO number (scheme 0208) that free KBO name-search cannot provide — 20 BE rows gained a KBO this way, from which BE VAT is then derivable (BE+KBO → VIES). This is a concrete instance of the iterative-enrichment pattern below.
Iterative enrichment — identifiers as a graph, not a sequence (proposed)
Section titled “Iterative enrichment — identifiers as a graph, not a sequence (proposed)”The current orchestrator runs one sequential pass (country service → LEI → Peppol → …). The batch runs showed identifiers are mutually derivable — each found identifier unlocks new lookups that may recover identifiers an earlier step missed:
LEI ──(GLEIF record: registeredAs + RA code)──▶ national reg.nr ──▶ EUIDnational reg.nr ──(scheme 0106/0208/…)──▶ Peppol IDPeppol ID ──(business-card <id> elements)──▶ KBO/KVK/VAT (by-catch)KBO ──(BE + number)──▶ VAT ──(VIES)──▶ validatedRSIN ──(NL + B0x)──▶ VATreg.nr ──(GLEIF registeredAs, number instead of name)──▶ LEI (retry with higher precision)HRB+court (OffeneRegister) ──▶ DE EUIDProposal: treat enrichment as fixpoint iteration over this derivation graph. After each round, re-run every resolver whose inputs gained a value; stop when a round adds nothing (typically 2–3 rounds). Concretely valuable paths observed: name→LEI→reg.nr (rows that had no register number), Peppol→KBO→VAT→VIES (BE), reg.nr→LEI retry on registeredAs (more precise than the name-based fuzzy search). Guards: every derived value keeps its provenance chain (VAT ← KBO ← Peppol name-match), confidence is the minimum along the chain, and same-country/conflict rules apply at every hop so an early weak name-match cannot launder itself into a “hard” identifier.
Iteration results & convergence (2026-06)
Section titled “Iteration results & convergence (2026-06)”The fixpoint iteration proposed above was executed on the 1,202-row cross-client set:
- Round 1: Peppol→KBO write-back (12 rows), KBO→VAT→VIES (7/9 unique numbers VALID), reg.nr→LEI retry via
filter[entity.registeredAs](+192 LEIs after name-guard), OffeneRegister for remaining DE rows (+30 HRB/HRA+EUID). - Round 2: re-ran the LEI retry for the 30 newly found DE register numbers → 0 additions; fixpoint reached. Every GLEIF hit was an HRB collision: German HRB numbers are only unique per Amtsgericht, and the GLEIF API cannot filter on court.
registeredAslookups for DE therefore require a name guard — without it, Rhenus rows would have received the LEI of a meat-products company sharing the same HRB number in another court. - Name guard tuning: token-overlap alone rejects legitimate concatenation variants (Friesland Campina vs FrieslandCampina); add a whitespace-squashed containment check. Guard rejected 55 collisions and passed 211 legitimate matches.
Conclusion: 2 rounds suffice on a static source set; further rounds only pay off when a new source (or key) enters the graph. Re-clustering the deduplicated organisation list with the newly derived identifiers merged 9 organisation pairs that shared a hard ID (name variants, location aliases), 457 → 448 unique organisations.
Location codes (UN/LOCODE, SMDG, GLN, OTM) — Learnings 2026-06
Section titled “Location codes (UN/LOCODE, SMDG, GLN, OTM) — Learnings 2026-06”Location identity is a separate axis from organisation identity. Implemented stack:
| Code | Granularity | Source | Coverage achieved | Notes |
|---|---|---|---|---|
| Plus Code (OLC) | exact point (~3×3 m) | computed from geocode | 89% | no registry needed; open equivalent of what3words (which is paid/proprietary) |
| UN/LOCODE | city/place | UNECE list (~116k places; machine-readable mirror github.com/datasets/un-locode) | 95% | matched on country + normalised place name; beware exonyms (Copenhagen/København) and postcode fragments in city fields |
| SMDG terminal code | terminal within a UN/LOCODE | github.com/smdg-org/Terminal-Code-List (CSV, ~1,250 terminals) | 27 rows (terminals only) | used in DCSA standards; match on UN/LOCODE + quay number (e.g. Kaai 869 → BEANR:K869) first, then brand tokens + distance; exclude city/country tokens or offices match terminals |
| GLN (GS1) | org/location | Peppol scheme 0088 by-catch only | 4 rows | no open GS1 registry exists (Verified-by-GS1 needs an account); treat GLN as input data, not derivable |
| OTM location | data model | generated export | 1,202 rows | OTM ≥5 location entities with geoReference + all identifiers in externalAttributes; the bridge to OTM-based exchange |
Matching pitfall worth recording: when matching rows to SMDG terminals, generic tokens (sea, france, china, city names) caused offices to match terminals; the working rule is quay-number first, then brand-token ∩ (facility ∪ company) minus geographic tokens, with a distance guard (<5 km same-name, <400 m for geo-only matches).
Roll-up to corporate parent (concern) — GLEIF Level-2
Section titled “Roll-up to corporate parent (concern) — GLEIF Level-2”When consolidating multiple source lists (a 1.202-row ITG × Van Berkel × Contargo cross-client test), the useful question is “which concern does this row belong to?” — e.g. rolling 232 DSV rows under DSV A/S. Two levels were combined:
- Name/brand canonicalisation (full coverage proxy): scan the whole name for known operator brands so customer/operator constructions resolve to the operator — XEROX C/O DSV → DSV, ACER … Kuehne Nagel → Kühne+Nagel. The terminal’s customer is the logistics operator, not its end-client. This groups every row, including those without any identifier.
- GLEIF ultimate parent (verified, partial): for rows with an LEI, attach the
ultimate-parent/direct-parent(see Ultimate Parent under Global Identifiers). This both confirms the concern (DSV → DSV A/S) and disambiguates name-twins — DSV & Partners / DSV Finances returned no DSV-group parent and were correctly excluded from the DSV concern.
Dedup keying for the legal-entity layer used multiple identifiers (LEI → register-nr → EUID → VAT, since coverage varies), with name+country only as a bridge and a conflict guard (never merge two clusters that hold different LEIs or register numbers). Establishment-level keys (vestigingsnummer, SIRET) and Plus Code were deliberately excluded from organisation dedup — they are location-, not entity-level.
Risks & Technical Debt
Section titled “Risks & Technical Debt”Current Risks
Section titled “Current Risks”| Risk | Impact | Probability | Mitigation |
|---|---|---|---|
| Web Scraping Fragility | HIGH | MEDIUM | HTML structure changes break scrapers → Implement schema validation, version detection, fallback to GLEIF |
| Rate Limit Violations | MEDIUM | LOW | BundesAPI 60/hr limit → Enforce minRequestInterval, implement queue, cache aggressively |
| Peppol Data Loss | LOW | LOW | peppol_registry_data table not used → Store full API response for audit trail |
| GLEIF API Changes | MEDIUM | LOW | Search parameter changes → Monitor API changelog, implement version detection |
| KBO API Dependency | LOW | HIGH | Disabled due to subscription cost → Public scraping works but limited data |
Technical Debt
Section titled “Technical Debt”| Debt Item | Severity | Effort | Description |
|---|---|---|---|
| KBO API Integration | MEDIUM | 4 hours | Service implemented but disabled - requires subscription decision and configuration |
| Peppol Response Storage | LOW | 2 hours | Store full Peppol API response in peppol_registry_data for compliance audit trail |
| Retry Logic Standardization | MEDIUM | 4 hours | Implement consistent retry logic across all external API clients (currently inconsistent) |
| Circuit Breaker Pattern | LOW | 6 hours | Add circuit breakers for failing external APIs to prevent cascade failures |
| Enrichment Scheduling | LOW | 8 hours | Implement periodic re-enrichment for entities with EXPIRED verification status |
Future Enhancements
Section titled “Future Enhancements”- Expand Country Coverage - Add enrichment services for FR (SIREN/SIRET), IT (REA), ES (CIF), UK (Companies House)
- Real-time Webhooks - Subscribe to KVK/KBO change notifications instead of periodic polling
- ML-based Name Matching - Improve company name matching with fuzzy search and ML models
- Blockchain Identity - Integrate with W3C Decentralized Identifiers (DIDs) for verified credentials
- API Gateway - Centralize external API calls through Azure API Management for rate limiting, caching, monitoring
Related Documentation
Section titled “Related Documentation”- CTN Member Onboarding Workflow - Business vetting and verification process
- ASR Database Schema — see
database/asr_dev.sqlin the implementation repository (out of scope for arc42).
Appendix: Decision Trees
Section titled “Appendix: Decision Trees”When is RSIN applicable?
Section titled “When is RSIN applicable?”flowchart TD
Q1{Country = NL?}
Q1 -->|Yes| Q2{KVK number exists?}
Q1 -->|No| NOT_APPLICABLE["RSIN not applicable
(Dutch-specific identifier)"]
Q2 -->|Yes| FETCH[Fetch RSIN from KVK API]
Q2 -->|No| NOT_POSSIBLE["Cannot derive RSIN
without KVK number"]
FETCH --> RESULT[Store RSIN identifier]
When can VAT be auto-derived?
Section titled “When can VAT be auto-derived?”flowchart TD
Q1{Country?}
Q1 -->|NL| Q2{RSIN available?}
Q1 -->|BE| Q3{KBO available?}
Q1 -->|DE| MANUAL_DE["VAT must be manual
(Handelsregister has no VAT)"]
Q1 -->|Other| MANUAL_OTHER["VAT must be manual
(no derivation rule)"]
Q2 -->|Yes| DERIVE_NL["Derive: NL + RSIN + B01"]
Q2 -->|No| NO_RSIN["Cannot derive VAT
without RSIN"]
DERIVE_NL --> VALIDATE_NL[Validate via VIES]
VALIDATE_NL -->|Valid| STORE_NL[Store as VALID]
VALIDATE_NL -->|Invalid| TRY_B02["Try B02, B03..."]
Q3 -->|Yes| DERIVE_BE["Derive: BE + KBO"]
Q3 -->|No| NO_KBO["Cannot derive VAT
without KBO"]
DERIVE_BE --> VALIDATE_BE[Verify via VIES]
VALIDATE_BE -->|Valid| STORE_BE_V[Store as VALID]
VALIDATE_BE -->|Invalid| STORE_BE_D[Store as VALID]
When can EUID be generated?
Section titled “When can EUID be generated?”flowchart TD
Q1{Country = NL?}
Q1 -->|Yes| Q2{KVK exists?}
Q2 -->|Yes| GEN_NL["Generate: NL.KVK.kvkNumber"]
Q2 -->|No| NO_NL["Cannot generate"]
Q1 -->|No| Q3{Country = DE?}
Q3 -->|Yes| Q4{HRB/HRA exists?}
Q4 -->|Yes| Q5{Court code available?}
Q5 -->|Yes| GEN_DE["Generate: DEcourt.typenumber"]
Q5 -->|No| NO_CODE["Cannot generate
(missing court code)"]
Q4 -->|No| NO_HRB["Cannot generate
(missing register number)"]
Q3 -->|No| OTHER["See country-specific
EUID format rules"]
Document Version: 1.2 Last Updated: 2026-06-10 Status: Published Maintainer: Ramon de Noronha
Changelog:
- 1.2 (2026-06-10) — Peppol bulk-export enrichment (full-directory offline matching, scheme mapping, adoption-bounded coverage, KBO by-catch for BE); proposed iterative/fixpoint enrichment over the identifier derivation graph; transliteration & normalisation guidance (diacritic folding incl. German ue/ss convention, exonyms, non-Latin scripts, GLEIF transliterated names, concatenation variants); iteration executed & converged in 2 rounds (HRB-collision learning: GLEIF
registeredAsfor DE requires a name guard); location-code stack (UN/LOCODE, SMDG terminal codes incl. quay-number matching, GLN limitation, OTM location export). - 1.1 (2026-06-04) — Added two-level identity model (entity vs establishment); batch/discovery enrichment learnings (OffeneRegister bulk, recherche-entreprises FR SIRET, Zefix account requirement, KVK vestigingen, Plus Codes); GLEIF fuzzy-completion endpoint for name discovery; fiscale-eenheid VAT case; name-matching technique and environment caveats.
- 1.0 (2025-12-17) — Initial published architecture.
In samenwerking met




