Skip to content
Deze documentatie wordt actief uitgebreid — kom regelmatig terug.

CTN Identifier Enrichment & Verification Architecture

Automated Legal Entity Identifier Discovery and Validation

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).


  1. System Overview
  2. Key Principles
  3. Building Block View
  4. Country-Specific Enrichment Flows
  5. External Registry Services
  6. Data Sources & Rate Limits
  7. Identifier Type Matrix
  8. Service Architecture
  9. API Response Format
  10. Status Values & State Machine
  11. Quality Requirements
  12. Batch Enrichment of Existing Records (Learnings 2026-06)
  13. Risks & Technical Debt

  1. Automated Enrichment - Minimize manual data entry through automatic identifier discovery
  2. Source Authority - Validate identifiers against authoritative external registries
  3. Country Extensibility - Modular architecture supports adding new countries without code duplication
  4. Global Standards - Support pan-European identifiers (EUID, LEI, Peppol) for all EU member states
  5. Defensive Programming - Handle API failures gracefully, continue partial enrichment on errors
  6. Rate Limit Compliance - Respect external API rate limits through throttling and caching
  • 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

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:

  1. Primary search: Use national registration number (KVK, KBO, HRB, SIREN) + country code
  2. Fallback search: If primary fails, search by company name + country code
  3. 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)

Each enrichment type is a separate service:

  • NlEnrichmentService.java - RSIN and VAT derivation for Netherlands
  • DeEnrichmentService.java - Handelsregister scraping for Germany
  • BeEnrichmentService.java - KBO lookup and VAT derivation for Belgium
  • LeiEnrichmentService.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.

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.

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
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

The diagram below shows each country-specific enrichment service with its dedicated API client and registry data table.

flowchart LR
    NL[nlEnrichmentService] --> KVK[kvkService] --> KVK_DB[(kvk_registry_data)]
    NL --> VIES[viesService] --> VIES_DB[(vies_registry_data)]
    NL --> NUM[(legal_entity_number)]
flowchart LR
    BE[beEnrichmentService] --> KBO[kboService] --> BE_DB[(belgium_registry_data)]
    BE --> VIES[viesService] --> VIES_DB[(vies_registry_data)]
    BE --> NUM[(legal_entity_number)]
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)]
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
Enrichment ServiceAPI Client(s)Registry Data Table
nlEnrichmentServicekvkService, viesServicekvk_registry_data, vies_registry_data
beEnrichmentServicekboService, viesServicebelgium_registry_data, vies_registry_data
deEnrichmentServicehandelsregisterService, bundesApiService, leiServicegerman_registry_data, gleif_registry_data
leiEnrichmentServiceleiServicegleif_registry_data
peppolEnrichmentServicepeppolService(identifier stored in legal_entity_number)
viesEnrichmentServiceviesServicevies_registry_data
euidEnrichmentService(local generation)(identifier stored in legal_entity_number)

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_number is already in court-code form K1101R_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 Neuss
  • K1101R - Amtsgericht Hamburg
  • M1301R - Amtsgericht München
  • R2210R - Amtsgericht Koblenz

EUID Format: DE{courtCode}.{type}{number}DEK1101R.HRB116737

Rate Limits: 60 requests/hour (1 per minute enforced via minRequestInterval)


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:

  1. Subscribe to plan (Search, Medium, or Large)
  2. Set environment variables: KBO_API_KEY, KBO_API_ENABLED=true
  3. Restart API service

Rate Limits:

  • Public scraping: No documented limit (be respectful)
  • KBO API: Per subscription tier

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., 33031431 for 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 like RA000463 for 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]=20

