# Germany Transport Operators - VUDat Permits & Fleet (`scrapersdelight/vudat-transport-permits-scraper`) Actor

From $2.00 per 1,000 operators, no start fee. Germany's official BALM/VUDat licence register: ~57,645 haulage and bus operators with address, legal form, trade-register number, named legal rep and transport manager, vehicle counts and permit numbers. No email or phone - the register publishes none.

- **URL**: https://apify.com/scrapersdelight/vudat-transport-permits-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

$2.00 / 1,000 per operator row 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

## 🇩🇪 Germany Transport Operators — VUDat Permits & Fleet

**Every licensed road-transport operator in Germany, from the federal register itself — with the
fleet size and the name of the person legally responsible for it.**

VUDat (*Verkehrsunternehmensdatei*) is the register the **Bundesamt für Logistik und Mobilität
(BALM)** is required to publish under EU Regulation 1071/2009. A company cannot legally move
freight for hire or carry passengers in Germany without an entry in it. That makes it a census, not
a directory: **~57,645 operators**, every one of them licensed, each with a postal address, a legal
form, a named legal representative, a named transport manager, a vehicle count by class, and every
permit it holds with the number, the validity window and the authority that issued it.

***

### ⚠️ Read this before you buy: there is **no email, no phone and no website**

Counted, not assumed. Over a seeded random sample of **120 records** taken on 2026-09-16 and
re-counted by `offline_validate.mjs` on every run:

| | found in 120 record bodies |
|---|---|
| email addresses | **0** |
| `tel:` / `mailto:` links | **0** |
| company websites | **0** (the only URLs in a record are `schema.org` / `w3.org` boilerplate) |

This is structural, not a parser miss — a statutory permit register publishes who is licensed, not
how to ring them. There is therefore **no `email`, `phone` or `website` column in the output at
all**, and none in the schema. An actor that took "the first URL in the record" as a website would
ship a 100% filled website column made entirely of XML namespace declarations; that mistake is
specifically guarded against in the validator.

**What you get instead is the hard part of a lead: an exhaustive, legally-authoritative list of
qualified companies with the decision-maker's name and the fleet size.** Join `companyName` +
`postalCode` + `legalRepresentative` against a contact source to reach them. If you need the
contact channel in the same row, this is not that product.

***

### 📊 The numbers, measured on 2026-09-16

Everything below was counted against the live register on the day this Actor was built.

| | |
|---|---|
| Operators addressable | **57,645** (ids 1–57,645 populated; 57,646 and up are empty) |
| Holes inside that range | **none observed** — 125 distinct ids inside it came back populated 125/125 (a seeded random sample of 120, plus 5 probed by hand) |
| Page size | 47,655 / **48,055** / 50,481 bytes (min / median / max over 120 live fetches) |
| Anti-bot | **none** — no CAPTCHA, no Cloudflare, no JS gate; a bare request with no User-Agent gets a byte-identical page |
| Permits per operator | 127 permits across 120 operators; 7 operators hold more than one |
| Oldest permit seen | valid from **1990-02-19** |

The boundary was re-confirmed on the day of the build by a live run over ids 57,638–57,652: eight
populated, then seven empty, exactly at 57,645/57,646.

#### Field fill — counted over the 120-record random sample

`offline_validate.mjs` recounts this from the captured bytes every time it runs, so the table
cannot drift away from the code.

| Field | Fill | | Field | Fill |
|---|---|---|---|---|
| `companyName` | **100.0%** | | `trucks` / `buses` / vans | **100.0%** |
| `legalForm` | **100.0%** | | `totalVehicles` | **100.0%** |
| `postalCode` / `city` / `streetAddress` | **99.2%** | | `permitCount` / `permitTypes` | **100.0%** |
| `legalRepresentative` | **99.2%** | | `permitNumbers` | **97.5%** |
| `transportManager` | **95.0%** | | `issuingAuthorities` | **95.8%** |
| `tradeRegisterNumber` | 49.2% | | `hasCurrentPermit` | **100.0%** |
| `registerCourt` | 50.0% | | `firstPermitValidFrom` | **100.0%** |
| `branchCount` | 100% (0 for most) | | `lastPermitValidUntil` | 83.3% |

