# DVSA Operator Licence Scraper - UK HGV & PSV Fleet Data (`scrapersdelight/dvsa-operator-licence-scraper`) Actor

Scrape the UK Traffic Commissioners' operator-licence register: all 69,127 GB goods and PSV O-licences with authorised vehicles and trailers, operating centres, transport managers, directors and Companies House numbers. Every row carries the age of the export it came from.

- **URL**: https://apify.com/scrapersdelight/dvsa-operator-licence-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

from $1.20 / 1,000 per 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?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## 🚛 DVSA Operator Licence Scraper — every HGV and PSV fleet in Great Britain, **with proof of how fresh it is**

Every lorry, tipper, tanker, bus and coach operator in Great Britain has to hold an **operator
licence (O-licence)** from a Traffic Commissioner. The Traffic Commissioners publish the whole
granted register — **69,127 licences, 690,483 authorised vehicles, 423,603 authorised trailers,
97,348 operating centres** — as eight regional CSVs on data.gov.uk.

This Actor turns that raw export into a clean, filterable, rankable dataset: **one row per
licence** (or one row per operating centre), with the operating centres split out of the cell the
publisher jams them into, the transport managers and directors de-duplicated, the postcodes
parsed, and a Companies House link on every licence that has a company number.

> **The thing nobody else checks.** Those eight CSVs sit behind CloudFront, and **a stale edge
> answers HTTP 200 with a month-old file and no error of any kind.** Measured on 2026-09-08: from
> one machine every route landed on an edge still serving the **07 Aug** export while the origin
> already had the **07 Sep** one — and from an Apify worker the un-proxied route hit `IAD12-P5`
> serving a file **748 hours old**. Cache-busting does nothing (the query string is not in the
> cache key; `Cache-Control` and `Pragma` are ignored). This Actor HEAD-probes every file, rotates
> the egress IP until the export is inside your freshness window, and then stamps
> **`sourceLastModified`, `sourceEtag`, `sourceVersionId`, `sourceAgeHours` and
> `sourceCloudfrontPop` on every single row.** You never have to take our word for it.

***

### 📊 What you get, per row

Fill rates below are **measured over all 69,127 licences** in the 07 Sep 2026 export (parsed
2026-09-08), not estimated.

| Field | Fill | What it is |
|---|---|---|
| `licenceNumber` | **100%** | The O-licence number (`OG0093659`). Globally unique — **0 duplicates across all eight regional files** |
| `operatorName` | **100%** | The licence holder's registered name |
| `operatorType` | **100%** | Limited Company 53,790 · Sole Trader 10,537 · Partnership 4,183 · Other (authority/charity/trust) 478 · LLP 139 |
| `licenceCategory` | **100%** | 🚛 Goods 64,039 · 🚌 PSV 5,088 — derived from the licence number's first letter |
| `licenceType` | **100%** | Restricted 31,967 · Standard National 25,741 · Standard International 11,419 |
| `licenceStatus` / `licenceStatusLabel` | **100%** | Raw `lsts_valid` 68,588 · `lsts_curtailed` 522 · `lsts_suspended` 17, plus a decoded label |
| `trafficArea` / `trafficAreaCode` | **100%** | One of the eight Traffic Commissioner areas, plus its licence letter (B/C/D/F/G/H/K/M). Verified 1:1 against the licence number on all 69,127 rows |
| **`vehiclesAuthorised`** | **100%** | 🔔 **The field the whole dataset is priced on.** GB total **690,483**. Median 3, mean 9.99, max **2,853** (Wincanton Holdings Limited) |
| `trailersAuthorised` | 96.44% | GB total 423,603. The source leaves it blank, not zero, on the other 3.56% |
| `vehiclesSpecified` | **100%** | Vehicles actually on the licence today — GB total 446,930 |
| `vehicleUtilisation` / `spareVehicleAuthority` | **100%** | Specified ÷ authorised, and the raw gap. Utilisation 1.0 = the operator has filled every slot it is licensed for |
| `trailersSpecified` | **100%** | ⚠️ **Always `0`.** The publisher does not populate this column — it is `0` on all 179,733 source rows. We ship it so you can see that for yourself rather than wondering |
| **`operatingCentres`** | 99.61% | Array of `{address, postcode}` — **97,348 centres GB-wide**, 13,759 licences with more than one, max **23**. 99.68% of them yield a parseable postcode |
| `operatingCentreCount` / `operatingCentrePostcodes` | **100%** | Where the vehicles actually are, which is frequently not where the post goes |
| **`transportManagers`** | 52.88% | Array of named CPC-holding transport managers — **45,955 named posts across 36,553 licences**. Blank on Restricted licences, which legally need none |
| **`directorsOrPartners`** | 84.07% | Array of `{name, role}` — **129,674 distinct people, 119,010 Directors and 10,664 Partners.** The source's `None` sentinel is never emitted as a person |
| `companyRegNumber` | 78.57% | 54,316 licences: 50,171 eight-digit, 3,583 `SC…`, 179 `NI…`, 130 `OC…`, 10 `SO…` |
| `companiesHouseUrl` | 78.57% | A ready-made link to that company's Companies House record, for enrichment |
| `correspondenceAddress` | **100%** | The address the Traffic Commissioner writes to |
| `correspondencePostcode` (+ area, district) | **99.98%** | Parsed **case-insensitively on purpose** — the source contains lowercase postcodes (`sg1 2bh`, `lE16 9EE`), and a case-sensitive regex only reaches 99.29% |
| **`continuationDate`** | **100%** | 🔔 The five-yearly renewal date, which the operator pays a fee to meet: **3,715 fall due in 2026, 13,024 in 2027, 13,366 in 2028, 14,196 in 2029, 14,350 in 2030** |
| `continuationDueInDays` | **100%** | Computed at run time |
| **`sourceLastModified`, `sourceAgeHours`, `sourceIsStale`** | **100%** | The provenance block — on every row, always, even with `includeSourceProvenance` off |
| `sourceEtag`, `sourceVersionId`, `sourceCloudfrontPop`, `sourceEdgeCacheAgeSecs`, `sourceTransport`, `sourceFile`, `sourceUrl`, `sourceRangeLimited` | **100%** | The exact S3 object, the exact CloudFront edge, and how it was fetched |
| `raw` | opt-in | The 16 untouched CSV cells, so you can audit any derived field against the publisher's own bytes |

**Monitor mode adds:** `changeType`, `changedFields`, `previousVehiclesAuthorised`,
`previousTrailersAuthorised`, `previousVehiclesSpecified`, `previousOperatingCentreCount`,
`previousLicenceStatusLabel`, `previousOperatorName`, `vehiclesAuthorisedDelta`,
`operatingCentreCountDelta`, `fieldsAvailable`, `removalFilterCheck`.