Name Matching Logic:

  1. Single result → Use it
  2. Multiple results → Try exact match (normalized, alphanumeric only)
  3. No exact match → Try starts-with match
  4. 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 via filter[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 is a pan-European e-invoicing network. Companies registered in Peppol Directory can send/receive electronic invoices.

Search Strategy:

  1. Search by national registry number (KVK for NL, KBO for BE, etc.)
  2. Then search by VAT number
  3. 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:

CountryCoC SchemeVAT SchemeExample
NL0106 (KVK)99440106:12345678
BE0208 (KBO)99250208:0439291125
DE-99309930:DE123456789
FR0009 (SIRET)99570009:12345678901234
GB0088 (CRN)99320088:12345678
DK0184 (CVR)99020184: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 (European Unique Identifier) is available for ALL EU member states via BRIS system.

Format by Country:

CountryFormatExampleSource
NLNL.KVK.{number}NL.KVK.12345678KVK
BEBE.KBO.{number}BE.KBO.0123456789KBO/BCE
DEDE{court}.{type}{number}DEK1101R.HRB116737HRB/HRA
FRFR.SIREN.{number}FR.SIREN.123456789SIREN
ATAT.FB.{number}AT.FB.123456AFirmenbuch
ITIT.REA.{number}IT.REA.RM-123456REA
ESES.CIF.{number}ES.CIF.A12345678CIF
DKDK.CVR.{number}DK.CVR.12345678CVR

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 ultimate
GET https://api.gleif.org/api/v1/lei-records/{lei}/direct-parent # immediate parent

Resolution logic (per LEI):

  1. ultimate-parent → use it (parent LEI + legal name).
  2. else direct-parent → use it.
  3. else (HTTP 404 on both) → the entity is its own group head / non-consolidating → use its own LEI+name.

Notes & limitations:

  • A single ultimate-parent call 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):

BrandGLEIF ultimate parentParent LEI
DSVDSV A/S529900X41C0BSLK67H70
Kühne+NagelKühne + Nagel International AG
DB SchenkerDeutsche Bahn AG
FrieslandCampinaKoninklijke FrieslandCampina N.V.
MaerskA.P. Møller Holding A/S

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_data table exists but is not used (only identifier stored in legal_entity_number)

⚠️ 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.

ServiceURLMethodRate Limit
BundesAPIhandelsregister.deWeb scraping60 requests/hour (1 per minute)
GLEIF (fallback)api.gleif.orgREST APINo hard limit (fair use)

Implementation: BundesApiService.java

// Rate limit: 60 queries/hour per Terms of Use
private static final Duration MIN_REQUEST_INTERVAL = Duration.ofMinutes(1);
ServiceURLMethodRate Limit
KBO Publickbopub.economie.fgov.beWeb scrapingNo documented limit (be respectful)
KBO API (paid)api.kbodata.appREST APIPer 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
ServiceAPIRate LimitAuth Required
KVK (NL)api.kvk.nlPer API key tierYes (API key)
GLEIFapi.gleif.orgFair use policyNo
VIESec.europa.eu/taxation_customs/viesFair use policyNo
Peppol Directorydirectory.peppol.eu2 req/secondNo

Peppol Rate Limiting: PeppolService.java

// Rate limit: 2 queries per second (500ms minimum between requests)
private static final long MIN_REQUEST_INTERVAL_MS = 500;

CountryIdentifierSourceDerivation Logic
NLKVKInputManual entry or application
NLRSINKVK APIExtracted from _embedded.eigenaar.rsin
NLVATGeneratedNL + RSIN + B01 (or B02, B03, B04 for fiscal units)
NLEUIDGeneratedNL.KVK.{kvkNumber}
DEHRB/HRAHandelsregisterScraped or manual entry
DEEUIDGeneratedDE{courtCode}.{type}{number}
DEVATManualCannot be auto-derived
BEKBO/BCEInput/KBOBelgian business register (10 digits)
BEVATGeneratedBE + KBO number (without dots)
BEEUIDGeneratedBE.KBO.{kboNumber}
IdentifierSourceLookup Strategy
EUIDGeneratedFrom national identifier (KVK, KBO, HRB, SIREN, etc.)
LEIGLEIF1. Registration number + country
2. Company name + country fallback
Ultimate parentGLEIF Level-2lei-records/{lei}/ultimate-parent → else direct-parent → else self (corporate group head, not AML UBO)
PEPPOLPeppol Directory1. CoC/VAT by country scheme
2. Company name + country fallback
CountryRuleExampleAuto-Derivable?
NLNL + RSIN + B01 (try B02, B03 if invalid)NL861785721B01✅ Yes (if RSIN available)
BEBE + KBO number (10 digits, no dots)BE0439291125✅ Yes (if KBO available)
DEManual entry onlyDE123456789❌ No (Handelsregister has no VAT)
OtherManual entryCountry-specific❌ No (no derivation rule)
TypeCountryGLEIF Registration Authority
KVKNLRA000463 (Kamer van Koophandel)
HRB/HRADERA000197-RA000296 (~100 local courts)
KBO/BCEBERA000025 (Kruispuntbank)
RCS/SIRENFRRA000192, RA000189 (Infogreffe, SIRENE)
CRNGBRA000585-RA000587 (Companies House)
REAITRA000407 (Registro Delle Imprese)
CIFESRA000533, RA000780 (Registro Mercantil)
CVRDKRA000170 (Central Business Register)