"Filled" excludes `""`, `null`, `"N/A"`, `"-"`, `"k.A."`, `"keine Angabe"` and a literal `"*"`.
Three of those four sub-100% figures are **true absences, not gaps**, and each one is explained:

- **`tradeRegisterNumber` 49.2%** — the other half are sole traders (`Einzelunternehmen`, 60 of 120
  in the sample) and natural persons, who have no Handelsregister entry at all. Set
  `requireTradeRegisterNumber` if you only want incorporated companies.
- **`lastPermitValidUntil` 83.3%** — 23 of 127 permits publish `unbefristet`, "open-ended". Those
  carry `isIndefinite: true` and `isCurrent: true`. A parser that read `unbefristet` as a missing
  date and then tested `validUntil >= today` would mark Germany's longest-standing licence holders
  as expired; this one does not.
- **`permitNumbers` 97.5%** — three operators carry a literal `*` where a legacy Land put a
  placeholder. That is nulled rather than shipped as a licence number.

#### What the register looks like (same 120 operators)

| Fleet (trucks) | operators | | Legal form | operators |
|---|---|---|---|---|
| 0 | 19 | | Einzelunternehmen (sole trader) | 60 |
| 1–2 | 45 | | GmbH / UG | 41 |
| 3–9 | 33 | | OHG/KG without a natural GP | 12 |
| 10–49 | 19 | | Eingetragener Kaufmann | 4 |
| 50+ | 4 (max **102**) | | AG / OHG / KG | 3 |

Mean 7.5 trucks. **34%** run 5 or more, **19%** run 10 or more, **90%** run at least one vehicle.
**115 of 120** are freight operators, **5** are passenger (bus). Postcode first digits are spread
4–18% each, so the register is genuinely national rather than clustered.

***

### 📦 What one row is

One row per operator. One charge per row.

| Field | What it is |
|---|---|
| `operatorId` / `sourceUrl` | The VUDat `idUnternehmen` and a link straight back to the register page |
| `companyName` | Name as filed |
| `legalForm` | `Einzelunternehmen`, `GmbH`, `Aktiengesellschaft`, `Kommanditgesellschaft`… |
| `tradeRegisterNumber` / `registerCourt` | `HRB 17280` / `Münster`. **Null for sole traders — a true absence.** |
| `streetAddress` / `postalCode` / `city` / `fullAddress` | Postal address. Null, never a mis-split postcode, when the register omits the block. |
| `legalRepresentative` + `legalRepresentatives[]` | **Named officer(s)**, as `Surname, Firstname` and split into `surname` / `firstName`. 17 of 120 operators name more than one. |
| `transportManager` + `transportManagers[]` | **The `Verkehrsleiter`** — the person who legally answers for the fleet, and usually the right person to talk to about it |
| `trucks` / `buses` / `smallVehicles2500to3500kg` / `totalVehicles` | Vehicle count by class, from the register's own `Fuhrpark` block. A `0` is a real zero; a missing block is `null`, never a fabricated 0. |
| `permits[]` | Every licence: `permitType`, `permitNumber`, `validFrom`, `validUntil`, `isIndefinite`, `issuingAuthority`, `isCurrent`, `transportMode` |
| `permitNumbers` / `permitTypes` / `issuingAuthorities` | Flattened for a spreadsheet. 91 distinct issuing authorities appeared in a 120-operator sample. |
| `transportModes` | `freight` and/or `passenger`, derived from the permit type |
| `hasCurrentPermit` / `hasIndefinitePermit` | `true` / `false` / **`null` when the register published no end date** |
| `firstPermitValidFrom` / `lastPermitValidUntil` | The operator's overall licence window |
| `branchCount` / `branches[]` | Registered branch establishments (`Niederlassungen`). Sparse and real: 5 of 120 operators list one or more. `0` means none registered, which is data. |
| `scrapedAt` | ISO timestamp of the fetch |

