# Belgian Ondernemingsnummer Scraper — KBO, BTW & Contacts (`scrapersdelight/be-ondernemingsnummer-website-contact-scraper`) Actor

Turn Belgian company domains into KYB-grade B2B leads from each site's statutory legal notice (WER Boek XII): ondernemingsnummer checked against its mod-97 checksum, BTW number, RPR court, legal form, address, email and phone. Dutch, French and German pages. $3.50/1,000. No login.

- **URL**: https://apify.com/scrapersdelight/be-ondernemingsnummer-website-contact-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Lead generation, Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$3.50 / 1,000 per belgian disclosure lead returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## 🇧🇪 Belgian Ondernemingsnummer Scraper — leads with a mod-97-checked KBO number

Paste a list of Belgian business domains (or point this Actor at another Actor's dataset) and get back **one clean B2B/KYB lead per domain**, read off each site's statutory legal notice: the company name and legal form, the **ondernemingsnummer / numéro d'entreprise checked against its mod-97 checksum**, the **BTW/TVA number**, the **RPR/RPM court**, the *verantwoordelijke uitgever*, a contact email, phone and a split postal address — for **$3.50 per 1,000 leads**.

**Why a legal notice and not a directory?** Because **Boek XII, art. XII.6 §1** of the *Wetboek van economisch recht* (Code de droit économique) obliges every information-society service provider to make its **name (1°)**, **geographic address (2°)**, **contact details including an email address (3°)**, its **ondernemingsnummer (4°)** and its **BTW-identificatienummer (7°)** *easily, directly and permanently accessible*. The corpus is therefore *the whole Belgian commercial web* — not one directory's member list — and every field is a disclosure the company published about itself, by law.

***

### ✅ What does it do?

For each domain you give it, plain HTTP hops. **No browser, no login, no API key.**

1. **Discovery.** Fetch the homepage and **rank every `<a href>`** on both its href *and* its anchor text, in **four languages at once** — exact "Wettelijke vermeldingen" / "Mentions légales" / "Impressum" (100) beats a loose text match (90) beats an `href` match (88) beats "Algemene voorwaarden" / "Conditions générales" / "AGB" (50) beats a contact page (26). If nothing is linked, fall through to the **homepage's own footer**, the **XML sitemap**, the **WordPress REST page index**, then **19 conventional paths**.
2. **Resolution.** Fetch the winning candidate, **gate it on a statutory content marker** — a bare HTTP 200 is not enough — then read the fields out of the page **text** with anchored, case-sensitive patterns.

#### The five things a generic EU legal-notice extractor gets wrong in Belgium

- 🧮 **It does not check the number.** A Belgian legal notice is full of nine- and ten-digit strings: licence numbers, ITAA and BIV accreditations, IBANs, phone numbers written with dots. The **ondernemingsnummer is the one that satisfies `last two digits = 97 − (first 8 digits mod 97)`**, and the BTW number is `BE` + exactly that number — so each is an exact algebraic container for the other. This Actor resolves the number from the most trustworthy statement on the page, emits `enterpriseNumberValid` so you can *see* the checksum result rather than trust it, and **flags a published typo instead of dropping the row**. Measured: **262 of the 264 numbers found passed**; the 2 failures are real typos on real sites.
- 🗣 **Belgium has three official languages, and one site may use any of them.** A Dutch-only label list under-collects on Walloon, Brussels and Eupen sites, and the failure looks exactly like *sparse data* rather than a bug. Every label here — page titles, field labels, legal forms, street types, director roles, third-party credit blocks — lives in a **config block with `nl`, `fr`, `de` and `en` variants**, and every row carries the `language` it was read in so you can audit the split yourself. Measured across 330 real pages: **53% nl · 34% fr · 9% de · 5% en**.
- 🌐 **`.be` is not the filter.** Belgian companies trade on `.com` and `.eu` constantly. Of the 734 domains probed, **165 (22%) were not `.be`**, and **74 of the 329 delivered leads (23%) came from them**. There is no TLD filter by default.
- 📞 **An ondernemingsnummer beginning `04` is character-for-character a Belgian mobile number.** Nothing in the digits tells them apart. The first live run of this Actor emitted `+32466792704` as a third phone number for a travel agency — it was the agency's own KBO number. A registry id the page published is never also delivered as a phone.
- 🏢 **The hosting credit and the web agency's credit are other companies' identity statements.** Left in place they ship as the lead's own address, email and enterprise number. Both blocks are located and removed before any owner field is read — and a one-line credit ("Webhosting door lexxweb.be") is cut at the end of that line, not by a fixed 400-character window, because a fixed window swallowed one company's entire contact block including its KBO number.

***

### 📊 Measured, from real runs

Everything below is from actual runs, not estimates.

#### End-to-end over 734 real Belgian business domains

Sourced from OpenStreetMap (`shop` / `office` entries carrying a `website` tag) across the whole of Belgium and sampled across the three language regions — a realistic buyer list, not a hand-picked one. Apify **datacenter** proxy, concurrency 20.

| Outcome | Domains | Share | Billed? |
|---|---:|---:|:--:|
| ✅ a parsed lead | **329** | **45%** | **yes** |
| ⚪ reachable, publishes no findable disclosure | 284 | 39% | no |
| 💀 `unreachable` — dead host (NXDOMAIN / TLS / refused) | 69 | 9% | no |
| 🔴 `blocked` by an anti-bot wall | 52 | 7% | no |

**613 of the 734 domains were reachable; 329 of those 613 (54%) published a findable statutory disclosure.**

How the disclosure was found: **ranked footer anchors 214 · the homepage's own footer 97 · guessed paths 18.** That middle number is the Belgium-specific one — nearly **three in ten** Belgian leads come from the identity block printed in the homepage footer, at **zero extra requests**, because the homepage is already in hand.

#### Per-field fill, over 330 delivered leads — and split by the language the disclosure is written in

Counted against the raw captured bytes, not against the parser's own optimism.

| Field | All | 🇳🇱 nl (174) | 🇫🇷 fr (111) | 🇩🇪 de (30) | 🇬🇧 en (15) |
|---|---:|---:|---:|---:|---:|
| `companyName` | **97%** | 97% | 98% | 93% | 100% |
| `phone` | **91%** | 94% | 89% | 93% | 67% |
| `addressLine` / `postalCode` / `city` | **88%** | 90% | 86% | 97% | 60% |
| `email` | **85%** | 87% | 82% | 93% | 60% |
| `enterpriseNumber` / `vatNumber` | **80%** | 85% | 79% | 60% | 67% |
| `legalForm` | 37% | 36% | 41% | 43% | 13% |
| `registerCity` (RPR/RPM) | 12% | 17% | 8% | 0% | 7% |
| `iban` | 8% | 7% | 10% | 0% | 7% |
| `hostingProvider` | 7% | 1% | 16% | 13% | 0% |
| `director` | 5% | 1% | 7% | 17% | 0% |
| `responsiblePublisher` | 5% | 1% | 2% | **40%** | 0% |
| `registerNumber` | 2% | 3% | 3% | 0% | 0% |

**Any registry id: 80% · any contact (email or phone): 94% · postal address: 88%.**
Median row carries **8** of the 15 value fields (p90 = 10, best = 12).

The number was resolved from the **BTW/TVA statement on 129 pages**, from an explicit *ondernemingsnummer* label on **66**, from a bare `BE…` token on **62** and from an unlabelled checksum-valid token on **7**. The cross-field guards fired for real: **21 rows carried a second, different valid enterprise number** (surfaced in `enterpriseNumberConflict` rather than silently picked) and **2 RPR numbers disagreed with the company's own** and were withheld.

**The per-language split is published because it is the honest way to show where a tri-lingual parser is weaker.** German-language pages fill *worse* on the enterprise number (60% vs 85% Dutch) and *far better* on `responsiblePublisher` (40% vs 1%) — the German-speaking Community's sites follow a German *Impressum* habit, which names a *Verantwortlicher Herausgeber* but states the company number less often.

#### Offline validation

The parser is re-run over all **330 pages captured live through the shipping proxy**, with **9,915 assertions** comparing each advertised field against independent evidence in the raw bytes — including the check that runs the *other* way: for every row with no enterprise number, the bytes must contain no checksum-valid one the parser missed. **0 failures.** That check is what caught a German-language Eupen site printing its number as `BE 07 0060 9422` — a grouping no 4-3-3 pattern matches.

#### Transport

got-scraping over the Apify **datacenter** proxy; **Chromium is never launched**, and the image is 512 MB. 3,447 requests for 734 domains ≈ 4.7 per domain. 52 domains were refused; residential-BE was tried on 51 of them and recovered 17. The 69 dead hosts reproduced identically on datacenter and residential — stale directory entries, not blocks, and never reported as blocked.

Per-domain latency on delivered rows: **p50 7.2 s, p90 33.4 s, max 142.8 s** — which is why `perDomainTimeoutSecs` exists and defaults to 60.

***

### ⚠️ Honest limits — read these before you buy

- **About four in ten reachable Belgian SME sites publish no findable disclosure.** That is not a parser gap: those domains were re-probed through ranked anchors in four languages, the homepage footer, the sitemap, the WordPress REST index and 19 guessed paths. They carry no ondernemingsnummer, BTW number, RPR entry or registered address anywhere reachable. **You are never charged for them.**
- **`director` fills at only 5%, and that is Belgian law, not a bug.** WER art. XII.6 §1 requires the *company's* identity — it does **not** require naming an officer, the way the German §5 DDG requires the *Geschäftsführer*. If you need officers, this is not the product; the KBO/BCE register is.
- **`registerNumber` fills at 2% and that is expected.** Belgian sites write "RPR Gent" without repeating the number, because by law the RPR registration number **is** the ondernemingsnummer — which is why this Actor reports the court in `registerCity` and does not fabricate the number into `registerNumber`. Join on `enterpriseNumber` instead.
- **This Actor does not confirm the number against the KBO/BCE register.** It reports what the company published, plus an offline checksum. A registry cross-check is a different data source and a different job.
- **A non-Belgian operator on a Belgian domain comes back honestly empty.** Cross-border retailers in the corpus publish a Dutch `KvK-nummer` or a German `Handelsregister` entry instead; no Belgian number is invented for them, and `enterpriseNumber` is simply `null`.
- **Stale lists have dead hosts.** 69 of 734 domains (9%) did not resolve at all. Never charged.
- **`robots.txt` is exposed as a switch and is off by default.** These pages exist to be indexed — a legal notice a search engine cannot read does not meet the statute's "easily and directly accessible" test.

***

### 🧾 Output

One row per domain. `flattenOutput` (default **on**) gives flat columns for Sheets / Clay; turn it off for nested `registry{} / people{} / contact{} / hosting{} / policies{}` objects.

| Field | Description |
|---|---|
| `domain` | Registrable domain (eTLD+1, `www`-stripped) — the fallback stable id. |
| `disclosureUrl` | The exact page the data came from. |
| `discoveryChannel` | `anchor` · `homepage` · `sitemap` · `wpjson` · `guess` · `input` — how it was found. |
| `language` | `nl` · `fr` · `de` · `en` — the language the disclosure is written in. |
| `companyName` | The legal entity name. |
| `legalForm` | `BV` · `NV` · `CV` · `VOF` · `CommV` · `VZW` · `SRL` · `SA` · `SC` · `ASBL` · `GmbH` · `AG` · `VoG` and the pre-2019 forms (`BVBA`, `CVBA`, `SPRL`, `SCRL`). Matched **case-sensitively and only inside the identity block**, because "1 KG KOFFIE", "Publier mon CV" and "CV en Verwarming" are not legal forms. |
| `enterpriseNumber` | The 10-digit KBO/BCE number, normalised (`BE-0446.185.845` → `0446185845`). The pre-2008 nine-digit VAT form is normalised by restoring its leading zero. |
| `enterpriseNumberFormatted` | The same number as Belgium prints it: `0446.185.845`. |
| `enterpriseNumberValid` | **The mod-97 checksum result.** `false` means the page carries a typo — flagged, never silently dropped. |
| `enterpriseNumberSource` | `label` · `vat` · `be-prefix` · `bare` · `rpr` — which statement it came from. |
| `enterpriseNumberConflict` | A second, disagreeing valid number found on the page, surfaced instead of hidden. |
| `vatNumber` | The BTW/TVA form, `BE` + the enterprise number (`BE0446185845`). |
| `vatNumberPublished` | Did the page actually present it **as** a VAT number? An entity can hold an enterprise number without being VAT-registered, so "no VAT number published" and "not VAT-registered" stay different facts. |
| `registerCity` / `registerNumber` | The RPR / RPM court, and the registration number **only when the page states one that agrees with the enterprise number**. |
| `registerConflict` | An RPR number that disagreed — kept visible, not published as the company's. |
| `responsiblePublisher` | *Verantwoordelijke uitgever* / *éditeur responsable* / *Verantwortlicher Herausgeber* — the distinctly Belgian named-person field. |
| `director` | Zaakvoerder · bestuurder · gérant · administrateur · Geschäftsführer. |
| `dpoContact` | Functionaris voor gegevensbescherming / DPO, for GDPR and KYB buyers. |
| `addressLine` / `street` / `postalCode` / `city` | Registered address, parsed as a block and **split**, handling both Belgian orders (`Zuiderlaan 71` and `16 rue de la Loi`) and `bus`/`boîte` suffixes. |
| `addressConflict` | Set when the surviving address is byte-identical to the hosting provider's. |
| `email` / `emails[]` | Contact addresses. `mailto:` preferred, **Cloudflare `data-cfemail` obfuscation decoded**, `(at)` / `(punt)` forms decoded. |
| `emailConflict` | The primary email's domain when it is neither the company's own nor a free mailbox. |
| `phone` / `phones[]` | Normalised to E.164 (`03/430.59.43` → `+3234305943`), with the `(0)` trunk placeholder in `+32 (0)…` removed. |
| `iban` | The company's Belgian IBAN, **validated against the ISO 13616 mod-97 check**. |
| `socialLinks[]` | LinkedIn / Facebook / Instagram / X / YouTube / TikTok profiles on the page. |
| `hostingProvider` / `hostingProviderAddress` | The host — Combell, One.com, OVH, Wix, Shopify, Cloudflare… A resellable technographic signal, and the guard that keeps the host's own data out of the lead's fields. |
| `termsUrl` / `privacyPolicyUrl` | The site's algemene voorwaarden / conditions générales / AGB and privacy policy. |
| `stableId` | The checksum-valid enterprise number when present, else the registrable domain. **The dedupe key.** |
| `fieldCount` · `status` · `missReason` · `fetchedAt` · `elapsedMs` | Row quality, outcome and provenance. |

Example row (`amigoreizen.be`, real output from a live run):

```json
{
  "domain": "amigoreizen.be",
  "disclosureUrl": "https://amigoreizen.be/algemene-voorwaarden/",
  "discoveryChannel": "anchor",
  "language": "nl",
  "companyName": "Amigo Reizen",
  "enterpriseNumber": "0466792704",
  "enterpriseNumberFormatted": "0466.792.704",
  "enterpriseNumberValid": true,
  "enterpriseNumberSource": "vat",
  "vatNumber": "BE0466792704",
  "vatNumberPublished": true,
  "addressLine": "Gasthuisstraat 11, 2340 Beerse",
  "street": "Gasthuisstraat 11", "postalCode": "2340", "city": "Beerse",
  "email": "info@amigoreizen.be",
  "emails": ["info@amigoreizen.be", "beerse@amigoreizen.be"],
  "phone": "+3233402858",
  "socialLinks": ["https://www.facebook.com/amigoreizen1", "https://www.instagram.com/amigoreizen/"],
  "stableId": "0466792704",
  "status": "ok"
}
```

***

### ⌨️ Input

Five ways to hand over a list, because agencies do not all work the same way.

| Field | What it is |
|---|---|
| **`domains`** | One entry per company. A bare domain (`aktum.be`), a homepage URL, **or a direct legal-notice URL** — a direct URL skips the homepage hop and costs one request. **Any TLD.** |
| **`startUrls`** | The standard Apify request-list editor, so Make / Zapier / Google Sheets can hand over a URL list natively. |
| **`sourceDatasetId`** + **`domainFieldName`** | Read the list straight out of another Actor's dataset — the real agency workflow: enrich a Maps or directory scrape. |
| **`domainsFileUrl`** | A CSV / TXT / JSON / JSONL file at a URL, for lists too big to paste. |
| **`skipDomains`** + **`previousDatasetId`** | Suppression. Point `previousDatasetId` at last month's run and this month's never re-buys a lead you already paid for. |

Then the knobs that matter:

| Field | Default | What it does |
|---|---|---|
| `maxItems` | **100** | Hard cap on rows **delivered and billed**. Raise it for a real list — at 54% yield a 1,000-domain list needs about 480. `0` = unlimited. |
| `maxDomains` | 0 | Cap on domains **attempted** — a different number, because yield is ~54%. |
| `discoveryChannels` | all 5 | A real cost lever. `homepage` costs nothing and won 29% of finds. |
| `maxDiscoveryRequestsPerDomain` | 10 | Ceiling on the guessed-path channel. |
| `perDomainTimeoutSecs` | 60 | Per-domain deadline, so one slow host cannot stall the run. |
| `requestConcurrency` | 12 | 1–30. |
| `proxyConfiguration` | Apify datacenter | The measured winner. Bring your own proxies if you prefer. |
| `proxyCountry` | none | **Note:** Apify datacenter proxies cannot be country-pinned (they answer HTTP 407), so choosing a country switches the run to residential exit nodes. |
| `escalateToResidentialOnBlock` | true | One residential-BE retry on a real 403 or challenge — never on a dead host, never on a 404. |
| `languageFilter` | all | `nl` / `fr` / `de` / `en` — for a campaign that only targets Flanders, Wallonia or the German-speaking Community. |
| `requireEnterpriseNumber` / `requireValidChecksum` / `requireContact` / `minFieldsRequired` | off | Quality floors. **A row filtered out here is never billed.** |
| `emailPolicy` | all | `role-only` (info@, contact@…) or `exclude-role` (named mailboxes only). |
| `dedupeBy` | number, else domain | Or `domain`, `enterprise-number`, or `none`. |
| `includeMissRows` | false | Emit an **unbilled** row with `status` + `missReason` for every dead / walled / notice-less domain. |
| `includeRawText` / `includeRawHtml` | false | Attach the source for audit or your own LLM re-parse. |
| `flattenOutput` | true | Flat columns vs nested objects. |
| `respectRobotsTxt` | false | Exposed as a switch. |

Example input:

```json
{
  "domains": ["aktum.be", "amigoreizen.be", "https://drukta.be/algemene-voorwaarden"],
  "maxItems": 1000,
  "requireValidChecksum": false,
  "includeMissRows": false
}
```

***

### 💰 Pricing

**Pay per event — one event, `be-disclosure-scraped`, at $0.0035 per delivered lead ($3.50 per 1,000).** No start fee.

Rows are delivered **and** billed atomically through the same call, so if you hit a charge cap you never keep a row you were not billed for, and never pay for a row you did not receive.

**You are not charged for:**

- a domain whose host is dead,
- a domain blocked by an anti-bot wall,
- a domain that publishes no statutory disclosure,
- a duplicate collapsed by `dedupeBy`,
- a row filtered out by `requireEnterpriseNumber` / `requireValidChecksum` / `requireContact` / `minFieldsRequired` / `languageFilter`,
- any `includeMissRows` coverage row.

| | Price per row |
|---|---:|
| Generic EU legal-notice extractors (no enterprise number, no checksum) | $0.002 – $0.0037 |
| **This Actor** | **$0.0035** |
| Our France *Mentions Légales* scraper (17 fields, median 11) | $0.004 |
| Our Spain *Aviso Legal* scraper | $0.005 |
| Our DACH *Impressum* scraper | $0.015 |

It sits a notch under the French sibling because Belgian law requires a **shorter** disclosure than the French LCEN or the German DDG — no share capital, no activity code, no register court, no obligation to name an officer — and the measured median row carries 8 of 15 value fields against France's 11 of 17.

At 54% yield, a **1,000-domain list** costs roughly **$1.70** in event fees.

***

### ❓ FAQ

**What is an "ondernemingsnummer"?**
The 10-digit identifier every Belgian enterprise receives in the *Kruispuntbank van Ondernemingen* (KBO) / *Banque-Carrefour des Entreprises* (BCE). Since 2008 it is also the VAT number: `BE` + the same ten digits. It is the key you use to look the company up in the official register.

**How do I know the number is real?**
Every number is checked against its mod-97 checksum — the last two digits equal 97 − (first 8 digits mod 97) — and the result ships in `enterpriseNumberValid`. Across 330 real pages, 262 of 264 passed; the 2 failures are typos the sites published, and they are flagged, not hidden.

**Is this legal to scrape?**
These pages are **statutory public disclosures** that Belgian law (WER Boek XII, art. XII.6 §1) requires to be easily, directly and permanently accessible — they exist precisely to be read. You remain responsible for each site's Terms of Service, and for handling any personal data in the output (a named *verantwoordelijke uitgever*, a personal email) under the GDPR.

**Does it work on French- and German-language Belgian sites?**
Yes — that is the point. Every label set is tri-lingual plus English, and each row reports the `language` it was read in. On the 330-page benchmark: 174 Dutch, 111 French, 30 German, 15 English.

**Why is `enterpriseNumber` 80% and not 100%?**
Because about one disclosure in five names a company and an address but no registry id — commonly sole traders and associations, and non-Belgian operators. Turn on **`requireEnterpriseNumber`** (or **`requireValidChecksum`**) and those rows are neither delivered nor billed.

**Can I get only Flemish, only Walloon or only German-speaking companies?**
Set `languageFilter` to `["nl"]`, `["fr"]` or `["de"]`. Filtered rows are never billed.

**How is a duplicate handled?**
Two ways. A repeated *input line* is collapsed at queue time, before it costs a request. Two *different* domains that turn out to share one enterprise number are collapsed before `pushData` — so a duplicate can never double-charge.

**How do I re-run monthly without paying twice?**
Put the previous run's dataset id in `previousDatasetId`. Every `stableId`, `domain` and `enterpriseNumber` it contains is suppressed.

**Does it need a browser or a residential proxy?**
No. Datacenter HTTP reached 613 of 734 domains. Residential is a per-block escalation (tried on 51, recovered 17), not the norm. Chromium is never launched.

**Does it use the KBO/BCE Open Data or a paid API?**
No. It reports what the company itself published, plus an offline checksum. No external API calls, no keys, no rate limits.

**What happens if my list is entirely dead domains?**
The run exits **cleanly** with zero billed rows and a status message saying why. It only fails loudly when the transport itself is broken — 80%+ of domains refused with a challenge — because that is a proxy problem, not an empty market.

**Can I export to Google Sheets / Clay / HubSpot?**
Yes. Leave `flattenOutput` on and use Apify's CSV / Excel / JSON export or the dataset API.

***

### ⚖️ Legal

This Actor reads **statutory public disclosures** that Belgian law requires websites to publish, over ordinary HTTP requests to ordinary public pages. It does not log in, does not bypass any anti-abuse control, does not solve CAPTCHAs, and does not forge authentication. `robots.txt` compliance is available as an input switch.

Output may contain **personal data** — a named *verantwoordelijke uitgever*, a named zaakvoerder, a personal email address. Under the GDPR you are the controller of what you do with it: have a lawful basis, honour erasure requests, and respect each site's Terms of Service. Scraping and using this data is your responsibility, not Apify's and not this Actor's.

# Actor input Schema

## `domains` (type: `array`):

One entry per company. Accepts all three shapes: a bare domain ("aktum.be"), a homepage URL ("https://www.drukta.be") or a direct legal-notice URL ("https://x.be/wettelijke-vermeldingen"). ANY TLD works — 22% of the Belgian businesses measured trade on .com, .eu or .net, so a .be-only list loses about one lead in five. Leave empty to run the built-in Belgian demo batch.

## `startUrls` (type: `array`):

The same domain list handed over as URLs, so Make, Zapier, Clay or a Google Sheet can pass it natively (a link to a text/CSV file of URLs also works). Merged with "Belgian domains".

## `sourceDatasetId` (type: `string`):

Dataset ID of a previous Actor run. Each item's domain/website column is read and enriched — the real agency workflow: run a directory or Maps scraper first, then pipe its output here.

## `domainFieldName` (type: `string`):

Which field of the source dataset (or which CSV column of the list file) holds the domain. Leave empty to auto-detect domain / website / websiteUrl / url / site / homepage. Dotted paths like "company.website" work.

## `domainsFileUrl` (type: `string`):

URL of a CSV, TXT, JSON or JSONL file holding the domains — for lists too big to paste into the editor. The column is picked with "Domain field / CSV column".

## `skipDomains` (type: `array`):

Domains to skip outright — accounts you already own, competitors, do-not-contact entries. Matched on the registrable domain, so "www.x.be/page" and "x.be" are the same entry.

## `previousDatasetId` (type: `string`):

Dataset ID of an earlier run of THIS Actor. Its stableId / enterpriseNumber / domain values are loaded as a suppression list, so a monthly re-run never re-delivers — and never re-charges you for — a company you already bought.

## `maxItems` (type: `integer`):

Hard cap on rows DELIVERED AND BILLED this run. Distinct from "Max domains": about 54% of reachable Belgian sites publish a findable disclosure, so 1,000 domains yield roughly 480 billed rows. Raise it for a real list. 0 = unlimited.

## `maxDomains` (type: `integer`):

Cap on domains ATTEMPTED, applied before any request. Use it to sample a big list cheaply. 0 = attempt every domain supplied.

## `maxDiscoveryRequestsPerDomain` (type: `integer`):

Hard cap on discovery + confirmation requests per domain, counted after the homepage. This is the knob that stops one slow host burning two minutes of a run. Ranked footer anchors alone cost 1-2 requests; the full cascade can reach 20. Measured yield plateau: about 8.

## `perDomainTimeoutSecs` (type: `integer`):

Wall-clock deadline for one domain, discovery included. Measured latency on delivered rows: p50 7.2 s, p90 33.4 s, max 142.8 s — 60 s leaves headroom without letting a pathological host stall the pool.

## `requestConcurrency` (type: `integer`):

How many domains are worked in parallel. Higher is faster; keep it modest to stay polite to small business sites.

## `requestTimeoutSecs` (type: `integer`):

Timeout for a single HTTP request.

## `maxRequestRetries` (type: `integer`):

Retries per request, each on a FRESH proxy IP (got-scraping's own retry reuses the flagged IP, which is useless against a soft block).

## `discoveryChannels` (type: `array`):

Which channels may locate the statutory disclosure, tried in this order. "homepage" is the Belgium-specific one: the identity block is often printed in the homepage footer itself, and it won 97 of 329 finds in recon at ZERO extra requests. Ranked anchors won 214. Turning off the guessed paths is a real cost lever on a big list.

## `followWwwAndRootVariants` (type: `boolean`):

If the homepage fails, retry the other host form (www.x.be ↔ x.be) before declaring the domain unreachable.

## `deepJsDiscovery` (type: `boolean`):

Last-resort channel for a footer that only exists after client-side render. No Chromium is launched — this build is deliberately browser-free, because a browser image would multiply the compute cost of every run for a case recon measured as rare. Instead the Actor reads what a render would have read FROM: the page's inline JSON payloads and its external JavaScript bundles. Costs up to 6 extra requests per domain and is never charged separately.

## `respectRobotsTxt` (type: `boolean`):

Fetch and honour each site's robots.txt before requesting anything. Off by default because it costs one extra request per domain and these pages exist to be indexed — a legal notice a search engine cannot read does not meet the WER's "easily and directly accessible" test.

## `proxyConfiguration` (type: `object`):

Proxy settings. Apify DATACENTER is the default and is the measured winner on the Belgian SME corpus: 613 of 734 domains reachable, 52 blocked, and residential recovered only 17 of the 51 it was tried on.

## `proxyCountry` (type: `string`):

Pin the exit country for sites that geo-tailor their content. MEASURED: Apify datacenter proxies cannot be pinned to a country (they answer HTTP 407), so choosing a country switches the run to RESIDENTIAL exit nodes in that country. Leave on None for the cheaper, faster datacenter path the Belgian SME corpus does not need a country for.

## `escalateToResidentialOnBlock` (type: `boolean`):

On a 403 / challenge (never on a dead host, never on a 404), retry the request once on RESIDENTIAL + country-BE. Measured: tried on 51 domains, recovered 17.

## `customUserAgent` (type: `string`):

Override the browser User-Agent sent on every request. Leave empty for the built-in Chrome 124 fingerprint.

## `extraHttpHeaders` (type: `object`):

Additional request headers, merged over the defaults (e.g. a From: header identifying your crawler).

## `tldFilterMode` (type: `string`):

Belgian companies commonly trade on .com and .eu, so there is no TLD filter by default. Turn one on only if your list mixes countries.

## `tldFilter` (type: `array`):

The TLD list the mode above applies to, without the dot: be, com, eu, brussels, vlaanderen, gent.

## `languageFilter` (type: `array`):

Deliver (and bill) only rows whose disclosure is written in one of these languages — for a campaign that only targets Flanders, Wallonia or the German-speaking Community. Measured split across 330 real pages: 53% nl, 34% fr, 9% de, 5% en. Leave empty for all.

## `requireEnterpriseNumber` (type: `boolean`):

Deliver (and bill) only companies whose disclosure carries an ondernemingsnummer / numéro d'entreprise. Measured: 80% of parsed pages do.

## `requireValidChecksum` (type: `boolean`):

Stricter still: the number must also pass its mod-97 checksum, so what you buy can be looked up in the KBO/BCE. Measured: 262 of the 264 numbers found were valid; the 2 failures are published typos, flagged rather than dropped when this is off.

## `requireContact` (type: `boolean`):

Deliver (and bill) only companies with an email or a phone number. Measured: 94% of parsed pages carry at least one.

## `minFieldsRequired` (type: `integer`):

Quality floor: a row must carry at least this many of the 15 value fields (company, legal form, enterprise number, VAT, RPR city + number, responsible publisher, director, address, postcode, city, email, phone, IBAN, host) before it is delivered and billed. Measured median is 8. 0 = no floor.

## `emailPolicy` (type: `string`):

Agencies split hard on whether info@ / contact@ counts as a lead. "Role only" keeps just those; "Exclude role" keeps only named mailboxes.

## `dedupeBy` (type: `string`):

Which key collapses duplicates BEFORE anything is pushed or charged. The default uses a checksum-valid ondernemingsnummer when the page carries one and the registrable domain otherwise, so two domains owned by the same company collapse to one billed row — and a repeated input line is dropped at queue time, before it costs a request. "No deduplication" genuinely means none: you get, and pay for, one row per input line.

## `validateChecksum` (type: `boolean`):

Verify every ondernemingsnummer against its mod-97 checksum — the last two digits equal 97 − (first 8 digits mod 97) — and emit enterpriseNumberValid. A failure is FLAGGED, never dropped, because a published typo is a real thing a buyer needs to see.

## `extractHostingProvider` (type: `boolean`):

The site's host (Combell, One.com, OVH, Wix, Shopify, Cloudflare…) plus its address — a technographic signal hosting resellers and agencies buy on its own. Also what keeps the host's OWN address and email out of the lead's fields.

## `extractDpo` (type: `boolean`):

Functionaris voor gegevensbescherming / délégué à la protection des données / Datenschutzbeauftragter, for GDPR and KYB buyers.

## `extractSocials` (type: `boolean`):

LinkedIn, Facebook, Instagram, X, YouTube and TikTok links present on the disclosure page.

## `extractPolicyUrls` (type: `boolean`):

The site's algemene voorwaarden / conditions générales / AGB and its privacy-policy URL, linked from the disclosure.

## `includeRawText` (type: `boolean`):

Attach the parsed page as plain text (capped at 40,000 characters) so you can audit a field or re-parse it with your own LLM.

## `includeRawHtml` (type: `boolean`):

Attach the disclosure page's raw HTML (capped at 200,000 characters). Makes the dataset much larger.

## `includeMissRows` (type: `boolean`):

Emit a row for every domain that produced no lead — dead host, blocked, publishes no disclosure, filtered out, a duplicate, or suppressed — with its status and missReason, so you can do coverage accounting. These rows are NEVER charged.

## `flattenOutput` (type: `boolean`):

On: one flat row, ready for Google Sheets, Clay or a CSV export. Off: fields grouped into registry {}, people {}, contact {}, hosting {} and policies {} objects.

## Actor input object example

```json
{
  "domains": [
    "aktum.be",
    "alofskuse.be",
    "amigoreizen.be",
    "drukta.be",
    "deverol.be"
  ],
  "maxItems": 100,
  "maxDomains": 0,
  "maxDiscoveryRequestsPerDomain": 10,
  "perDomainTimeoutSecs": 60,
  "requestConcurrency": 12,
  "requestTimeoutSecs": 25,
  "maxRequestRetries": 2,
  "discoveryChannels": [
    "anchor",
    "homepage",
    "sitemap",
    "wpJson",
    "pathGuess"
  ],
  "followWwwAndRootVariants": true,
  "deepJsDiscovery": false,
  "respectRobotsTxt": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "proxyCountry": "none",
  "escalateToResidentialOnBlock": true,
  "tldFilterMode": "none",
  "languageFilter": [],
  "requireEnterpriseNumber": false,
  "requireValidChecksum": false,
  "requireContact": false,
  "minFieldsRequired": 0,
  "emailPolicy": "all",
  "dedupeBy": "enterprise-then-domain",
  "validateChecksum": true,
  "extractHostingProvider": true,
  "extractDpo": true,
  "extractSocials": true,
  "extractPolicyUrls": true,
  "includeRawText": false,
  "includeRawHtml": false,
  "includeMissRows": false,
  "flattenOutput": true
}
```

# Actor output Schema

## `records` (type: `string`):

The dataset of parsed Belgian statutory disclosures — one item per domain that published one.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "domains": [
        "aktum.be",
        "alofskuse.be",
        "amigoreizen.be",
        "drukta.be",
        "deverol.be"
    ],
    "maxItems": 100,
    "proxyConfiguration": {
        "useApifyProxy": true
    }
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/be-ondernemingsnummer-website-contact-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "domains": [
        "aktum.be",
        "alofskuse.be",
        "amigoreizen.be",
        "drukta.be",
        "deverol.be",
    ],
    "maxItems": 100,
    "proxyConfiguration": { "useApifyProxy": True },
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/be-ondernemingsnummer-website-contact-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "domains": [
    "aktum.be",
    "alofskuse.be",
    "amigoreizen.be",
    "drukta.be",
    "deverol.be"
  ],
  "maxItems": 100,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}' |
apify call scrapersdelight/be-ondernemingsnummer-website-contact-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/be-ondernemingsnummer-website-contact-scraper"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/CSbGT30dvILsscEy4/builds/8pFbi7FlezJqEgkJe/openapi.json