**A removed row also carries everything the baseline remembered**, because those are the fields the
removal was judged on: `previousLicenceType`, `previousOperatorType`, `previousLicenceStatus`,
`previousCompanyRegNumber`, `previousTransportManagerCount`, `previousHasTransportManager`,
`previousVehicleUtilisation`, `previousContinuationDate`, `previousCorrespondencePostcode`,
`previousOperatingCentrePostcodes`, `previousTransportManagers`, `previousDirectorOrPartnerNames`.
The licence is gone, so the *current* columns on that row are empty (`transportManagerCount: 0`,
`companyRegNumber: null`) — the `previous…` pair is the real value, exactly as
`operatingCentreCount` / `previousOperatingCentreCount` already works. **A `previous…` key is
absent, not null, when the baseline never stored that field** (a baseline written by an older build
of this Actor): absent means unknown, never "the operator had none". `includePersonNames: false`
drops the two name lists and `includeOperatingCentres: false` drops the postcode list, the same way
they do on a live row.

***

### 🎯 Who buys this, and what they buy it for

| Buyer | The query |
|---|---|
| **Truck, trailer and van dealers · leasing · rental** | `sortBy: vehiclesAuthorised`, `maxLicences: 1000` — the biggest 1,000 fleets in GB, ranked, in one run |
| **Fleet insurance and finance brokers** | `minVehiclesAuthorised: 10` → the **13,992** licences that are a real commercial fleet, not a man-and-a-van |
| **Telematics, fuel cards, tyres, workshop services** | `postcodePrefixes` on the **operating centre**, not the correspondence address — 97,348 depots to territory-plan against |
| **Compliance, audit and O-licence consultants** | `continuationDueInNextDays: 365` (12,735 licences due within a year on the 07 Sep 2026 export), or `licenceStatuses: ["curtailed","suspended"]` — 539 fleets currently under a Traffic Commissioner sanction |
| **Transport recruiters** | `hasTransportManager: "no"` + `licenceTypes: ["Standard National","Standard International"]` — Standard licences must have a CPC holder |
| **M\&A, market research, PE** | The whole register plus the `RUN_SUMMARY` aggregates: fleet-size distribution, vehicles per traffic area, operator legal form |
| **Anyone running a paid fleet list today** | The best-known commercial product built on this same source covers fleets of **75+ vehicles — about 1,000 of them.** Only **2,176** GB licences have 50 or more vehicles and **820** have 100 or more; the other **~68,000** small and mid operators are in here too |

***

### 🔎 What you can filter on

Every filter runs over the **whole file before any row cap**, so the counts the run log prints are
real. 30+ inputs, all wired to behaviour:

- **Scope** — traffic areas (any of the eight, or all of GB), row cap, one row per licence or per operating centre
- **Fleet size** — min/max authorised vehicles, authorised trailers, specified vehicles, utilisation ratio
- **Licence** — Goods vs PSV, Restricted / Standard National / Standard International, valid / curtailed / suspended
- **Operator** — legal form, name (contains / starts with / exact, accent-insensitive), has a Companies House number
- **Geography** — postcode prefixes matched against the correspondence address, the operating centres, or either
- **People** — has a named transport manager, transport-manager name contains, director/partner name contains, min transport managers
- **Sites** — min/max operating centres
- **Renewal** — continuation due within N days, or between two dates
- **Lookup** — exact licence numbers, exact Companies House numbers

***

### 🏆 The ranking that makes this worth more than the raw CSV

```jsonc
{ "sortBy": "vehiclesAuthorised", "sortOrder": "desc", "maxLicences": 1000 }
```

The ranking is applied **across every licence read, before the cap is taken** — a bounded top-N
selector, verified against a full sort of all 69,127 licences. So that really is *the 1,000 biggest
fleets in Great Britain*, not the biggest of the first 1,000 rows. Measured on the 07 Sep 2026
export: the 1,000th biggest fleet holds **85 authorised vehicles**; the largest is **Wincanton
Holdings Limited at 2,853**.

Verified live on the platform (run `bWxDsKMqg3LvEWBgw`, 2026-09-08): all eight files read,
**69,127 licences scanned, 2,176 matched `minVehiclesAuthorised: 50`, top 500 delivered**, 81.1 MB,
28 HTTP requests, **0 retries**, 293.8 s, peak 219 MB.

***

### 🔔 Monitor mode — only what moved

Set `mode: "monitor"` and schedule it. The Actor downloads the same files, diffs them against a
baseline kept in a **named** key-value store (an unnamed store is wiped every run, which would make
every licence look new every time), and delivers only the licences that are **new, changed or
gone** — with `changedFields` naming exactly which fields moved.

> **Monitor mode needs `maxLicences: 0`, and refuses to start without it.** `maxLicences` defaults
> to **200**, so it has to be set to 0 explicitly. A capped monitor run cannot tell *unchanged* from
> *not reached*, so it deliberately does not advance its baseline — which would leave it reporting
> the same licences as new, and billing for them, on every single run. Rather than let that happen
> quietly the Actor **fails before it downloads or charges anything** and says so. To bound what a
> monitor run can spend, use the run's `maxTotalChargeUsd` instead.

**Measured, 07 Aug 2026 export vs 07 Sep 2026 export, all eight regions, 69,127 licences:**

| | Count |
|---|---|
| New licences | **418** |
| Changed licences | **8,989** |
| Removed licences | **464** |
| Unchanged | 59,720 |
| **Share of the register that moved in 31 days** | **14.28%** |

Which fields moved, on those 8,989 changed licences:

| Field | Licences |
|---|---|
| `vehiclesSpecified` | 5,198 |
| `operatingCentres` | 2,384 |
| `transportManagers` | 901 |
| `directorsOrPartners` | 817 |
| `continuationDate` | 746 |
| `vehiclesAuthorised` | 426 |
| `correspondenceAddress` | 401 |
| `trailersAuthorised` | 256 |
| `operatorName` | 74 |
| `licenceStatus` | 47 |
| `licenceType` | 38 |

Narrow `watchedFields` to cut both the noise and the bill: watching only `vehiclesAuthorised` and
`licenceStatus` turns 8,989 rows into **473**.

#### A removed licence is filtered like any other row

A removal is the one row type that has no current values to test — the licence is gone from the
export, so all that is left is what the last run saw. This Actor therefore **keeps every field its
filters read** in the baseline (licence type, operator type, company registration number, status,
the four vehicle/trailer counts, operating-centre count and postcodes, correspondence postcode,
transport-manager and director names, continuation date) and re-runs **the same predicate** against
that stored row. A removal is delivered — and billed — only when the licence it replaces **would
itself have matched your filters**.

Measured on Wales (4,744 licences), 400 removals put through all 33 filter clauses one at a time —
13,200 removal decisions in total:

| | Removals delivered and billed |
|---|---|
| Before this was fixed | **13,200** — every removal, whatever the filter said (**$79.20**) |
| Now | **5,544** — exactly the ones whose last-seen row matched (**$33.26**) |
| Disagreements with running the filter on the full record | **0** |

