DEV Community

Your Evenings Data
Your Evenings Data

Posted on

KYB checks across six EU company registers from one Python script (FR, NO, FI, CZ, IE, PL)

If you onboard European suppliers or customers, "does this company exist, is it still active, and is that its registered address?" means six different registers with six different APIs, ID formats and status vocabularies. This post builds a small KYB check that takes a list of register IDs and flags anything that needs a human. Then it turns the same check into a daily watch for liquidations.

Disclosure: the hosted API used here is ours, and it's paid per record. The free official route for each country is noted as we go, so you can build it yourself if you'd rather.

The input: country-prefixed register IDs

Each country has its own ID: a SIREN in France, an organisation number in Norway, a Business ID in Finland, an IČO in Czechia, a CRO number in Ireland and a KRS number in Poland. Keep them as CC:ID strings. That's the only format decision you need:

COUNTERPARTIES = [
    "FR:552032534",   # SIREN, 9 digits
    "NO:923609016",   # organisasjonsnummer, 9 digits
    "FI:0112038-9",   # Y-tunnus
    "CZ:00177041",    # IČO, 8 digits
    "IE:104547",      # CRO number
    "PL:0000028860",  # KRS, 10 digits
]
Enter fullscreen mode Exit fullscreen mode

One call, one schema

The Actor's lookup mode takes up to 500 of these per run. Calling it through the Apify API's run-sync endpoint returns the dataset rows directly:

import os, requests

ACTOR = "yourevenings~eu-company-registers"
URL = f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items"

def lookup(ids):
    r = requests.post(
        URL,
        params={"token": os.environ["APIFY_TOKEN"]},
        json={"mode": "lookup", "companies": ids, "maxResults": 0},
        timeout=310,  # run-sync waits up to 300 s; use async runs for big batches
    )
    r.raise_for_status()
    return r.json()
Enter fullscreen mode Exit fullscreen mode

Every company row has the same fields whichever register it came from: country, registerId, name, legalForm, status, registeredDate, closedDate, address{street, postalCode, city, country, full}, activityCode, vatId, website, recordUrl (the register's own page) plus licence and attribution. status is normalised to active, inactive, in_liquidation, bankrupt or unknown. Where the register gives its own status wording, it's kept in statusDetail (Ireland's "Normal", for example); otherwise statusDetail is null.

For the six IDs above, a run on 30 October 2026 returned:

Country ID Name Status City
FR 552032534 DANONE active PARIS
NO 923609016 EQUINOR ASA active STAVANGER
FI 0112038-9 Nokia Oyj active ESPOO
CZ 00177041 Škoda Auto a.s. active Mladá Boleslav
IE 104547 RYANAIR DESIGNATED ACTIVITY COMPANY active — (see below)
PL 0000028860 ORLEN SPÓŁKA AKCYJNA active PŁOCK

Ireland's CRO data gives the address as a single line, so address.city is null there and the whole address is in address.full ("RYANAIR DUBLIN OFFICE, AIRSIDE BUSINESS PARK, SWORDS CO. DUBLIN, SWORDS,DUBLIN"). Match on address.full if you compare addresses across countries.

The part that matters for KYB: rows that aren't companies

An empty result is the dangerous case in a KYB check. You can't tell "not registered" from "typo" or "register was down". So the API never returns nothing silently. Each problem comes back as a free status row with rowType: "status", a status code and a plain-English message:

  • invalid_id: the ID doesn't have the right shape for that register. Usually a typo in your own data.
  • not_found: well-formed, but not in the register.
  • source_error: the register was unreachable after retries. The other countries still run.
  • skipped_individual: a sole trader or natural person. These are skipped, not returned.

So the triage is a small function:

def triage(rows, expected):
    out = {"ok": [], "review": []}
    for row in rows:
        if row["rowType"] == "status":
            out["review"].append((row.get("input") or row.get("country"), f'{row["status"]}: {row["message"]}'))
        elif row["status"] != "active":
            out["review"].append((row["registerId"], f'{row["name"]}: {row["status"]} ({row.get("statusDetail")})'))
        elif expected.get(row["registerId"]) and row.get("vatId") and row["vatId"] != expected[row["registerId"]]:
            out["review"].append((row["registerId"], f'VAT on file {expected[row["registerId"]]} != register {row["vatId"]}'))
        else:
            out["ok"].append(row["registerId"])
    return out
Enter fullscreen mode Exit fullscreen mode

expected is whatever your CRM holds, for example {"923609016": "NO923609016MVA"}. vatId is only filled where the register publishes one. It's null otherwise, so treat a null as "can't check here", not as a mismatch.

Turn it into a daily liquidation watch

A one-off check goes stale. Set monitorMode: true and schedule the same input daily. The Actor stores each company's name, legal form, status, closure date, address, activity code, website and VAT number, and from then on returns only companies whose watched fields changed:

{
  "mode": "lookup",
  "companies": ["FR:552032534", "NO:923609016", "FI:0112038-9"],
  "monitorMode": true,
  "emitBaselineOnFirstRun": false,
  "stateStoreName": "kyb-suppliers"
}
Enter fullscreen mode Exit fullscreen mode

Changed rows carry changeType: "updated", changedFields (e.g. ["status"]) and previousValues, so an active → in_liquidation move is one row you can route to a webhook or Slack. With emitBaselineOnFirstRun: false the first run just records the baseline, and runs where nothing changed return no company rows.

New registrations (NO, FI, IE)

Norway, Finland and Ireland expose registration dates, so the same API can list companies registered in a window. It's useful for market research, or for checking that a "new" counterparty really is new:

{ "mode": "newCompanies", "countries": ["NO", "FI", "IE"], "registeredWithinDays": 7, "activeOnly": true, "maxResultsPerCountry": 100 }
Enter fullscreen mode Exit fullscreen mode

If you only need one of those countries, the free official APIs are a single call. Brønnøysund's data.brreg.no/enhetsregisteret/api/enheter?fraRegistreringsdatoEnhetsregisteret=2026-10-20 and PRH's avoindata.prh.fi/opendata-ytj-api/v3/companies?registrationDateStart=2026-10-20 both work with no key. The wrapper is worth it when you need several countries in one schema.

Honest limits

  • Company-level data only. No directors, owners, phone numbers or emails. If your KYB needs beneficial owners, that's a separate source.
  • Six countries. Poland is lookup only (the KRS API has no name search). newCompanies is Norway, Finland and Ireland only.
  • Registers page differently. ARES (CZ) returns at most 1,000 matches, and broad searches elsewhere hit a 10,000 cap. Over-broad searches return too_many_results rather than a partial list.
  • Freshness. Each run reads the registers live, but Ireland's open dataset is refreshed on the CRO's schedule, so very recent changes can show there later than on its website.
  • Marketing use. French records whose owner opted out of commercial prospecting carry extra.optedOutOfCommercialProspecting: true. Respect it.
  • Pacing. Requests are rate-limited per register (1–4 a second), so a 500-ID batch can take several minutes, longer than run-sync's 300-second wait. Start an async run and read the dataset when it finishes.
  • Cost. $0.003 per company row and $0.00005 per run start. Monitor mode adds $0.01 per run that finds a change. Status rows and quiet monitor runs are free. Higher Apify plans get lower prices.

The Actor, with input examples you can run from the browser: EU Company Registers API on Apify

Top comments (2)

Collapse
 
florian131313 profile image
G •

The one-off path puts source_error in the review list, so a down register stays visible. The daily watch describes a no-change run as an empty set of company rows. If monitor mode omits that status row, an outage and a quiet day land in the same empty dataset.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Ireland's one long address line is a nice trap.