ServiceFilePurposeScope
Orchestratornl.ctn.asr.enrichment.EnrichmentServiceMain coordinator - enrichLegalEntity()ALL
EUID Enrichmentnl.ctn.asr.enrichment.EuidEnrichmentServiceEUID generationALL EU countries
NL Enrichmentnl.ctn.asr.enrichment.NlEnrichmentServiceRSIN, VAT derivation, KVK registryNL only
DE Enrichmentnl.ctn.asr.enrichment.DeEnrichmentServiceHRB/HRA, registry scrapingDE only
BE Enrichmentnl.ctn.asr.enrichment.BeEnrichmentServiceKBO, VAT derivationBE only
LEI Enrichmentnl.ctn.asr.enrichment.LeiEnrichmentServiceGLEIF lookupALL countries
Peppol Enrichmentnl.ctn.asr.enrichment.PeppolEnrichmentServicePeppol Directory lookupALL countries
VIES Enrichmentnl.ctn.asr.enrichment.ViesEnrichmentServiceBatch VAT verificationALL EU countries
Typesnl.ctn.asr.enrichment.EnrichmentContext / EnrichmentResultShared records-
ServiceFilePurposeMethodStatus
KVKnl.ctn.asr.enrichment.KvkServiceDutch CoC lookupREST API✅ Active
KBO (scraping)nl.ctn.asr.enrichment.KboServiceBelgian KBO public searchWeb Scraping✅ Active
KBO APInl.ctn.asr.enrichment.KboApiServiceBelgian KBO official APIREST API⏸️ Disabled (requires subscription)
VIESnl.ctn.asr.enrichment.ViesServiceEU VAT validationSOAP/REST✅ Active
LEInl.ctn.asr.enrichment.LeiServiceGLEIF LEI lookup + storageREST API✅ Active
Handelsregisternl.ctn.asr.enrichment.HandelsregisterServiceGerman registry coordinatorMixed✅ Active
BundesAPInl.ctn.asr.enrichment.BundesApiServiceHandelsregister.de scrapingWeb Scraping (60/hr)✅ Active
Peppolnl.ctn.asr.enrichment.PeppolServicePeppol directory lookupREST API (2/sec)✅ Active
EUIDnl.ctn.asr.enrichment.EuidServiceEUID format generationLocal generation✅ Active
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 generation
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.

  1. Separation of concerns - Each enrichment type in its own service
  2. Testability - Services can be unit tested independently
  3. Readability - ~100-200 lines per service vs 900+ lines inline
  4. Global scope - EUID, LEI, Peppol work for ALL EU countries
  5. Extensibility - Easy to add new country-specific formats
  6. Maintainability - Bug fixes isolated to specific services

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"]
}
}

When enrichment runs, each identifier returns one of these statuses:

Enrichment StatusMeaningMaps to Validation Status
addedNew identifier found and storedVALID
existsIdentifier already in database(unchanged)
not_availableIdentifier cannot be found/derived(not stored)
errorAPI or processing error occurred(not stored)

The validation_status field in legal_entity_number table:

StatusMeaningSet When
VALIDConfirmed valid by registry API or derivation rulesLEI from GLEIF, VAT from VIES, RSIN from KVK, EUID generated, Belgian VAT derived
INVALIDRegistry confirmed identifier does not exist or failed validationVIES returned invalid, GLEIF not found
PENDINGNot yet checked/verifiedManual entry, awaiting API check
EXPIREDWas valid but verification has expiredPeriodic re-verification needed
NOT_VERIFIABLECannot be verified (no registry API exists)Identifier types without registry APIs
SourceEnrichment StatusValidation Status
LEI from GLEIF APIaddedVALID
VAT from VIES APIaddedVALID
RSIN from KVK APIaddedVALID
EUID auto-generatedaddedVALID
Peppol from DirectoryaddedVALID
Belgian VAT (derived from KBO)addedVALID
KBO from Belgian registryaddedVALID
HRB/HRA from German registryaddedVALID
Manual entryN/APENDING
VIES validation failedN/AINVALID
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

RequirementTargetMeasurement
Enrichment Duration< 5 seconds per entityTotal API call time
Rate Limit Compliance100% adherenceNo 429 HTTP errors
Cache Hit Rate> 80% for repeated queriesRegistry cache usage
Parallel ProcessingALL identifiers attemptedNo sequential blocking (defensive try-catch)
RequirementTargetMeasurement
Partial SuccessContinue on individual failuresAt least 1 identifier added if available
Error HandlingGraceful degradationNo cascading failures
Retry Logic3 retries with exponential backoffHTTP 5xx errors
Cache Staleness90 daysRe-fetch after threshold
RequirementTargetMeasurement
Verification Coverage> 90% VALID status% of identifiers confirmed via API or derivation
Validation Accuracy100% format complianceRegex pattern matches
Duplicate Prevention0 duplicate identifiersUnique constraint enforcement
Audit Trail100% registry responses storedCache table completeness
RequirementTargetMeasurement
Service Size< 250 lines per serviceLines of code
Cyclomatic Complexity< 10 per functionStatic analysis
Test Coverage> 80%Unit tests
API DocumentationComplete OpenAPI specSwagger 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.

CountryIdentifier obtainedSourceAuthNotes
DEHRB/HRA + Amtsgericht + EUIDOffeneRegister.de bulk (offline)none5M+ companies; no rate limit; EUID derivable from company_number
FRSIREN + SIRET (establishment)recherche-entreprises.api.gouv.frnoneFree; code_postal filter resolves the branch SIRET per location
CHUID (CHE-…)Zefix PublicRESTaccount requiredNow needs free Basic-auth account (request via zefix@bj.admin.ch); alternatives: LINDAS SPARQL register.ld.admin.ch, opendata.swiss bulk
NLvestigingsnummer (12)KVK /basisprofielen/{kvk}/vestigingenAPI keyMatched to the row address by postcode
ALLPlus Code (Open Location Code)Nominatim/Photon geocode + local OLCnoneUniversal per-location identifier when no register ID exists
ALLPeppol participant IDPeppol Directory bulk export (offline)none4.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/vestigingen worked; the Google Geocoding API returned REQUEST_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:

  1. Strip legal forms (GmbH, AG, SA, B.V., …) and compare on core brand tokens.
  2. 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.
  3. 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).
  4. Use city/postcode only as a tie-breaker, never to override a weak name match — otherwise a same-city but unrelated company wins.
  5. 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:

  1. Diacritic folding (NFKD → ASCII: é→e, ø→o) — but German has its own convention (ü→ue, ß→ss: MüllerMueller); Swiss registers (Zefix/LINDAS) only match the ue-variant, so generate both forms.
  2. ExonymsCopenhagenKøbenhavn, GothenburgGöteborg for an exact matcher; try English and local place names (bit us in UN/LOCODE assignment).
  3. 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.

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.

  • Decompressing the 248 MB OffeneRegister .bz2 (≈5M lines) exceeds a single batch window; splitting with bzip2recover into 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.
  • openpyxl gotcha: ws.cell(row, col, value=None) does not clear a cell (the None default means “no value passed”); use ws.cell(row, col).value = None.