Concretely: a `minVehiclesAuthorised: 10` monitor fed 200 removals (100 large fleets, 100 small)
used to deliver and bill all 200 for **$1.20**; it now delivers the 100 that really did run 10+
vehicles, for **$0.60**, and drops the rest without charging. Every delivered removal carries
`removalFilterCheck: "matched-against-previous-run-state"` so the judgement is auditable, **and the
remembered row itself** — `previousTransportManagerCount`, `previousCompanyRegNumber`,
`previousLicenceType` and the rest (full list in the field table above) — so a row billed for
matching `minTransportManagers: 1` shows you the transport managers it matched on instead of only a
`transportManagerCount: 0` for a licence that no longer exists. The run log and `RUN_SUMMARY` both
report how many removals were dropped.

The one case this cannot answer is a baseline written by an **older build** of this Actor, which did
not keep all those fields. `removalsWithUnmatchableFilters` decides what happens then: the default
**"skip"** never charges for a removal it cannot vouch for and hands you the licence numbers for
free in `RUN_SUMMARY` under `monitor.removalsSkippedAsUnmatchable`; **"deliver"** ships them at
$0.006 each, flagged `removalFilterCheck: "not-checkable: …"`. Old baselines are **upgraded in
place, not rebuilt**, so nothing is re-reported and nothing is re-billed — from the second run
onwards the question does not arise. Keeping the extra fields costs the baseline **1,805,740 → 3,293,541 bytes
for the largest traffic area** (East of England, 13,144 licences), against a 9 MB key-value record
ceiling. The second figure is not a projection: it is what a live GB-wide run reports in
`RUN_SUMMARY.monitor.stateRecordBytesMax`, to the byte.

**Honest about what "new" means here.** Net licence churn is small — 418 new against 464 removed is
a net **-46 in 31 days**. The value in monitoring this register is the **field-level** movement (a
fleet growing, a depot opening, a transport manager leaving, a licence being curtailed), not a flood
of brand-new operators. Anyone selling you "hundreds of new UK hauliers every month" from this
source is inventing it.

***

### 🚀 Example inputs

**The 1,000 biggest fleets in Great Britain**

```json
{ "sortBy": "vehiclesAuthorised", "sortOrder": "desc", "maxLicences": 1000 }
```

**Every commercial fleet in South Wales, by depot location**

```json
{
  "regions": ["Wales"],
  "postcodePrefixes": ["CF", "NP", "SA"],
  "postcodeMatchField": "operatingCentre",
  "minVehiclesAuthorised": 5,
  "maxLicences": 0
}
```

**Renewal leads: O-licences up for continuation in the next 6 months**

```json
{ "continuationDueInNextDays": 180, "minVehiclesAuthorised": 10, "sortBy": "continuationDate", "sortOrder": "asc", "maxLicences": 0 }
```

**Compliance signal: every fleet currently curtailed or suspended**

```json
{ "licenceStatuses": ["curtailed", "suspended"], "maxLicences": 0 }
```

**Every bus and coach operator, one row per depot**

```json
{ "licenceCategories": ["psv"], "outputLevel": "operating-centre", "maxLicences": 0 }
```

**Weekly change monitor (schedule it)**

```json
{ "mode": "monitor", "maxLicences": 0, "watchedFields": ["vehiclesAuthorised", "licenceStatus", "operatingCentres"], "monitorStateKey": "gb-all" }
```

**The whole register**

```json
{ "maxLicences": 0 }
```

***

### 💵 Pricing

**Pay per event. No start fee, no monthly fee, no platform-usage surcharge.**

| Event | Price | Fires |
|---|---|---|
| `licence-scraped` | **$0.0012 per row** | Full-export mode — once per row actually written to your dataset |
| `licence-change-detected` | **$0.006 per change** | Monitor mode only, and it **replaces** the row charge. A row is never billed under both |

| What you run | Rows | Cost |
|---|---|---|
| Wales only | 4,744 | **$5.69** |
| Every fleet with 10+ vehicles | 13,992 | **$16.79** |
| Top 1,000 fleets by size | 1,000 | **$1.20** |
| Every licence in Great Britain | 69,127 | **$82.95** |
| Great Britain, one row per operating centre | 97,348 | **$116.82** |
| A monthly GB change monitor, all 13 fields watched | ~9,871 | **$59.23** |
| The same monitor watching only fleet size + status | ~473 | **$2.84** |

**Things that are never billed:** rows your filters removed — **including removals whose
last-seen row did not match your filters** — licences dropped by cross-run de-duplication, rows a
charge cap prevented from being delivered, and any traffic area that could not be downloaded. **Two things that ARE billed and are easy to miss**, so they are stated here
rather than discovered on an invoice: ticking `changeTypes: ["unchanged"]` delivers — and charges
`licence-change-detected` for — every licence that did **not** move, at $0.006 a row rather than the
$0.0012 the same row costs in full-export mode (all of GB = 69,127 rows ≈ **$414.76** for a run
where nothing changed; it is off by default). Delivery is budget-aware — rows are written and charged atomically, so
**delivered always equals billed**, and if a cap truncates the run the log says so and names the cap
as the cause.

`outputLevel: "operating-centre"` legitimately bills more rows than licence mode (97,348 against
69,127 for all of GB) because those rows are genuinely delivered — one per depot. That is stated
here, on the pricing page, and in the input form, so it can never read as a trick.

***

### ⚙️ How it works, and what it does not hide

**The route.** Eight public CSVs on `content.mgmt.dvsacloud.uk` (Amazon S3 behind CloudFront),
listed on data.gov.uk under the dataset *Traffic Commissioners: goods and public service vehicle
operator licence records*. No login, no API key, no cookies, no CAPTCHA, no browser. Recon and
live runs made ~100 requests with **zero non-200 responses and zero rate limiting**. There is no
`robots.txt` on the data host (the path 403s an `AccessDenied`), and `www.data.gov.uk/robots.txt`
disallows only `/v1`. Nothing here is disallowed.

**Four ways this route silently lies, and what the Actor does about each.**

1. **A stale CloudFront edge serves a month-old file with HTTP 200.** Measured 2026-09-08: the
   un-proxied route from an Apify worker returned a **748-hour-old** export; across 12 rotated
   proxy sessions, **7 (58%) hit a fresh edge and 5 hit a stale one** (fresh: YUL62, SEA900, ARN56,
   JFK50 — stale: ORD58, DFW56, LAX50). Query-string cache-busting, `Cache-Control: no-cache` and
   `Pragma: no-cache` all returned the identical cached object with an unchanged `Age`.
   👉 The Actor HEAD-probes, rotates the egress IP until `Last-Modified` is inside
   `maxSourceAgeDays`, downloads on the **same approved session**, and asserts the ETag of the body
   matches the one the probe approved. If it never finds a fresh copy it delivers the freshest one
   it saw with **`sourceIsStale: true` on every row** and names the edges it tried.