**Permit numbers are not one format.** Real values in the sample include `D-05-059-G-0177`,
`0219PAF`, `BY/041/schw/gz`, `281-E` and `159`. Everything here is extracted by **label**
(`Berechtigungsnummer`), never by pattern — a regex for the common shape scores about 75% fill and
silently drops every Land that numbers its permits differently.

***

### 🔎 How to choose which operators you get

VUDat publishes **no list, no search-by-place and no page cursor**. Its own search form makes
*both* a company name *and* a place mandatory and explicitly refuses wildcards, so it cannot
enumerate anything. The integer `idUnternehmen` is the only enumeration primitive there is.

That has one consequence you should know before you set a filter:

> **Every filter in this Actor is applied AFTER the page is fetched.** Rows you filter out are
> never delivered and **never charged** — but they are fetched, so a narrow filter over a wide id
> range spends time to deliver few rows. `RUN_SUMMARY` reports the
> `examinedPerDelivered` your filters actually produced.

- **`operatorIds`** — read exactly these ids. One page per id: the cheap, precise mode.
- **`startId` / `endId`** — walk a range. `endId: 0` walks to the end of the register and stops
  after 150 consecutive empty ids (no holes were observed anywhere inside the populated range, so
  150 is decisive).
- **`postalCodePrefixes`** — `["8"]` the south-east, `["80"]` Munich, `["80331"]` one postcode.
- **`transportModes`** / **`permitTypes`** — freight vs passenger; the register's three permit
  types, validated against the real list **before** the run starts so a typo cannot return an empty
  result that looks like a real answer.
- **`minTrucks` / `minTotalVehicles`** — a genuine fleet-size filter, because the register publishes
  the count. An operator whose fleet block is missing is excluded, never counted as zero.
- **`onlyCurrentPermits`**, **`requireTradeRegisterNumber`**, **`requireAddress`**,
  **`requireTransportManager`** — quality gates with measured costs (they remove 0%, 51%, 1% and 5%
  of the sample respectively).
- **`maxRecordsExamined` / `maxRunSeconds`** — budgets. Use them with a narrow filter.

***

### 💰 Billing

**$0.002 per operator row delivered. No run-start fee.** You are charged for delivered rows and
nothing else.

Nothing else is billable, and the three kinds of nothing are kept apart in `RUN_SUMMARY` so you can
see which happened:

1. **`notFound`** — the id answered HTTP 200 with an empty page. The register holds no operator
   there. Not a failure, not charged.
2. **`filteredOut`** — a real operator that did not match your filters, with a per-filter breakdown
   of which one removed it. Not charged.
3. **`unreachable`** — could not be read after retries on fresh proxy sessions. A transport failure,
   **not** evidence the operator does not exist. Not charged; re-run those ids with `operatorIds`.

A fourth counter, **`unparsable`**, means the page carried a record the parser did not recognise.
Non-zero there means BALM changed its template, and it is loud on purpose.

***

### ⚙️ Reliability, measured today

**The host throttles by dropping connections, not by returning an error.** Measured on 2026-09-16
from a single IP:

| what was run | result |
|---|---|
| 120 requests at **concurrency 10** | 67 succeeded, **53 connection timeouts**, then the host stopped answering that IP entirely for several minutes |
| the same 53 ids, **serial with a 2.5 s pause** | **53/53 succeeded** |
| live Actor runs at **concurrency 2** | 19 requests, 0 failures |

So `concurrency` defaults to **4** and is capped at 10, and Apify Proxy is on by default — the
point of the proxy here is **rotation, not evasion**, since there is no anti-bot to evade. If you
see the `unreachable` count climb, lower `concurrency` or switch the proxy to RESIDENTIAL.