Identifier levelRowsSource
Legal entity (LEI / HRB / SIREN / KVK / UID)220GLEIF + national registers
Establishment (SIRET / vestigingsnummer)24recherche-entreprises, KVK
Location only (Plus Code)67geocoding
None67CH (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”):

ExportURL pathSize (2026-06)Use
BusinessCards XML without doctypes/export/businesscards-xml-no-doc-types1.08 GBpreferred for matching
BusinessCards CSV (incl. doctypes)/export/businesscards-csv4.7 GBdoctype analysis only
Participant IDs CSV/XML/JSON/export/participants-csv178 MBID-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:

  1. 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, no CHE prefix, often with suffixes like ch02), 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.
  2. 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 ──▶ EUID
national reg.nr ──(scheme 0106/0208/…)──▶ Peppol ID
Peppol ID ──(business-card <id> elements)──▶ KBO/KVK/VAT (by-catch)
KBO ──(BE + number)──▶ VAT ──(VIES)──▶ validated
RSIN ──(NL + B0x)──▶ VAT
reg.nr ──(GLEIF registeredAs, number instead of name)──▶ LEI (retry with higher precision)
HRB+court (OffeneRegister) ──▶ DE EUID

Proposal: 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.

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. registeredAs lookups 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:

CodeGranularitySourceCoverage achievedNotes
Plus Code (OLC)exact point (~3×3 m)computed from geocode89%no registry needed; open equivalent of what3words (which is paid/proprietary)
UN/LOCODEcity/placeUNECE 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 codeterminal within a UN/LOCODEgithub.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 869BEANR:K869) first, then brand tokens + distance; exclude city/country tokens or offices match terminals
GLN (GS1)org/locationPeppol scheme 0088 by-catch only4 rowsno open GS1 registry exists (Verified-by-GS1 needs an account); treat GLN as input data, not derivable
OTM locationdata modelgenerated export1,202 rowsOTM ≥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:

  1. 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.
  2. 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-twinsDSV & 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.


RiskImpactProbabilityMitigation
Web Scraping FragilityHIGHMEDIUMHTML structure changes break scrapers → Implement schema validation, version detection, fallback to GLEIF
Rate Limit ViolationsMEDIUMLOWBundesAPI 60/hr limit → Enforce minRequestInterval, implement queue, cache aggressively
Peppol Data LossLOWLOWpeppol_registry_data table not used → Store full API response for audit trail
GLEIF API ChangesMEDIUMLOWSearch parameter changes → Monitor API changelog, implement version detection
KBO API DependencyLOWHIGHDisabled due to subscription cost → Public scraping works but limited data
Debt ItemSeverityEffortDescription
KBO API IntegrationMEDIUM4 hoursService implemented but disabled - requires subscription decision and configuration
Peppol Response StorageLOW2 hoursStore full Peppol API response in peppol_registry_data for compliance audit trail
Retry Logic StandardizationMEDIUM4 hoursImplement consistent retry logic across all external API clients (currently inconsistent)
Circuit Breaker PatternLOW6 hoursAdd circuit breakers for failing external APIs to prevent cascade failures
Enrichment SchedulingLOW8 hoursImplement periodic re-enrichment for entities with EXPIRED verification status
  1. Expand Country Coverage - Add enrichment services for FR (SIREN/SIRET), IT (REA), ES (CIF), UK (Companies House)
  2. Real-time Webhooks - Subscribe to KVK/KBO change notifications instead of periodic polling
  3. ML-based Name Matching - Improve company name matching with fuzzy search and ML models
  4. Blockchain Identity - Integrate with W3C Decentralized Identifiers (DIDs) for verified credentials
  5. API Gateway - Centralize external API calls through Azure API Management for rate limiting, caching, monitoring

  • CTN Member Onboarding Workflow - Business vetting and verification process
  • ASR Database Schema — see database/asr_dev.sql in the implementation repository (out of scope for arc42).

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]
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]
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 registeredAs for 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

Connected Trade NetworkConclusionData in LogisticsContargoInland Terminals GroupVan Berkel