2. **`OCAddress` is not one operating centre — it is all of them, semicolon-joined into a single
   cell**, byte-identical on every row of a licence (verified: **0 of 69,127** licences vary it).
   The row multiplication is transport managers × directors, not centres: `rows == distinct(TM) ×
   distinct(director)` holds for **68,817 of 69,127 (99.55%)**.
   👉 Read naively you get 179,733 rows where there are 69,127 licences, and anyone who **sums
   `NumberOfVehiclesAuthorised` across raw rows overstates the GB fleet by 2.6×** (1,806,663 instead
   of the true 690,483). This Actor de-duplicates on licence number, splits the centre list on `;`,
   and de-duplicates *inside* the cell too — 74 licences repeat an address in their own cell.
3. **A proxy hop can drop the connection mid-body and still return HTTP 200.** Measured 2026-09-08
   (run `cecZj8th5gyrlJD33`): 1.6 MB of the 4.5 MB Wales export came back, the ETag matched, the CSV
   parsed cleanly — and the run reported **2,294 licences as if that were the whole traffic area.**
   👉 Every download now counts its own bytes against `Content-Length` (or `Content-Range`) and
   rejects a short body, and the parser independently refuses a full read whose body stops without a
   line terminator. A truncated transfer is retried on a different egress IP; it is never delivered.
4. **The traffic areas are eight separate files with no directory listing** (the S3 prefix 403s), so
   a ninth would be invisible. 👉 The run reads the data.gov.uk CKAN record at the start and warns
   if the publisher has added, renamed or dropped an export.

**Order of operations, because a filter applied after truncation is a lie.** Filters run over the
whole parsed file, before the cap. Ranking runs across every licence read, before the cap. The
`RUN_SUMMARY` aggregates cover **every matching licence the run examined, not the delivered slice**
(and they count the licences cross-run de-duplication dropped, which are never billed), and carry a
`corpusComplete` flag that goes false — with a named reason — whenever a cap, a byte-range read, a
skipped stale area or a failed download meant the run did not see the whole thing. When
`corpusComplete` is false the aggregates are a sample of what the sweep reached before it stopped,
and `aggregatesCover` says so in words.

**Memory, measured rather than guessed.** The Actor declares **1,024 MB**, because two paths were
measured on the platform, not estimated:

| Path | Peak | |
|---|---|---|
| Whole-GB export, streaming (`maxLicences: 0`) | **228 MB** | run `yLc5kaB4znLDDo7wf`, 69,127 rows |
| One traffic area, capped | **98 MB** | run `zIry9h5GE0Oo9k6hE` |
| **Ranking the whole register uncapped** (`sortBy` + `maxLicences: 0`) | **432 MB** | run `UW0tGfBlBEtNMDyKl`, 69,127 rows in 174.6 s |

Ranking is the one operation that has to buffer: a sort cannot write its first row until it has seen
every matching licence. At 512 MB that path was OOM-killed at a 508 MB peak **after it had already
billed 32,000 rows** (run `jZlyMtwOBdXDijYiW`) — so the Actor now checks the memory it has been
given **before it downloads anything**, and refuses with an actionable message instead of dying
half-way through a paid run. 1,024 MB clears every measured path with better than 2× headroom.
(An earlier 256 MB build was also OOM-killed on a whole-GB export; the parser was then rewritten to
walk bytes rather than materialise the file as a string, which cut whole-corpus parse time from
7.9 s to **1.4 s** and the peak from 256 MB to 228 MB.)

**Speed and cost.** The un-proxied route pulls all 81 MB in about 8 seconds; the proxy is roughly
35× slower on this source (measured 12 s to 270 s for a single regional file), which is why the
default is *try direct first, rotate only when the file is too old*. A run with a row cap and no
filter fetches only a **byte range** of each CSV — 50 licences from Wales is ~150 KB instead of
4.5 MB. A stalled download rotates to a different egress IP rather than waiting out a slow session.

***

### ⏱️ Measured on the Apify platform, not estimated

Every figure below is from a real run of this Actor on 2026-09-08, or 2026-09-09 for the removal-filter rows.

| Run | Input | Result |
|---|---|---|
| `zIry9h5GE0Oo9k6hE` | The demo input (`Wales`, 50 licences) | **4.3 s**, 50 rows, 50 charged, 0.2 MB downloaded, 4 requests, peak 98 MB |
| `YEW98ZhoH4pxc7Mtb` | Empty input `{}` | **8.0 s**, 200 rows, 200 charged, would bill **$0.24** |
| `bWxDsKMqg3LvEWBgw` | Rank all of GB, `minVehiclesAuthorised: 50`, top 500 | **293.8 s**, 69,127 licences scanned, 2,176 matched, 500 delivered, 81.1 MB, **0 retries** |
| `yLc5kaB4znLDDo7wf` | The whole register (`maxLicences: 0`) | **610 s**, **69,127 rows delivered, 69,127 charged**, 81.05 MB, 35 requests, 1 retry, `corpusComplete: true` |
| `Sj78vkHsfT5hqp6ak` | Wales, uncapped, with a **$0.06 charge cap** | 50 rows delivered, **50 charged** — the cap held exactly, and the log named it as the cause |
| `0j9ZUJ62SwnoIPM1V` | Rank the **whole register** by fleet size, uncapped | **530 s**, 69,127 rows, charged 69,127, peak **424 MB** of 1,024 |
| Monitor, Wales | baseline then repeat | 4,744 changes then **0 changes / 0 charged**, exiting clean with "nothing in the register changed since the last run" |
| `E0d98nQVnSheu56ay` | Monitor, 6 removals, `minVehiclesAuthorised: 10` | **3 rows delivered, 3 charged** — the 3 removals whose last-seen row ran 10+ vehicles; the other 3 dropped, never charged |
| `ozqcADUU5PbdVav6B` | The same 6 removals, **no filter** | **6 rows, 6 charged** — the removal feature itself is unchanged |
| `RWUgRdeJDXOEDlGdO` | The same 6, `minTransportManagers: 1` | 4 rows, 4 charged — and each carries `previousTransportManagerCount`, `previousTransportManagers` and the rest of the row it was judged on |
| `L9ZWuxjblyhEYHQVB` | The same, with `includePersonNames: false` and `includeOperatingCentres: false` | 4 rows, 4 charged, and the three opted-out `previous…` lists are **absent from the row**, not blanked |
| `HNDZEsVOexURoNOrv` | A baseline from an older build + a filter it cannot answer | **0 rows, 0 charged**; all 6 licence numbers handed back free in `RUN_SUMMARY` |
| `vjj09Le1b19w3QMH5` | The same, with `removalsWithUnmatchableFilters: "deliver"` | 6 rows, 6 charged, each flagged `not-checkable: …`, carrying only the 2 `previous…` fields that baseline could actually answer |
| `wKdKDQ3nKoMx4jA4N` | GB-wide monitor baseline, all 8 traffic areas | **41.8 s**, 69,127 licences diffed, **peak 284 MB** of 1,024, largest state record **3,293,541 bytes** of a 9 MB ceiling, 81.0 MB in 16 requests |