Three failure modes this Actor refuses to paper over:

- **An empty id answers HTTP 200.** Existence is decided by page *content*, never by status code —
  a status-code check would report a 100% successful crawl over an entirely empty range.
- **A truncated download also answers HTTP 200,** and what survives is the chrome without the
  record — byte-identical in shape to an empty id. Every body is checked for completeness *before*
  it is parsed, and an incomplete one is retried on a fresh session rather than counted as "no such
  operator".
- **A walk that finds nothing at all fails the run.** If every examined id comes back empty, the
  endpoint moved (it already moved once, from `/SiteGlobals/Forms/vudat/` to
  `/DE/Service/EEREinzelansicht/`) — that is reported as a failure, not as a clean empty run.

***

### 📜 Source and robots

Data: **VUDat — Verkehrsunternehmensdatei**, published by the *Bundesamt für Logistik und Mobilität*
at `https://www.balm.bund.de/DE/Service/EEREinzelansicht/einzelansicht_node.html?idUnternehmen=…`,
a public register the agency is required to publish under EU Regulation 1071/2009. No login, no
cookie, no licence key.

`robots.txt`, verbatim as served on 2026-09-16:

```
User-agent: *
Disallow: /SiteGlobals/
Disallow: /EN/Service/
Allow: /SiteGlobals/Modules/
Allow: /SiteGlobals/StyleBundles/
Allow: /SiteGlobals/Frontend/
Crawl-delay: 30
Sitemap: https://www.balm.bund.de/Sitemap_Index.xml
```

The path this Actor reads, `/DE/Service/EEREinzelansicht/`, is **not** under any `Disallow` rule.
The `Crawl-delay: 30` directive is stated here in full so you can make your own call: at 30 seconds
per request a full pass of the register would take 20 days, and this Actor does not honour it.

***

### 🧪 Verifying this build yourself

```
node offline_validate.mjs
```

295 assertions, no network and no platform, run against **12 whole operator pages** captured from
balm.bund.de on 2026-09-16 (covering the edge cases: no address, no named representative, a
placeholder permit number, an open-ended permit, branch establishments, the last populated id) plus
the first **empty** id, the legacy **404** page, `robots.txt`, and the verbatim `<main>` slice of
the 120-record random sample the fill table above is counted over.

# Actor input Schema

## `startId` (type: `integer`):

First `idUnternehmen` to read. Ids run from 1; the highest populated id measured on 2026-09-16 was 57,645.

## `endId` (type: `integer`):

Last id to read. Leave at 0 to walk until the register ends — the walk stops after 150 consecutive ids that hold no record, which is decisive because no holes were observed anywhere inside the populated range.

## `maxOperators` (type: `integer`):

Stops once this many rows have been delivered. This is also your bill: you are charged per delivered row and for nothing else. 0 means no limit — the whole register is ~57,645 operators.

## `operatorIds` (type: `array`):

Read exactly these `idUnternehmen` values and nothing else, ignoring the range above. This is the cheap, precise mode: one page fetched per id. Example: 2816, 28701, 56000.

## `postalCodePrefixes` (type: `array`):

German postcode prefixes, 1-5 digits, matched against the operator's postcode: "8" = the south-east eighth of Germany, "80" = Munich area, "80331" = one exact postcode. Multiple entries are OR-ed. Leave empty for all of Germany.

## `transportModes` (type: `array`):

Derived from the permit type. Leave empty for both.

## `permitTypes` (type: `array`):

The register uses exactly these three. A value that is not one of them is rejected before the run starts, because a typo here would return an empty result that looks like a real answer.

## `minTrucks` (type: `integer`):

Keep only operators running at least this many trucks. The register publishes the count, so this is a real fleet-size filter, not a guess. An operator whose fleet block is missing is excluded rather than treated as zero. Measured over 120 operators: mean 7.5 trucks, max 102, 34% had 5 or more, 19% had 10 or more.

## `minTotalVehicles` (type: `integer`):

