kenteken
Look up Dutch vehicle data by licence plate, from the RDW open data API.
Built for humans at a terminal and for agents reading a pipe, equally: text when
stdout is a TTY, JSON when it is not, a machine-readable contract under
kenteken schema, and structured errors on stderr. It follows
The CLI Spec v0.2.
$ kenteken lookup V-95-JKV
V-95-JKV Iveco 35s14
Type Bedrijfsauto (N1), neerklapbare zijschotten
APK expires 2027-12-11 in 1 year 4 months
First admitted 2024-12-11 1 year 8 months ago
Registered since 2024-12-11
Fuel Diesel, 100 kW, 243 g/km CO2 (WLTP)
Mass 2,059 kg empty, 3,500 kg max
Catalogue price EUR 91,144
Odometer consistent
Insured (WAM) yes
Recall none outstanding
Anything that should stop you is shouted in words, not just coloured, so it
survives being piped, redirected or read by someone who cannot tell red from
grey: EXPIRED, NOT INSURED, OPEN RECALL, INCONSISTENT,
TRANSFER BLOCKED.
Install
cargo install kenteken
uv tool install kenteken # or: pipx install kenteken
Or from a clone:
make install # builds --release and copies to ~/.local/bin
No API key is needed. RDW open data is anonymous and in the public domain.
Setting RDW_APP_TOKEN to a Socrata app token
raises the shared per-IP rate limit; the token is read from the environment only,
never from the command line.
Commands
| Command | What it returns |
|---|---|
lookup <PLATE>... |
Registration summary: make, model, APK expiry, masses, fuel, indicators |
defects <PLATE>... |
Defects recorded at inspection, each code resolved to its description |
fuel <PLATE>... |
Fuel and emissions rows, one per fuel for a hybrid or bifuel vehicle |
raw <DATASET> <PLATE>... |
Rows from any known RDW dataset, exactly as RDW returned them |
datasets |
The datasets this build knows. Makes no network request |
schema |
The machine-readable contract, as JSON |
completions <SHELL> |
A shell completion script |
Every command is read-only, and every command takes several plates at once:
kenteken lookup V-95-JKV 00-BN-V3 12-ABC-3
Plates are normalized before anything is sent, so V-95-JKV, v95jkv and
V95JKV are the same plate, and a plate that cannot be one is refused without
spending a request.
Defects
Defect codes are resolved from a table embedded in the binary, so this needs one request rather than two:
$ kenteken defects 00-BB-D9 --limit 3
DATE CODE DEFECT
2024-11-21 205 Band onvoldoende profiel
2024-11-21 419 Blokkering gordel werkt niet (goed)
2024-11-21 516 Dimlicht onjuist afgesteld
note: showing rows 1-3 of 11; raise --limit or page with --offset
Rows arrive newest inspection first, so a page cut short by --limit shows the
most recent defects rather than an arbitrary three of eleven. The plate column
appears only when more than one plate was asked for.
Refresh the table from RDW with make update-gebreken, which writes
data/gebreken.json as a reviewable diff.
Raw datasets
raw reaches the datasets lookup does not summarize:
kenteken datasets # names and Socrata ids
kenteken raw assen V-95-JKV # by short name
kenteken raw 3huj-srit V-95-JKV # or by Socrata id
Output
--output/-o takes auto (the default), text, json, yaml or ndjson.
auto is text on a TTY and JSON in a pipe, so a script gets JSON without asking.
JSON and YAML carry the whole envelope:
$ kenteken lookup V95JKV --fields kenteken,merk,vervaldatum_apk
{
"items": [
{
"kenteken": "V95JKV",
"merk": "IVECO",
"vervaldatum_apk": "20271211"
}
],
"total": 1,
"limit": 100,
"offset": 0,
"truncated": false,
"not_found": [],
"no_rows": []
}
items is always an array, under every command, so a consumer parses one shape.
total counts every matching row and truncated says whether rows remain after
this page; page with --limit and --offset until truncated is false. NDJSON
prints one item per line and puts that metadata on stderr, where it cannot
corrupt the stream. Text says it in words.
--fields keeps only the named columns. A field present in no row is a usage
error rather than a silently empty column.
What RDW sent, and what it means
Every item carries RDW's own columns untouched, plus a derived block holding
this tool's reading of them. The text output is that same block, formatted, so a
human and an agent are looking at one computation rather than two that can
disagree.
$ kenteken lookup GZS-81-K --fields derived -o json
{
"items": [
{
"derived": {
"plate": "GZS-81-K",
"make": "TESLA",
"model": "MODEL Y",
"kind": "Personenauto",
"eu_category": "M1",
"body": "MPV",
"colour": "ZWART",
"second_colour": null,
"apk_expiry": "2026-12-31",
"apk_expired": false,
"apk_days_remaining": 148,
"first_admission": "2024-12-31",
"age_days": 582,
"registered_since": "2024-12-31",
"fuels": ["Elektriciteit"],
"power_kw": 378.0,
"co2_g_per_km": null,
"co2_basis": null,
"electric_range_km": 533.0,
"mass_empty_kg": 1954,
"mass_max_kg": 2518,
"catalogue_price_eur": 51990,
"odometer": "consistent",
"insured": true,
"open_recall": false,
"exported": false,
"taxi": true,
"transferable": true
}
}
],
"total": 1,
"limit": 100,
"offset": 0,
"truncated": false,
"not_found": [],
"no_rows": []
}
Its keys are always present, and a fact RDW did not supply is null. Dates are
ISO 8601 rather than 20261231, Ja/Nee are booleans, Logisch is
consistent, and a CO2 figure never appears without the test cycle that
produced it, because a WLTP number and an NEDC number are not comparable.
apk_expired is null rather than false when there is no expiry date: a
vehicle that needs no inspection has not passed one.
RDW writes a placeholder into a column it has no value for rather than leaving
it empty: N.v.t., Niet geregistreerd, or Geen verstrekking in Open Data.
Read naively, tweede_kleur alone gives ten million single-colour vehicles a
second colour. The raw columns carry those strings verbatim, because that is
what RDW sent; derived resolves them to null.
Paging returns the same rows twice
Socrata leaves the order of an unsorted result undefined, and this endpoint has
been observed returning the same rows in different orders for two identical
requests. Every query therefore carries an explicit sort, newest first for
anything dated, so --limit is the first N rather than an arbitrary N, and
--offset neither skips nor repeats. kenteken datasets -o json shows the sort
used for each dataset.
Only results go to stdout. Warnings, notes and error envelopes go to stderr, and
--quiet silences the warnings and notes without changing stdout by a byte. It
does not silence the NDJSON metadata line: that is the envelope rather than a
warning, and it is the only place a consumer of that format can learn that rows
were withheld.
$ kenteken -o ndjson --limit 2 --quiet datasets 2>&1 >/dev/null
{"total":13,"truncated":true,"not_found":[],"no_rows":[]}
Absent, empty and missing are three different answers
Collapsing them is the one mistake a vehicle lookup cannot afford, so this tool keeps them apart everywhere:
- Not registered. No vehicle exists under the plate. It is listed in
not_found, and when no requested plate exists at all the run is anot_founderror with exit 4. - Registered, nothing in this dataset. The vehicle exists but has no rows
here. It is listed in
no_rowsand the run exits 0. In text,defectssays so positively: "V95JKV is registered, with no defects recorded at inspection", never a blank table that reads like a failed lookup. - Some of each. With several plates, the ones that resolved are returned and
the ones that did not are named on stderr, and the run exits 1 (
partial).
A field RDW did not report is left out rather than rendered as 0 or an empty
string: null in JSON, - in a table cell, and no line at all on a summary
card, since a confident-looking label next to nothing is worse than silence.
defects and fuel therefore read the vehicle register as well, purely to tell
a typo from a clean bill of health.
Exit codes
| Code | Meaning | Retryable |
|---|---|---|
| 0 | Success | |
| 1 | partial: some plates resolved, some are not registered. A data state, not an error, so no error envelope is written |
|
| 2 | network: the RDW API could not be reached |
yes |
| 3 | usage, invalid_plate, unknown_dataset |
|
| 4 | not_found: no requested plate is registered |
|
| 5 | timeout |
yes |
| 6 | rate_limit: RDW returned HTTP 429 |
yes |
| 7 | api: RDW answered with an error |
|
| 8 | io: the answer was fetched but could not be written to stdout |
A consumer that closes the pipe early, as | head does, is not an io failure:
it got what it asked for, and the run exits 0.
Errors are a single JSON object on the last line of stderr:
$ kenteken lookup 'not a plate'; echo "exit=$?"
{"error":{"kind":"invalid_plate","message":"not a valid Dutch licence plate: 'NOTAPLATE' has 9 characters, every Dutch plate has 6","exit_code":3,"retryable":false,"hint":"plates are six letters and digits, e.g. V-95-JKV","details":{"input":"not a plate"}}}
exit=3
kenteken schema declares every error kind, its exit code and whether a retry
can behave differently, so a consumer can branch without parsing prose.
Requests to RDW
RDW is a free public service, so the tool is deliberately quiet:
- No request is ever retried automatically. Transient failures are reported as
retryableand the caller decides. --concurrency(default 4) is capped at 8.- Malformed plates and datasets with no
kentekencolumn are refused locally. defectsneeds no second request for the code table; it is embedded.
Development
make check # fmt, clippy -D warnings, and the test suite
make test
make conformance # clispec score ./target/release/kenteken
The test suite never touches the network. run is generic over an RdwSource
trait that tests substitute, and the HTTP client is exercised against a fake RDW
on a local socket.
Licence
MIT. RDW open data is published in the public domain; this tool is not affiliated with RDW.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distributions
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file kenteken-0.1.0.tar.gz.
File metadata
- Download URL: kenteken-0.1.0.tar.gz
- Upload date:
- Size: 110.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e70d70a9502a973e1183fe1110e62f4b6f40e89bfbf0532ca2c4f2095ec33f45
|
|
| MD5 |
3b10f17549197c406619d5a5b80def65
|
|
| BLAKE2b-256 |
a6381d989e098bcd4158ad19f3a7074ebd19efa170febb52e37d9ca292f857cf
|
File details
Details for the file kenteken-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.
File metadata
- Download URL: kenteken-0.1.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- Upload date:
- Size: 1.9 MB
- Tags: Python 3, manylinux: glibc 2.17+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2def391f4c91704a72b219d384539a240a39293c945d9bc30a31872ad23f5b7e
|
|
| MD5 |
51bf6cdb5dc8b6a87936688218ac8f5f
|
|
| BLAKE2b-256 |
645833b1cc49001b4f72132ddca7150d87439bb97afac728c37fe3792a98c7fb
|
File details
Details for the file kenteken-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.
File metadata
- Download URL: kenteken-0.1.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
- Upload date:
- Size: 1.9 MB
- Tags: Python 3, manylinux: glibc 2.17+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f95a3e3780f73f3284a9e1ec65ff1d90040f4fe217c78eaba39565d014307946
|
|
| MD5 |
e246c252cfce6959886eb30ffe0ac8ff
|
|
| BLAKE2b-256 |
261ff51daa932b049282cba779a3912e26fbf7231f149b1a85c3815f7e3dd6af
|
File details
Details for the file kenteken-0.1.0-py3-none-macosx_11_0_arm64.whl.
File metadata
- Download URL: kenteken-0.1.0-py3-none-macosx_11_0_arm64.whl
- Upload date:
- Size: 1.8 MB
- Tags: Python 3, macOS 11.0+ ARM64
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
72c755fc0ff80141b89ca4dd51aa3c44993ef17bd480e814e14fcfdda1c56bc8
|
|
| MD5 |
8763748cee84702a3c08858bb0743139
|
|
| BLAKE2b-256 |
623b398cfb4b273e4a0633cc6c5b34b58e35d970b9c2fbbd857e99e02bfde657
|
File details
Details for the file kenteken-0.1.0-py3-none-macosx_10_12_x86_64.whl.
File metadata
- Download URL: kenteken-0.1.0-py3-none-macosx_10_12_x86_64.whl
- Upload date:
- Size: 1.9 MB
- Tags: Python 3, macOS 10.12+ x86-64
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
06a769451ec87b994fb52b31f8a38da13dd1d5e25dc85b379d9eca0a16b3704d
|
|
| MD5 |
f4918c0f1cf34b5336995f4ab25fde63
|
|
| BLAKE2b-256 |
d79079d0fd6afd8f4eb34282d2b5a4aa94c497689b867cec048df3cc638e43a2
|