**Freshness on that whole-GB run:** all eight exports came back **27.63–27.64 hours old** — the same
07 Sep 23:02 publication, served consistently from one CloudFront edge (`IAD12-P5`) with no egress
rotation needed. On an earlier whole-GB run the same eight files came back 5.75–5.90 hours old and
five of the eight *did* need a rotation, which is exactly why the rotation exists. Delivered rows and
charged events matched exactly on every run above — **delivered always equals billed**.

**Parsing cost:** all eight files, 81 MB, 179,733 source rows → 69,127 licence records in **1.4 s**
of CPU (the parser walks the bytes, so a whole traffic area is never held in memory twice). The
network is the entire cost of a big run, not the parse.

***

### 🔒 Privacy — read this before you use the people fields

This is a **statutory public register**, published by the Traffic Commissioners under the UK Open
Government Licence, and it names individuals at scale: **129,674 directors and partners** and
**45,955 transport-manager posts**. **10,537 sole traders and 4,183 partnerships** mean the
correspondence address — and often the operating centre — is somebody's **home address**.

The Actor ships **`includePersonNames`** (default on). Turn it off and the `transportManagers` and
`directorsOrPartners` arrays are dropped while the counts stay, so you can use the fleet data
without holding named natural persons.

The publisher's licence permits commercial reuse. **UK GDPR still applies to the natural-person
rows independently of that**, and how you use them — marketing, enrichment, retention — is your
decision and your legal responsibility, not ours.

***

### ⚖️ Legal and fair use

- **Source:** *Traffic Commissioners: goods and public service vehicle operator licence records*,
  published on data.gov.uk from the Vehicle Operator Licensing (VOL) database. Confirmed licence:
  **`uk-ogl` — UK Open Government Licence (OGL)**, read live from the publisher's own CKAN record.
- **Attribution, as OGL requires:** *Contains public sector information licensed under the Open
  Government Licence v3.0.*
- This Actor reads **only** the bulk export the publisher put on data.gov.uk for reuse. It does not
  touch the interactive VOL lookup service, and it holds no credentials.
- **Great Britain only.** Northern Ireland operator licensing is a separate register
  (opendatani.gov.uk) and is **not** in this export. We say GB, not UK, on purpose.
- Nothing here is legal advice. What you do with personal data in this register is your call.

***

### ❓ FAQ

**Is this the same as the DVSA's own operator licence lookup?**
It is the same underlying register (the VOL database), published by the Traffic Commissioners as a
bulk export for reuse. The interactive lookup service sits behind a bot-protection interstitial;
the bulk export is the route the publisher intends for reuse, and it is the only one this Actor
uses.

**How often is it actually updated? Don't just tell me "weekly".**
The publisher's own note on data.gov.uk says *"These records are updated on a weekly basis, every
Sunday."* We quote that as **their** claim. What we have **measured** is three `Last-Modified`
stamps: **Sun 18 May 2025 22:00 GMT**, **Fri 07 Aug 2026 23:02 GMT** and **Mon 07 Sep 2026 23:02
GMT** — two of those three are not Sundays. So we do not repeat the weekly claim as fact. What we
guarantee instead is the thing that actually matters: **every row tells you exactly how old the file
it came from is**, and you can refuse anything older than `maxSourceAgeDays`.

**Why does one licence have 300+ rows in the raw CSV?**
Because the publisher writes one row per transport-manager × director combination. DPDgroup UK Ltd
(OG0093659) is 184 raw rows: 23 transport managers × 8 directors, one operating-centre cell.
This Actor collapses that to **one row per licence** with both lists de-duplicated.

**Are the operating centres really all in one cell?**
Yes — semicolon-joined, and identical on every row of the licence. DHL Supply Chain's cell even
repeats one Ebbw Vale address. We split on `;`, trim, de-duplicate and parse each postcode.

**Why is `trailersSpecified` always 0?**
Because the publisher does not populate that column. It is `0` on all 179,733 source rows. We ship
it rather than dropping it so you can verify that yourself, and so nothing looks like it went
missing.

**Can I get just the biggest fleets without paying for the whole register?**
Yes, and that is the intended use: `sortBy: "vehiclesAuthorised"`, `sortOrder: "desc"`,
`maxLicences: 1000` costs **$1.20** and ranks across all 69,127 licences before the cap is applied.

**What is the difference between authorised and specified vehicles?**
Authorised is the ceiling the Traffic Commissioner granted (690,483 GB-wide). Specified is what is
actually on the licence today (446,930). The gap — `spareVehicleAuthority` — is headroom the
operator has already paid to have.

**How do I turn a licence into a full company record?**
78.57% of licences carry a Companies House number and every one of those rows ships a ready-made
`companiesHouseUrl`. Feed those into a Companies House scraper for accounts, SIC codes and officers.

**Does monitor mode charge me for a run where nothing changed?**
No — with one opt-in exception. There is deliberately **no per-run charge**, and a monitor run that
finds nothing delivers nothing and bills nothing; the log says the register has not moved since the
last run rather than pretending something went wrong. The exception is `changeTypes: ["unchanged"]`,
which asks for every licence that did *not* move: those rows are delivered and billed at the change
price. It is off by default, the input form says what it costs, and the run log warns before it
happens.

**Why won't monitor mode run with `maxLicences` set?**
Because it could never make progress. A capped run cannot tell *unchanged* from *not reached*, so it
does not advance its baseline — and would therefore report the same licences as new, and bill for
them, on every run. The Actor refuses before downloading or charging anything and tells you to set
`maxLicences: 0` (it defaults to 200). Bound a monitor run's spend with `maxTotalChargeUsd`.

**What happens if my charge cap runs out mid-run?**
Rows are written and charged atomically, so you are never billed for a row you did not receive. The
run stops, the log names the cap as the cause and says exactly how many rows were withheld, and in
monitor mode the baseline is **not** advanced — so the next run reports those changes again instead
of losing them.

**What if one of the eight files won't download?**
It is retried on a different egress IP, with a much longer timeout on the last attempt. If it still
fails, that traffic area is skipped, named in `regionsFailed`, and `corpusComplete` goes false — a
partial sweep is never reported as a complete one. Set `onRegionFailure: "fail"` if you would rather
the whole run stop.

**Is Northern Ireland included?**
No. This export is Great Britain: eight Traffic Commissioner areas covering England, Scotland and
Wales. Northern Ireland licensing is a separate register on opendatani.gov.uk.

