ADR tunnel compliance, enforced in costing
A tanker whose load carries ADR tunnel restriction code C/E may not pass
through a category-D tunnel under the worst-case reading of ADR 8.6.4. MapMap
knows that inside the cost model, before a route exists, so the geometry that
comes back is one the vehicle can legally drive rather than one you have to
check afterwards. We also sell the decision on its own: POST /adr/check
returns the tunnel-entry verdict, with the rule that produced it, and no
routing at all, at 1p per call. As of July 2026, MapMap is the only navigation
API offering a self-serve ADR tunnel-compliance product at published per-call
prices.
What does an ADR tunnel restriction code actually restrict?
It restricts which categories of tunnel a dangerous-goods load may pass through, and nothing else. Two attributes meet at the tunnel mouth. The tunnel has a category from A to E, assigned by the national competent authority and posted on the approach. The load has a restriction code from ADR Table A, column (15), and under ADR 8.6.3 the code of the whole load is the most restrictive code of any item on board.
The four unconditional codes are the simple case:
| Code | Passage forbidden through |
|---|---|
B | Categories B, C, D and E |
C | Categories C, D and E |
D | Categories D and E |
E | Category E |
The rest are conditional. B1000C bites at category B only above 1,000 kg
total net explosive mass per transport unit. C5000D bites at category C only
above 5,000 kg. The slashed codes (B/D, B/E, C/D, C/E, D/E) apply
their first clause only when the goods travel in tanks, or in bulk in the case
of D/E. A routing API does not know your net explosive mass and does not know
whether today's movement is in tanks, so we read every conditional clause as
applying. That collapses each code to a single threshold, which is its first
letter: passage is forbidden when the tunnel category is greater than or equal
to that threshold. The full 12-code table, including the ADR no-restriction
entry, is on the API conventions page.
The direction of the error matters and it is deliberate. The worst-case reading can route a load around a tunnel it was in fact entitled to use. It will never send a load through a tunnel ADR could forbid.
Why we enforce the tunnel category in costing rather than post-filtering
Because post-filtering answers a question nobody asked. If you plan a route first and then scan it for forbidden tunnels, all you learn is whether that one route happens to be legal. When the answer is no, you plan again, and nothing about the second attempt makes it any more legal than the first. You end up looping, or you end up shipping a "compliance check" that is really a rejection notice.
We push the restriction down into the cost model instead. A tunnel the load cannot enter is not an edge the search can traverse, so the shortest legal path is simply what the search returns. There is no second pass. Either you get a route the vehicle can drive, or you get no route, and no route is honest information: it means the destination is not reachable under that load's restrictions and someone needs to make a decision a router cannot make.
Dimensional gates work at the same layer for the same reason. Height, width, length, weight and axle load are checked edge by edge against the OSM limits during the search, not against the finished line on the map. We wrote that up separately in truck routing and vehicle dimensions.
How do you get a compliance decision with no routing at all?
POST /adr/check takes a vehicle profile and a tunnel category and returns the
ADR 8.6.4 entry decision. No coordinates, no geometry, no map. It exists because
the compliance question and the routing question are genuinely separate, and
plenty of the people who need the first one are not planning a journey: they are
answering a question about a load, a tunnel and a rule.
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -X POST "$BASE/adr/check" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"adr": {
"dimensions": {
"height_m": 4.0, "width_m": 2.55, "length_m": 16.5,
"gross_weight_t": 44.0, "axle_load_t": null, "axle_count": null
},
"tunnel_code": "C/E",
"hazmat": true
},
"tunnel_category": "D"
}'
The response is two fields:
{
"status": "blocked",
"reason": "ADR 8.6.4: tunnel restriction code C/E forbids passage through tunnels of category D (worst-case reading; conditional clauses assumed to apply)"
}
A permitted load gets the short form, with no reason key at all:
{ "status": "allowed" }
tunnel_category accepts "A" through "E". tunnel_code accepts the
canonical ADR notation and is nullable. hazmat and dimensions are required.
The same decision is available to coding agents as the check_adr_tunnel tool
on our MCP server.
Why a compliance decision you cannot explain is useless
Because the transport manager has to defend it, and "the API said no" is not a defence. When a load is turned back, or an insurer asks why a vehicle was on a particular road, or an auditor pulls six months of planning records, the person answering needs the rule, not the verdict. A boolean is not evidence.
So reason is a sentence with four things in it: the section of the agreement
that applied (ADR 8.6.4), the restriction code that was in force (C/E), the
tunnel category it was tested against (D), and the disclosure that this was the
worst-case reading with conditional clauses assumed to apply. That last clause
is the honest part. It tells a reader who knows their load better than we do
exactly where our answer might be more conservative than the law requires, and
it does so in the response rather than in a footnote on a docs page they will
never read.
Log the string. It is written to be pasted into an audit trail and to still make sense to a human 18 months later.
The asymmetry is deliberate and worth naming: an allowed decision carries no
reason, because there is no restriction to cite. If you need a positive
record, record the request alongside the response.
How do you route the load rather than just check it?
Two ways, depending on which client you already have.
The compatible URL endpoint takes the load as vendor query parameters, so OSRM-shaped tooling can send them with no structural change:
curl "$BASE/route/v1/truck/-0.1276,51.5072;-1.8904,52.4862?height=4.0&weight=44.0&hazmat=true&tunnel_code=D" \
-H "Authorization: Bearer $API_KEY"
| Param | Unit | Example |
|---|---|---|
height | metres | height=4.0 |
width | metres | width=2.55 |
length | metres | length=16.5 |
weight | tonnes | weight=44.0 |
hazmat | boolean | hazmat=true |
tunnel_code | ADR code | tunnel_code=D |
The native POST /route takes the whole load as a top-level adr object
alongside a Valhalla-style body. dimensions, tunnel_code (nullable) and
hazmat are all required keys, and the gateway merges them into
costing_options.truck for you:
{
"locations": [
{ "lat": 51.5072, "lon": -0.1276 },
{ "lat": 52.4862, "lon": -1.8904 }
],
"costing": "truck",
"adr": {
"dimensions": {
"height_m": 4.0, "width_m": 2.55, "length_m": 16.5,
"gross_weight_t": 44.0, "axle_load_t": null, "axle_count": null
},
"tunnel_code": "C/E",
"hazmat": true
}
}
If your own costing_options.truck contradicts what the adr profile implies,
that is a 400 costing-conflict with the offending fields listed, not a silent
pick of one over the other. Three semantics are worth knowing before you send
anything: "hazmat": false means the tunnel matrix does not apply at all
(dimensional limits still do), "hazmat": true with no tunnel_code is treated
as code B because the load's code is unknown, and the adr extension is
honoured only on POST /route and POST /optimise.
What it costs
ADR compliance is our premium class, priced at 1p per call. That is 20 times the 0.05p standard rate, and we are not going to pretend otherwise: it is the thing we built, and it is worth more than a car route.
| Monthly volume | Premium price per call |
|---|---|
| Included free tier | 50,000 included calls, of which a premium call draws 20 (so 2,500 all-premium calls) |
| 50,001 to 1,000,000 | 1p |
| 1,000,001 to 10,000,000 | 0.7p |
| Beyond 10,000,000 | 0.4p |
Bands are marginal, so each price applies only to the calls that fall inside it.
The premium class is selected automatically per request: /adr/check,
"costing": "truck", a top-level adr object, "hazmat": true, or a truck
profile or truck query parameter on the compatible endpoint. Everything else
bills as standard. Payment is prepaid credit only, so there is no post-paid
overage and no surprise invoice: with no credit you get a 402 or a 429.
Prices reconcile with /pricing.json, which
wins if this page ever drifts.
What this does not do
ADR is a legal framework and we implement one part of it: the tunnel-category entry decision defined in 8.6.4. We do not classify your goods, assign the restriction code, handle placarding, driver certification, national derogations or time-of-day tunnel orders. You give us the code, we give you the decision. The operator remains responsible for the movement.
Four more limits worth having in writing:
- The worst-case reading is conservative on purpose. Quantity-conditional and tank/bulk-conditional codes are assumed to apply, so a load may be routed around a tunnel it was entitled to use. The error only ever runs in the safe direction, but it is a real error and it will cost you distance.
- Tunnel categories come from OpenStreetMap tags. We read
hazmat:adr_tunnel_cat, thenhazmat:tunnel_cat, then infer from per-codehazmat:B=nostyle tagging on any way, tunnels and their signed approach corridors alike, including thehazmat=noplushazmat:E=yesallowance style used on Dutch tunnel routes. A tunnel with no category tagged is treated as category A, no restriction. That is the correct legal default, since ADR restrictions apply where a category has been assigned and signed, but it does mean coverage depends on the data. - Tunnel-code enforcement needs an ADR-capable routing backend. Against a
backend without ADR costing, the dimensions still apply but the tunnel
restriction code is not enforced, and there is no per-response warning field
to tell you.
POST /adr/checkis always authoritative. - The analysis endpoints apply no ADR restrictions.
/isochrone,/matrix,/trace_routeand/trace_attributesignore anadrobject if you send one. Do not build a dangerous-goods compliance step on top of them.
Try it
Run POST /adr/check from the browser with no install at the
playground, or read the request and response shapes in full in
the API reference. A key comes from one card-free call
to POST /v1/keys and works immediately.
Routing derives from OpenStreetMap, so credit "© OpenStreetMap contributors" when you render or republish it.