Trucks + buses + small 2.5-3.5 t vehicles. 90% of the 120 sampled operators run at least one vehicle; the rest are licensed with none recorded.

## `onlyCurrentPermits` (type: `boolean`):

Keeps an operator only when at least one of its permits is valid today, counting open-ended ("unbefristet") permits as valid. An operator whose permits publish no end date at all is excluded — "we do not know" is not "yes". All 120 sampled operators passed this, so it is a safety net rather than a narrowing filter.

## `requireTradeRegisterNumber` (type: `boolean`):

Filters out sole traders and natural persons, who have no Handelsregister entry. Measured: 49% of the 120 sampled operators carry one — so this roughly halves the result set, and the half it removes are real one-person haulage businesses, not bad data.

## `requireAddress` (type: `boolean`):

A very small number of real records carry no address block at all (1 of 120 sampled). Turn this on if a postal address is mandatory for your use.

## `requireTransportManager` (type: `boolean`):

The Verkehrsleiter is the person legally responsible for the fleet — usually the best-qualified contact in the business. 95% of the 120 sampled operators name one.

## `maxRecordsExamined` (type: `integer`):

Hard budget on pages fetched, whether or not they yield a row. Use it with filters: "look at at most 5,000 ids and give me whatever matched".

## `maxRunSeconds` (type: `integer`):

Soft wall-clock budget. When it is reached the walk stops, everything already delivered is kept, and RUN\_SUMMARY records `stoppedBy: maxRunSeconds`. Useful because this host throttles by dropping connections rather than returning an error, so a bad network moment makes a run slow rather than making it fail.

## `concurrency` (type: `integer`):

Pages fetched at once. Measured on 2026-09-16 from a single IP: 120 requests at concurrency 10 produced 53 dropped connections and then a total cut-off lasting minutes, while concurrency 4 and a serial pass both ran clean. 4 is the default for that reason. Raise it only with rotating residential proxies, and watch the unreachable count in RUN\_SUMMARY.

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

balm.bund.de has no CAPTCHA, no Cloudflare and no JS gate — a bare request with no User-Agent gets a byte-identical page. What it does have is per-IP connection throttling, so the point of the proxy here is rotation, not evasion. Apify Proxy is enabled by default; switch to RESIDENTIAL if you see the unreachable count climb in RUN\_SUMMARY.

## Actor input object example

```json
{
  "startId": 1,
  "endId": 0,
  "maxOperators": 50,
  "operatorIds": [],
  "postalCodePrefixes": [],
  "transportModes": [],
  "permitTypes": [],
  "minTrucks": 0,
  "minTotalVehicles": 0,
  "onlyCurrentPermits": false,
  "requireTradeRegisterNumber": false,
  "requireAddress": false,
  "requireTransportManager": false,
  "maxRecordsExamined": 0,
  "maxRunSeconds": 240,
  "concurrency": 4,
  "proxyConfiguration": {
    "useApifyProxy": true
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per licensed road-transport operator: company, postal address, legal form, trade-register number, named legal representative and transport manager, vehicle counts by class, and every permit with its number, validity window and issuing authority. No email, phone or website - the register publishes none.

## `runSummary` (type: `string`):

RUN\_SUMMARY: ids examined vs rows delivered and charged, the ids that held no record, the ids your filters removed and which filter removed them, and the ids that could not be read at all - kept apart, and charged for none of them.

# 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 = {
    "startId": 1,
    "maxOperators": 50,
    "maxRunSeconds": 240
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/vudat-transport-permits-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 = {
    "startId": 1,
    "maxOperators": 50,
    "maxRunSeconds": 240,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/vudat-transport-permits-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 '{
  "startId": 1,
  "maxOperators": 50,
  "maxRunSeconds": 240
}' |
apify call scrapersdelight/vudat-transport-permits-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/vudat-transport-permits-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/poUYTHRSXJVkC2dtr/builds/StaB4T3jXLiDk0jdj/openapi.json