**Does it need a proxy?**
Not for access — every file returns HTTP 200 from a plain IP. The proxy is there to **rotate around
a stale CloudFront edge**, which is the only reliable way to get the current file. Set
`proxyMode: "never"` if you genuinely do not care how old the data is.

# Actor input Schema

## `regions` (type: `array`):

Which of the eight Traffic Commissioner areas to read. Leave empty for all of Great Britain (69,127 licences, 81 MB, measured 2026-09-08). Licence counts in that export: East of England 13,144 · North East 10,326 · West of England 9,855 · North West 9,398 · West Midlands 8,088 · London & the South East 7,929 · Scotland 5,643 · Wales 4,744. When you leave this empty the areas are read smallest file first, so a run with a row cap downloads the least it can; when you list areas yourself, YOUR order is used. Northern Ireland is NOT in this export — it is a separate register (opendatani.gov.uk), so this Actor is Great Britain, not the whole UK.

## `outputLevel` (type: `string`):

"Licence" gives one row per operator licence with the operating centres nested in an array (69,127 rows GB-wide). "Operating centre" flattens that: one row per authorised operating centre, carrying all the licence fields plus that centre's address and postcode (97,348 rows GB-wide — 13,759 licences have more than one centre and the largest has 23). Operating-centre mode genuinely delivers, and therefore bills, more rows than licence mode: 97,348 against 69,127 for the whole of GB.

## `mode` (type: `string`):

“Full export” delivers the register. “Monitor” downloads the same files, diffs them against what the last run saw and delivers ONLY the licences that are new, changed or gone — with `changedFields` naming exactly which fields moved. Monitor state lives in a named key-value store so it survives between scheduled runs. The first monitor run is a baseline: everything reads as new, and the second run onwards is the real signal. MONITOR MODE REQUIRES “Max licences” = 0, and refuses to run otherwise — before downloading or charging anything. A capped monitor run can never advance its baseline (it cannot tell “unchanged” from “not reached”), so it would report the same licences as new and bill for them again on every run. “Max licences” DEFAULTS to 200, so it has to be set to 0 explicitly. Use the run's maxTotalChargeUsd to bound what a monitor run can spend. Measured on the 07 Aug -> 07 Sep 2026 exports: 418 new, 8,989 changed, 464 removed out of 69,127 licences.

## `maxLicences` (type: `integer`):

Stop after this many LICENCES (not rows — in operating-centre mode one licence can produce up to 23 rows; the GB average is 1.41). 0 means no cap: the entire selected area. With no filter and no ranking the run stops downloading the moment the cap is met, and only fetches the byte range of each CSV it actually needs, so a 50-row demo pulls about 150 KB instead of 4.5 MB. With a ranking set, the cap picks the TOP N across every licence read — see “Rank by”. In Monitor mode this must be 0: a capped monitor run cannot advance its baseline, so the run is refused up front rather than billing you for the same licences on every run.

## `licenceCategories` (type: `array`):

Goods = HGV/freight O-licences (64,039 GB-wide, 603,051 authorised vehicles). PSV = bus and coach operator licences (5,088 GB-wide, 87,432 vehicles). Empty means both.

## `licenceTypes` (type: `array`):

Restricted 31,967 · Standard National 25,741 · Standard International 11,419 (measured GB-wide 2026-09-08). Standard International is the export/haulage segment; Restricted operators carry only their own goods.

## `licenceStatuses` (type: `array`):

The export contains granted licences only. Statuses seen GB-wide 2026-09-08: valid 68,588, curtailed 522, suspended 17. Curtailed and suspended operators are the compliance-signal segment — a fleet under a Traffic Commissioner sanction.

## `operatorTypes` (type: `array`):

Limited Company 53,790 · Sole Trader 10,537 · Partnership 4,183 · Other (public authority, charity, trust, university) 478 · LLP 139. Sole traders and partnerships are the segment where the correspondence address is most often a home address.

## `minVehiclesAuthorised` (type: `integer`):

The fleet-size field the whole dataset is priced on. GB distribution measured 2026-09-08: 1 vehicle 14,440 licences · 2-9 40,694 · 10-49 11,816 · 50-99 1,356 · 100+ 820. Median 3, largest 2,853 (Wincanton Holdings Limited).

## `maxVehiclesAuthorised` (type: `integer`):

Upper bound on authorised vehicles. Use with the minimum to carve a band — e.g. 10 to 49 is 11,816 licences GB-wide, the classic mid-market fleet segment.

## `minTrailersAuthorised` (type: `integer`):

Authorised trailers, 423,603 GB-wide. Filled on 96.44% of licences (the source leaves it blank on the rest, and blank does not pass a minimum).

## `maxTrailersAuthorised` (type: `integer`):

Upper bound on authorised trailers. Pair with the minimum to isolate, for example, tipper and curtainsider fleets that pull a handful of trailers rather than a full artic fleet.

## `minVehiclesSpecified` (type: `integer`):

Vehicles actually specified on the licence (446,930 GB-wide) as against authorised (690,483). The gap is unused authority.

## `maxVehiclesSpecified` (type: `integer`):

Upper bound on vehicles actually specified on the licence.

## `minVehicleUtilisation` (type: `string`):

vehiclesSpecified / vehiclesAuthorised. 1.0 means the operator has filled every slot it is licensed for — a buying signal for anyone selling vehicles, leasing or extra authority. Leave empty to ignore.

## `maxVehicleUtilisation` (type: `string`):

Set this low (e.g. 0.5) to find operators sitting on authority they are not using.

## `minOperatingCentres` (type: `integer`):

13,759 GB licences (19.9%) have more than one authorised operating centre; the largest has 23. Set 2 to get only multi-site operators.

## `maxOperatingCentres` (type: `integer`):

Upper bound on authorised operating centres. Set 1 to keep only single-site operators (55,368 licences GB-wide).

## `minTransportManagers` (type: `integer`):

36,553 licences (52.9%) name at least one transport manager, 45,955 named posts in total. Restricted licences legally need none, which is why the other half is blank rather than missing.

## `operatorNameContains` (type: `string`):

Case- and accent-insensitive match on the operator's registered name. Use the match mode below to switch between contains / starts with / exact.

## `operatorNameMatchMode` (type: `string`):

How "Operator name contains" is matched. Exact is useful for reconciling a list of company names you already hold; starts with is useful for brand families ("STOBART", "EDDIE STOBART").

## `transportManagerNameContains` (type: `string`):

Matches any of the licence's named transport managers. Ignored when "Include people's names" is off, because there would be nothing to match against.

## `directorNameContains` (type: `string`):

Matches any named director or partner on the licence. 129,674 distinct director/partner entries GB-wide (119,010 Directors, 10,664 Partners).

## `postcodePrefixes` (type: `array`):

Outward-code prefixes, e.g. \["CF", "SA1", "M17"]. Matched against the correspondence postcode and/or the operating-centre postcodes depending on the setting below. 99.98% of correspondence addresses and 99.68% of operating centres yield a parseable postcode.

## `postcodeMatchField` (type: `string`):

"Operating centre" is the one to use for territory targeting — it is where the vehicles actually are, which is often not where the paperwork goes.

## `licenceNumbers` (type: `array`):

Look up exact licences, e.g. \["OG0093659", "OG0094759"]. Format is O (goods) or P (PSV), then the traffic-area letter, then 7 digits. Note the whole regional file still has to be read to find them — the export has no per-licence endpoint.

## `companyRegNumbers` (type: `array`):

Exact Companies House numbers, e.g. \["01234567", "SC221866"]. 78.57% of licences carry one (50,171 eight-digit, 3,583 SC, 179 NI, 130 OC, 10 SO).

## `hasTransportManager` (type: `string`):

A named transport manager is legally required on Standard licences and not on Restricted ones, so this splits the register almost exactly in half: 36,553 licences name at least one, 32,574 name none.

## `hasCompanyRegNumber` (type: `string`):

"Yes" is the segment you can join to Companies House for accounts, SIC codes and officers; every such row also carries a ready-made companiesHouseUrl.

## `continuationDueInNextDays` (type: `integer`):

An O-licence is continued every five years and the operator pays a fee to do it — this is the renewal trigger. Measured on the 07 Sep 2026 export: 3,715 licences fall due during 2026, 13,024 in 2027, 13,366 in 2028. Set 365 for "due in the next year".

## `continuationDueAfter` (type: `string`):

ISO date, e.g. 2027-01-01.

## `continuationDueBefore` (type: `string`):

ISO date, e.g. 2027-12-31. Combine with the one above for an exact renewal window.

## `sortBy` (type: `string`):

Ranking is applied across EVERY licence read before the cap is taken, so "Rank by authorised vehicles, max 1,000" really is the 1,000 biggest fleets in Great Britain — not the biggest of the first 1,000 rows. That combination is the fastest way to reproduce (and beat) a paid top-fleets list, and it uses flat memory. Leave on "Source order" for a plain export; source order also lets a capped run stop downloading early, which ranking cannot. NOTE: ranking is the one operation that has to buffer — a sort cannot write its first row until it has seen every matching licence — so ranking the WHOLE register (maxLicences 0) needs about 1 GB of run memory. Measured: 432 MB peak for all 69,127 licences. If the run does not have enough, it says so before downloading anything rather than failing part-way through a paid run.

## `sortOrder` (type: `string`):

Direction for the ranking above. Descending on authorised vehicles gives the biggest fleets first; ascending on continuation date gives the soonest renewals first.

## `changeTypes` (type: `array`):

Monitor mode only. “Removed” means the licence is no longer in the export at all — surrendered, revoked or expired. A removed licence no longer carries the fields your filters test, so it is judged against the row THE LAST RUN SAW: it is delivered only when the licence it replaces would itself have matched your filters. Filter to PSV only and removed goods licences are dropped, never delivered and never billed. Those rows carry `fieldsAvailable` and `removalFilterCheck`, which says exactly how the row was judged. Removals are suppressed (with a warning) for any traffic area that was not read in full, because a partial read cannot tell “gone” from “not reached”. ONE THING TO KNOW BEFORE YOU TICK THE LAST BOX: “Unchanged” delivers every licence that did NOT move, and each one is billed at the licence-change-detected price of $0.006 — five times the $0.0012 the same row costs in mode “Full export”. Ticking it for the whole of Great Britain is 69,127 rows for about $414.76 on a run where nothing changed. Tick it only when you deliberately want the full register carrying change flags.

## `watchedFields` (type: `array`):

A licence is reported as "changed" only when one of these moved. Measured over the 07 Aug -> 07 Sep 2026 exports (8,989 changed licences GB-wide): vehiclesSpecified 5,198 · operatingCentres 2,384 · transportManagers 901 · directorsOrPartners 817 · continuationDate 746 · vehiclesAuthorised 426 · correspondenceAddress 401 · trailersAuthorised 256 · operatorName 74 · licenceStatus 47 · licenceType 38. Narrow this list to cut the noise — watching only vehiclesAuthorised and licenceStatus, for example, turns 8,989 rows into a few hundred. Changing this list between runs never corrupts the comparison: the stored fingerprints always cover every field.

## `monitorStateKey` (type: `string`):

Names the saved baseline, so two schedules watching different scopes never overwrite each other's state. Use one key per watch — e.g. "gb-all" and "big-fleets-only".

## `removalsWithUnmatchableFilters` (type: `string`):

Monitor mode only, and it almost never fires. A removed licence is checked against your filters using the row the last run saved, which holds every field the filters read. It can only be uncheckable when the saved baseline was written by an older build of this Actor that did not keep that field — in practice, the FIRST run after this Actor was updated, and only if a filter reads a field that baseline never stored. “Skip” (the default) does not deliver and does not charge for a removal it cannot vouch for; the licence numbers are still handed to you for free in RUN\_SUMMARY under `monitor.removalsSkippedAsUnmatchable`, and the run log names the missing fields. “Deliver” ships them anyway at $0.006 each, flagged with `removalFilterCheck: "not-checkable..."`. From the second run onwards the baseline carries everything, so every removal is checked properly either way.

## `maxSourceAgeDays` (type: `integer`):

THE MOST IMPORTANT SETTING IN THIS ACTOR. The eight CSVs sit behind CloudFront, and a stale edge answers HTTP 200 with a MONTH-OLD file and no error of any kind. Measured 2026-09-08: from one machine every route landed on an edge serving the 07 Aug export while the origin already had the 07 Sep one, and query-string cache-busting, Cache-Control and Pragma all changed nothing. Rotating the egress IP does work — 7 of 12 Apify proxy sessions hit an edge holding the fresh object. So this Actor HEAD-probes each file, rotates until the Last-Modified is inside this window, and only then downloads. 0 disables the check and takes whatever the first edge serves.

## `freshnessRetries` (type: `integer`):

How many extra egress IPs to try per file before giving up on getting a fresh copy. At the measured 58% hit rate, 8 rotations leaves roughly a 1-in-2,000 chance of not finding one.

## `freshnessBudgetSecs` (type: `integer`):

Hard wall on the rotation so a slow proxy can never stretch a run. When it runs out the freshest object found so far is used and the run says so.

## `onStaleSource` (type: `string`):

"Deliver and flag" is the safe default: you get the data, every row carries sourceIsStale=true plus the real Last-Modified, and the run log names the edge. "Fail" is for pipelines that must never ingest an old snapshot. "Skip" drops just that traffic area.

## `verifySourceListing` (type: `boolean`):

One extra request to the CKAN record at the start of the run, to notice if the publisher adds, renames or drops a regional export. The S3 prefix itself refuses a directory listing (403), so this is the only way a ninth traffic area would ever be spotted. Never fatal — it warns.

## `includePersonNames` (type: `boolean`):

The register names individuals: 129,674 directors and partners and 45,955 transport-manager posts. Turning this OFF drops the `transportManagers` and `directorsOrPartners` arrays while keeping the counts, which is the setting to use if your own data policy does not want named natural persons in the dataset. The source is published under the Open Government Licence and permits commercial reuse with attribution; UK GDPR still applies to the natural-person rows independently of that, and that judgement is yours.

## `includeOperatingCentres` (type: `boolean`):

The nested `operatingCentres` array (address + postcode per centre) and `operatingCentrePostcodes`. Turn off for a slimmer row; the counts stay either way.

## `includeSourceProvenance` (type: `boolean`):

Adds sourceFile, sourceUrl, sourceEtag, sourceVersionId, sourceCloudfrontPop, sourceEdgeCacheAgeSecs, sourceTransport and sourceRangeLimited to every row. sourceLastModified, sourceAgeHours and sourceIsStale ship whatever you set here — a completeness flag is never optional.

## `includeRawRow` (type: `boolean`):

Attaches the 16 untouched CSV cells under `raw` so you can audit any derived field against the publisher's own bytes.

## `normaliseWhitespace` (type: `boolean`):

The source pads addresses with runs of spaces ("CRUGYBAR ABERNAINT FAWR   LLANWRDA"). On, those collapse to single spaces. Turn off if you need the bytes exactly as published.

## `dedupeAcrossRuns` (type: `boolean`):

Remembers every licence number delivered, in a named key-value store, and skips them next time — for building a corpus incrementally. Skipped rows are dropped BEFORE billing, so you are never charged for them. This is not the same as Monitor mode: it never tells you a licence CHANGED, only that you have not seen it before.

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

Used to ROTATE the egress IP when an edge is serving a stale export — that is what defeats CloudFront caching, and it is why the default is on. Apify datacenter proxy is enough; all eight files returned HTTP 200 through it, and through residential exits in GB, US, DE and IE. The proxy is also 35x slower than the direct route on this source (81 MB took ~8s direct and ~612s through the proxy), which is exactly why "Freshness only" below is the default.

## `proxyMode` (type: `string`):

"Freshness only" tries the direct route first and rotates through the proxy only when the file it gets is too old — fast when the platform's own edge is already current, correct when it is not. "Always" routes every request through a rotated session. "Never" disables the proxy entirely: fastest, but a stale edge then cannot be routed around.

## `onRegionFailure` (type: `string`):

A file that stalls is retried on a different egress IP first (proxy throughput on this source was measured anywhere between 12s and 270s for the same 11 MB file), and the last attempt gets a six-times-longer timeout. If it still will not come down, "Skip it" delivers the other traffic areas, names the failed one in the run log and in RUN\_SUMMARY's regionsFailed, and sets corpusComplete to false — nothing is passed off as a full sweep. "Fail the run" is for pipelines that must have all eight or none. A run where the ONLY selected traffic area fails always fails, whichever you pick.

## `rangeFetch` (type: `boolean`):

When a row cap makes the whole file unnecessary and nothing is filtered, ranked or monitored, only the first slice of each CSV is downloaded (the export supports HTTP byte ranges). Measured: 50 licences from Wales needs ~150 KB instead of 4.5 MB. Automatically off whenever a filter, a ranking or Monitor mode is active, because a prefix cannot tell "does not match" from "not read yet".

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

Per HTTP request. Proxy throughput on this source varies wildly (measured 12s to 270s for a single regional file), so a timeout here triggers a retry on a different session rather than failing the run.

## `maxRetries` (type: `integer`):

Retries per request before the run gives up on that file. Recon saw zero non-200 responses and zero retries across ~70 requests, so this is headroom, not a workaround.

## Actor input object example

```json
{
  "regions": [
    "Wales"
  ],
  "outputLevel": "licence",
  "mode": "full",
  "maxLicences": 50,
  "licenceCategories": [],
  "licenceTypes": [],
  "licenceStatuses": [],
  "operatorTypes": [],
  "operatorNameMatchMode": "contains",
  "postcodePrefixes": [],
  "postcodeMatchField": "any",
  "licenceNumbers": [],
  "companyRegNumbers": [],
  "hasTransportManager": "any",
  "hasCompanyRegNumber": "any",
  "sortBy": "none",
  "sortOrder": "desc",
  "changeTypes": [
    "new",
    "changed",
    "removed"
  ],
  "watchedFields": [
    "operatorName",
    "operatorType",
    "companyRegNumber",
    "licenceType",
    "licenceStatus",
    "correspondenceAddress",
    "vehiclesAuthorised",
    "trailersAuthorised",
    "vehiclesSpecified",
    "operatingCentres",
    "transportManagers",
    "directorsOrPartners",
    "continuationDate"
  ],
  "monitorStateKey": "gb-all",
  "removalsWithUnmatchableFilters": "skip",
  "maxSourceAgeDays": 14,
  "freshnessRetries": 8,
  "freshnessBudgetSecs": 120,
  "onStaleSource": "warn",
  "verifySourceListing": true,
  "includePersonNames": true,
  "includeOperatingCentres": true,
  "includeSourceProvenance": true,
  "includeRawRow": false,
  "normaliseWhitespace": true,
  "dedupeAcrossRuns": false,
  "proxyConfiguration": {
    "useApifyProxy": true
  },
  "proxyMode": "freshnessOnly",
  "onRegionFailure": "warn",
  "rangeFetch": true,
  "requestTimeoutSecs": 90,
  "maxRetries": 3
}
```

# Actor output Schema

## `licences` (type: `string`):

One row per granted GB operator licence (or per operating centre in operating-centre mode): licence number, type and status, operator name and legal form, Companies House number, correspondence address and postcode, authorised and specified vehicles and trailers, every authorised operating centre, named transport managers and directors, the five-yearly continuation date — and the Last-Modified, ETag and CloudFront edge of the export the row was parsed from.

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

Corpus-wide aggregates over every licence that MATCHED your filters — not over the delivered slice — plus per-traffic-area byte counts, the freshness of each source file, and a corpusComplete flag that is false whenever a cap stopped the run short.

# 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 = {
    "regions": [
        "Wales"
    ],
    "mode": "full",
    "maxLicences": 50
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/dvsa-operator-licence-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 = {
    "regions": ["Wales"],
    "mode": "full",
    "maxLicences": 50,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/dvsa-operator-licence-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 '{
  "regions": [
    "Wales"
  ],
  "mode": "full",
  "maxLicences": 50
}' |
apify call scrapersdelight/dvsa-operator-licence-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/dvsa-operator-licence-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/6rzv7zEDKCubaiFkx/builds/R05uugTqxXcietDul/openapi.json
