Routing API
Five endpoints over a road network covering all of Canada: a route between two points, an isochrone around one point (the area you can reach from it inside a time budget, returned as a polygon), a matrix of travel times between many points, map matching, which snaps a GPS trace onto the road network, and stop-order optimization, which orders a set of stops and routes through them. All five are computed by Valhalla, the open-source routing engine.
Every request needs an API key; see Authentication.
Coordinates are always lng,lat, longitude first, in plain degrees. The road network covers
Canada only, which is the single most useful thing to know before you start. "Coverage and what fails" below
spells out exactly which requests that rules out.
Here is a real route across downtown Calgary, drawn from the response beside it:
import { Unmap } from "@unmap/sdk";
const unmap = new Unmap({
key: "um_live_...",
container: "map",
center: [-114.03, 51.07],
zoom: 11,
});
const route = await unmap.router.route([-114.0719, 51.0447], [-113.9871, 51.0899], { mode: "auto" });
unmap.map!.on("load", () => {
unmap.map!.addSource("route", {
type: "geojson",
data: { type: "Feature", properties: {}, geometry: route.geometry },
});
unmap.map!.addLayer({
id: "route-line",
type: "line",
source: "route",
layout: { "line-cap": "round", "line-join": "round" },
paint: { "line-color": "#FF3E9A", "line-width": 3 },
});
});@unmap/routing is the standalone client (no map dependency); @unmap/sdk exposes the same object
as unmap.router. Everything below is the HTTP contract underneath both.
Route
Coverage is Canada only, and some pairs cannot be routed at all. See Coverage and what fails before you build around a route that can fail.
GET /route
from(required):lng,lat, the origin.to(required):lng,lat, the destination. There are no intermediate waypoints. A request carries exactly two points, and a third comma-separated number in either value is a400, not a via point.mode(optional, defaultauto): one ofauto,car,bicycle,pedestrian,truck,transit.carandautoare the same driving profile;carexists becauseautoreads as "automatic" to anyone who does not know Valhalla. Anything else is a400:{ "error": "mode must be one of auto, car, bicycle, pedestrian, truck, transit" }. Until 2026-09-03 an unrecognised value silently fell back toauto; it no longer does.depart_at(mode=transitonly): an ISO 8601 date-time. A value with an offset orZidentifies an instant; a value without one uses the transit feed's local timezone. It is required for transit and rejected on every other mode. There is noarrive_byoption.- Truck profile (optional,
mode=truckonly):height,width,lengthin metres,weightandaxle_loadin tonnes,axle_countas a whole number,hazmatastrueorfalse, anduse_truck_routefrom0to1. See "Truck routing" below for defaults, ranges, and what the data does and does not cover. Any of these on a mode other thantruckis a400. avoid(optional): a comma-separated list oftolls,highways,ferries. A preference, not an exclusion: see "Avoidance" below before you rely on it.tollsandhighwaysare driving modes only;ferriesworks on every road mode. Transit rejects all three. An unknown value, an empty value, or one the mode has no setting for is a400.avoid=highwaysanduse_highwaysare the same setting and cannot both be sent.use_highways(optional, driving modes only):0to1. Willingness to take motorways and trunks.0avoids them,1prefers them. Omitted, Valhalla uses its own default (0.5). Allowed onauto,car, andtruck. Onbicycleorpedestrianit is a400:{ "error": "use_highways applies only to mode=auto, car, or truck" }. This is Valhalla's existing costing option on the Canada graph, not a second router. See "Industry routing" below.use_hills(optional, cycling and walking only):0to1. Willingness to climb.0avoids hills even if the route gets longer,1is indifferent. Omitted, Valhalla uses its own default (0.25onbicycle,0.5onpedestrian). Allowed onbicycleandpedestrian. Onauto,carortruckit is a400:{ "error": "use_hills applies only to mode=bicycle or pedestrian" }. See "Hills" below for why driving is excluded.
curl "https://api.unmap.dev/route?from=-114.0719,51.0447&to=-113.9871,51.0899&mode=auto" \
-H "Authorization: Bearer $UNMAP_API_KEY"import { Router } from "@unmap/routing";
const router = new Router({ key: "um_live_..." });
const route = await router.route([-114.0719, 51.0447], [-113.9871, 51.0899], { mode: "auto" });{
"distanceMeters": 15627,
"durationSeconds": 991,
"geometry": {
"type": "LineString",
"coordinates": [[-114.071903, 51.044666], [-114.072726, 51.044691], /* … */]
}
}distanceMeters: road distance, rounded to a whole metre.durationSeconds: estimated travel time, rounded to a whole second.geometry: a plain GeoJSONLineStringof[lng, lat]pairs at six decimal places, which is about 0.1 m. The first and last coordinates are snapped to the nearest routable edge, so they sit near the points you asked for rather than exactly on them. For a point on a road that is within 10 m, and for a street address within about 50 m.warnings(present only when there is something to say, on every mode): route-level notices. Branch oncode, which is stable; the text may be reworded.
Read warnings before you trust an endpoint. If there is no road near the point you asked
for, the routing engine does not refuse. It moves the point to the nearest road it can use and
returns a confident route to somewhere else. Asked for a route between two places with no road
between them, it answered 200 with a 10.1 km route having relocated the destination 292.3 km,
and in a dense province it has been measured relocating by 19 km. You still get a route, because
it is usually a good route that simply ends short, but ENDPOINT_RELOCATED tells you it does not
reach the place you named:
{
"distanceMeters": 10142,
"durationSeconds": 913,
"geometry": { "type": "LineString", "coordinates": [/* … */] },
"warnings": [
{
"code": "ENDPOINT_RELOCATED",
"where": "destination",
"message": "The route's destination is 292 km from the point requested: no road the vehicle can use was found nearer, so the engine moved it."
}
]
}code | What it means | What to do |
|---|---|---|
ENDPOINT_RELOCATED | The route does not begin or end at the point you sent. where says which end, and the message carries the distance | Treat the endpoint as unreached. This is what a well pad, a remote lease or a new subdivision looks like |
ENDPOINT_RELOCATION_NOT_CHECKED | The route came back without a usable shape, so the check could not run | Do not read it as "the endpoints are fine" |
CLOSURE_IN_FORCE_NEAR_ROUTE | A provincial authority has a road shut within 250 m of the path, right now | Expect the route to be wrong. The graph is rebuilt weekly, so it cannot route around a closure |
CLOSURE_SCHEDULED_NEAR_ROUTE | The same, starting later. effectiveFrom says when | Nothing, for a trip today. Worth surfacing for a trip being planned |
This is not a truck-only concern, and on mode=auto relocation fires more often than on
mode=truck, because car costing can reach road fragments a truck cannot. On mode=truck the same
findings also appear inside truck.validation, which is a self-contained compliance report;
warnings is the place to look whatever mode you asked for.
Closure coverage is Ontario, Alberta and British Columbia, which is where authoritative feeds exist. Snapshots are refreshed every five minutes by the gateway, never fetched while your request is in flight, so an outage at a provincial feed cannot become an outage here. Outside those three provinces the absence of a closure warning means nothing was checked, not that nothing is closed. A closure is reported as being NEAR your route rather than on it: most are published as a single point, so proximity is the honest claim.
The gateway does not pass Valhalla's own response format through. It maps the trip into this flat shape so your code is not coupled to Valhalla's internal contract. There is no turn-by-turn narration in the response: the gateway asks Valhalla to skip generating it.
The polyline is not simplified, so long routes are large. The Calgary to Halifax drive comes back as 4,867,062 m over 34,283 coordinates. Simplify client-side if you are only drawing an overview.
Errors. A from or to that is missing, is not exactly two comma-separated values, or is not
two finite numbers gets a 400: { "error": "from and to must be lng,lat" }. An empty half,
as in from=,, is a 400 too.
A pair Valhalla cannot route gets a 422 with { "error": "no route" }. Any other upstream
failure gets a 502 with the same body, so branch on the status code, not the message.
Isochrone
Coverage is Canada only, and some pairs cannot be routed at all. See Coverage and what fails before you build around a route that can fail.
GET /isochrone
lon,lat(required): the centre point, as two separate parameters here rather than the singlelng,latpair/routetakes.minutes(required): a comma-separated list of travel-time bands, e.g.5,10,15. Fractional values are accepted. At most four bands, each 120 minutes or less; see the limits below.mode(optional, defaultauto):auto,car,bicycle,pedestrian,truck, validated exactly as on/route, and taking the same truck profile parameters.avoid,use_highways,use_hills(optional): the same costing preferences as/route, validated the same way. A band drawn withavoid=ferriesis the area reachable without a ferry.
curl "https://api.unmap.dev/isochrone?lon=-114.0719&lat=51.0447&minutes=10,20&mode=pedestrian" \
-H "Authorization: Bearer $UNMAP_API_KEY"const bands = await router.isochrone([-114.0719, 51.0447], {
minutes: [10, 20],
mode: "pedestrian",
});import { Unmap } from "@unmap/sdk";
const unmap = new Unmap({
key: "um_live_...",
container: "map",
center: [-114.0719, 51.0447],
zoom: 12,
});
const bands = await unmap.router.isochrone([-114.0719, 51.0447], { minutes: [10, 20], mode: "pedestrian" });
unmap.map!.on("load", () => {
unmap.map!.addSource("bands", { type: "geojson", data: bands });
unmap.map!.addLayer({
id: "bands-fill",
type: "fill",
source: "bands",
paint: { "fill-color": "#FF3E9A", "fill-opacity": 0.18 },
});
});Returns a GeoJSON FeatureCollection, one Polygon or MultiPolygon feature per requested band,
passed through from Valhalla unmodified. Valhalla already returns exactly the shape a caller wants
here, so there is nothing to translate.
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"contour": 20,
"metric": "time",
"color": "#bf4040",
"opacity": 0.33,
"fill": "#bf4040",
"fillColor": "#bf4040",
"fillOpacity": 0.33,
"fill-opacity": 0.33
},
"geometry": { "type": "Polygon", "coordinates": [/* … */] }
}
]
}contour: the band this polygon belongs to, in the same units you asked for. Match features back to your request on this, not on array position.metric: alwaystimehere. The gateway only ever requests time contours, never distance ones.color,opacity,fill,fillColor,fillOpacity,fill-opacity: Valhalla's own suggested styling, in the several spellings different mapping libraries expect. It is a green-to-red ramp Valhalla picks for you, not anything unmap chose, and you are free to ignore all of it and colour bycontourinstead.
Features arrive largest band first. That is already the right paint order for filled polygons: draw them in the order given and the small bands land on top of the large ones.
// The collection is drop-in for MapLibre, styled from Valhalla's own properties.
map.addSource('bands', { type: 'geojson', data: bands })
map.addLayer({
id: 'bands-fill',
type: 'fill',
source: 'bands',
paint: { 'fill-color': ['get', 'color'], 'fill-opacity': ['get', 'fillOpacity'] },
})Limits. At most four contours per request, and no contour longer than 120 minutes. Both are
Valhalla's own service limits, checked at the gateway so they come back as a 400 that names the
rule rather than an upstream failure:
- More than four values in
minutes:{ "error": "at most 4 minutes contours" }. - Any value above 120:
{ "error": "minutes must be 120 or less" }.
Each contour is a separate costing pass inside the routing container, which is also why the count is capped: an unbounded list would let one request do an unreasonable amount of work.
Errors. Values in minutes that are not finite numbers, or are zero or negative, are silently
dropped rather than rejected: minutes=10,abc,-5,20 returns the 10 and 20 minute bands and says
nothing about the two it discarded. Only an empty list after that filtering is an error. That
case, and a missing or non-numeric lon or lat, get a 400:
{ "error": "lon, lat and minutes required" }.
An empty lon=&lat= is a 400 as well. Any upstream failure is a 502:
{ "error": "isochrone failed" }.
Matrix
Coverage is Canada only, and some pairs cannot be routed at all. See Coverage and what fails before you build around a route that can fail.
Live on api.unmap.dev. This endpoint sits behind a per-deployment feature flag, which is set
here, so it answers normally. On a deployment where the flag is unset it answers 404,
indistinguishable from a path that does not exist at all, so a 404 means not enabled rather than
a typo in your URL.
POST /matrix
origins(required): an array of[lng, lat]pairs, up to 10.destinations(required): an array of[lng, lat]pairs, up to 10.mode(optional, defaultauto): the same five travel modes as/route.- Truck profile (optional,
mode=truckonly): the same fields as/route. See "Truck routing" below. avoid(optional): the same list as/route, as a JSON array:["tolls", "ferries"].use_highways(optional, driving modes only): the same 0-to-1 preference as/route.use_hills(optional, cycling and walking only): the same 0-to-1 preference as/route.
Coordinates travel in the JSON body rather than the query string; the key still travels the same
way as on the two endpoints above, as an Authorization header, X-API-Key, or ?key=.
curl -X POST "https://api.unmap.dev/matrix" \
-H "Authorization: Bearer $UNMAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"origins":[[-114.0719,51.0447]],"destinations":[[-113.9871,51.0899],[-114.1,51.05]],"mode":"auto"}'const res = await fetch('https://api.unmap.dev/matrix', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
origins: [[-114.0719, 51.0447]],
destinations: [[-113.9871, 51.0899], [-114.1, 51.05]],
mode: 'auto',
}),
});
const times = await res.json();const origins: [number, number][] = [[-114.0719, 51.0447]];
const destinations: [number, number][] = [[-113.9871, 51.0899], [-114.1, 51.05]];
const times = await unmap.router.matrix(origins, destinations)
// Options come after the two arrays, same as route() and isochrone():
const withTruck = await unmap.router.matrix(origins, destinations, {
mode: "truck",
truck: { height: 4.2, weight: 36 },
});// 200
{
"durations": [[612, 984]],
"distances": [[7823, 15627]]
}durations: seconds. Row-major: one row per origin, one column per destination, in the order you sent them.distances: metres, the same shape.- A
0is a real answer: it is what you get on the diagonal of a request where an origin is also a destination, where the honest reply is zero seconds and zero metres, not "unreachable". Verified live: a Calgary/Banff matrix returns exactly0on the diagonal, nevernull. - If any origin-destination pair is unreachable, or beyond the engine's own road-distance
limit, the whole request fails as a
422instead of returning a cell for it. Anullcell never appears in practice. This corrects an earlier version of this page, which said anullcell meant unreachable; probing the deployed API showed Valhalla fails the wholesources_to_targetsrequest the same way it fails a bad/route, not per pair. The observed road-distance ceiling is somewhere between roughly 212 km and 300 km of road (Calgary to Lethbridge at 212 km succeeds, Calgary to Edmonton at about 300 km does not); the exact number is Valhalla's ownmax_matrix_distance, which this deployment does not set and has not yet read. The(number | null)[][]type and thenullhandling in the SDK are both kept: they cost nothing, are correct for a real0, and stay correct if a future engine version starts nulling cells instead of failing outright.
Limits. At most 10 origins and 10 destinations. Either exceeded is a 400:
{ "error": "at most 10 origins", "code": "too_many_locations" } (or the equivalent naming
destinations). Both numbers are deliberately conservative: nobody has yet probed Valhalla's own
service limit for this endpoint on this deployment, so 10 by 10 is a floor chosen to guarantee the
gateway's own 400 is reached before any upstream one would be, not a claim about the true
ceiling.
Errors. A missing or malformed origins or destinations is a 400:
{ "error": "origins and destinations must be arrays of lng,lat pairs", "code": "invalid_location" }.
A body that is not JSON, is not a JSON object, or is larger than 64,000 bytes is a 400 with
code: "invalid_body", before your coordinates are even read. A pair that is unreachable or
beyond the distance limit is a 422 with
{ "error": "no matrix: a pair is unreachable or beyond the engine's distance limit", "code": "no_route" }.
Any other upstream failure is a 502 with code: "routing_failed".
Use case. A dispatcher choosing which of several drivers to send to a new job: one call, with the job as the single destination and every driver's current position as an origin, returns every driver's real travel time in one round trip, ready to sort by duration.
Match
Coverage is Canada only, and some pairs cannot be routed at all. See Coverage and what fails before you build around a route that can fail.
Live on api.unmap.dev. Like /matrix, this endpoint has its own feature flag, set here. Where
it is unset the endpoint answers 404, meaning not enabled rather than a bad URL.
POST /match
coordinates(required): a GPS trace as an array of[lng, lat]pairs, in the order recorded. At least 2, at most 100.mode(optional, defaultauto): the same five travel modes as/route.- Truck profile (optional,
mode=truckonly): the same fields as/route. avoid(optional): the same list as/route, as a JSON array:["tolls", "ferries"].use_highways(optional, driving modes only): the same 0-to-1 preference as/route.use_hills(optional, cycling and walking only): the same 0-to-1 preference as/route.
curl -X POST "https://api.unmap.dev/match" \
-H "Authorization: Bearer $UNMAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"coordinates":[[-114.0719,51.0447],[-114.0705,51.0451],[-114.069,51.0458]],"mode":"auto"}'const res = await fetch('https://api.unmap.dev/match', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
coordinates: [[-114.0719, 51.0447], [-114.0705, 51.0451], [-114.069, 51.0458]],
mode: 'auto',
}),
});
const cleaned = await res.json();const coordinates: [number, number][] = [
[-114.0719, 51.0447],
[-114.0705, 51.0451],
[-114.069, 51.0458],
];
const cleaned = await unmap.router.match(coordinates)// 200
{
"distanceMeters": 412,
"durationSeconds": 58,
"geometry": {
"type": "LineString",
"coordinates": [[-114.071903, 51.044666], [-114.070812, 51.045104], [-114.069318, 51.045802]]
}
}Returns the same shape /route returns: a matched trace is a route. The geometry usually carries
more points than you sent, because it follows the road network between your recorded points
rather than connecting them with straight lines.
Limits. At least 2 and at most 100 coordinates. Fewer than 2 is a 400:
{ "error": "coordinates must be at least 2 lng,lat pairs", "code": "invalid_location" }. More
than 100 is a 400: { "error": "at most 100 coordinates", "code": "too_many_locations" }. Like
the matrix caps, 100 is a conservative floor rather than a measured ceiling.
Errors. A body that is not JSON, is not a JSON object, or is larger than 64,000 bytes is a
400 with code: "invalid_body". Like /route and /matrix, a trace the engine understood but
could not snap to any road is a 422:
{ "error": "no match: the trace could not be snapped to the road network", "code": "no_route" }
(confirmed against the deployed gateway: an open-water trace in Hudson Bay returns a 422).
Any other upstream failure is a 502 with code: "routing_failed".
Use case. Cleaning up a delivery van's raw GPS trail before billing a customer for distance
driven: feed the recorded points into /match and get back the actual road distance, instead of
the noisier straight-line sum a raw GPS log would give you.
Optimize
Coverage is Canada only, and some pairs cannot be routed at all. See Coverage and what fails before you build around a route that can fail.
Rolling out. Like /matrix and /match, this endpoint is behind its own feature flag and answers
404 until the founder enables it for a deployment. A 404 here means not yet available, not a
bad URL.
POST /optimized-route
Given a set of stops, returns the order to visit them in, plus the route through them. The first stop is the fixed start and the last is the fixed end; only the stops between them are reordered. That is a real constraint of the underlying engine, not a toggle this API withholds: there is no "round trip" or "open route" option.
stops(required): an array of[lng, lat]pairs. At least 3, at most 10.mode(optional, defaultauto): the same five travel modes as/route.- Truck profile (optional,
mode=truckonly): the same fields as/route. avoid(optional): the same list as/route, as a JSON array:["tolls", "ferries"].use_highways(optional, driving modes only): the same 0-to-1 preference as/route.use_hills(optional, cycling and walking only): the same 0-to-1 preference as/route.
curl -X POST "https://api.unmap.dev/optimized-route" \
-H "Authorization: Bearer $UNMAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"stops":[[-114.07,51.05],[-113.98,51.04],[-114.12,51.03],[-114.04,51.08]],"mode":"auto"}'const res = await fetch('https://api.unmap.dev/optimized-route', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
stops: [[-114.07, 51.05], [-113.98, 51.04], [-114.12, 51.03], [-114.04, 51.08]],
mode: 'auto',
}),
});
const tour = await res.json();const stops: [number, number][] = [
[-114.07, 51.05],
[-113.98, 51.04],
[-114.12, 51.03],
[-114.04, 51.08],
];
const tour = await unmap.router.optimize(stops)// 200
{
"order": [0, 2, 3, 1],
"distanceMeters": 38200,
"durationSeconds": 3410,
"geometry": {
"type": "LineString",
"coordinates": []
}
}order: indices into thestopsyou sent, in visiting order. The deployed engine keeps the first and last stops fixed; the credentialed corpus verifies that the returned order starts at 0, ends at the last stop's index, and contains every stop exactly once.distanceMeters,durationSeconds,geometry: the same fields/routereturns, for the full tour through every stop inorder. An optimized route is a route with one extra field.
Limits. At least 3 and at most 10 stops. Fewer than 3 is a 400:
{ "error": "stops must be at least 3 lng,lat pairs", "code": "invalid_location" }, because two
stops have exactly one ordering and the answer you want is /route. More than 10 is a 400:
{ "error": "at most 10 stops", "code": "too_many_locations" }. Ten rather than a larger number
some routing APIs advertise, because the optimizer solves an N-by-N matrix over the stops
internally, and ten stops is 100 pairs: exactly what /matrix is already capped at. A larger cap
would be a larger matrix against a ceiling this deployment has not read. A body that is not JSON, is not a JSON object, or is larger than 64,000 bytes is a 400
with code: "invalid_body", before stops is even read.
Observed errors. The credentialed corpus runs these checks against the deployed gateway on
every push to main:
- Four stops around Calgary return
200with a complete order and geometry. - A tour containing Calgary and Lutselk'e fails as a whole rather than returning a partial tour,
mapped to a
422with{ "error": "no optimized route: a stop is unreachable or beyond the engine's distance limit", "code": "no_route" }. - That Calgary-to-Lutselk'e leg is roughly 1,400 km, so the engine reaches its distance ceiling before road connectivity can be isolated. It proves that this endpoint optimizes a delivery round, not a territory. The exact optimizer ceiling has not been bracketed independently.
- Eleven stops return
400too_many_locationsat the gateway, and two stops return400invalid_locationwith directions to use/route. - Any other upstream failure is a
502withcode: "routing_failed". A response the gateway cannot map to a valid tour is also a502, not a fabricatedorder: a plausible-looking wrong order would be worse than an error, since a dispatcher would drive it.
Use case. A dispatcher with a van and a morning's deliveries around one city: eight addresses
go in as stops, order comes back as the sequence to drive them in, and distanceMeters /
durationSeconds are the total for the round, ready to hand to a driver alongside the geometry on
a map.
Truck routing
Developer Preview. The API and validation corpus are live, but Canadian restriction coverage is still too sparse for a general-availability safety claim. Expect additive validation and data quality improvements while the request and response shapes remain stable.
mode=truck runs Valhalla's truck costing over the same graph: it inherits driving behaviour,
then excludes roads whose tagged height, width, length, weight or axle-load limit your vehicle
exceeds, avoids roads closed to heavy goods vehicles, and can prefer designated truck routes.
This is included with normal routing, on every plan, and always has been. So are all the vehicle profile parameters below. A truck route is one call from your plan's allowance, with no multiplier and no separate truck meter.
The Truck Intelligence add-on layers on the part that is expensive to
build: Canadian government restriction data, validation against the vehicle you declared, and
explicit coverage and provenance reporting. Without it, /route?mode=truck still returns the same
route, and the truck block reports that nothing was checked, with every coverage class reading
not_checked and a TRUCK_INTELLIGENCE_NOT_ENABLED warning. This endpoint never refuses over the
add-on, and closure warnings are not gated by it on any mode. That page lists exactly which
jurisdictions and restriction classes are active today.
curl "https://api.unmap.dev/route?from=-114.0719,51.0447&to=-113.9871,51.0899&mode=truck&height=4.2&weight=36&axle_count=6" \
-H "Authorization: Bearer $UNMAP_API_KEY"const haul = await router.route([-114.0719, 51.0447], [-113.9871, 51.0899], {
mode: "truck",
truck: { height: 4.2, weight: 36, axleCount: 6 },
});| Parameter | Unit | Default | Accepted |
|---|---|---|---|
height | metres | 4.11 | above 0, up to 10 |
width | metres | 2.6 | above 0, up to 6 |
length | metres | 21.64 | above 0, up to 50 |
weight | tonnes | 21.77 | above 0, up to 100 |
axle_load | tonnes | 9.07 | 0 to 40 |
axle_count | count | 5 | whole number, 2 to 20 |
hazmat | boolean | false | true or false |
use_truck_route | preference | 0 | 0 (ignore) to 1 (strongly prefer) |
Every parameter is optional and the defaults are Valhalla's, which describe a typical North
American semi-trailer. A value outside its range is a 400 naming the parameter, so a stray unit
mix-up (feet, pounds) fails loudly instead of producing a plausible-looking route.
What the data covers, honestly. Restrictions come from OpenStreetMap, because no level of Canadian government publishes a per-structure bridge clearance or load-posting inventory the way the US National Bridge Inventory does. That has consequences you should design around, all measured on the 2026-09-02 Canada extract:
- Height limits are sparse and uneven. About 20,000 ways carry a
maxheightagainst roughly 100,000 bridge ways, and most of those are in Nova Scotia. Ontario has around 1,300. - Weight limits are effectively absent. Fewer than 700 ways nationally carry a
maxweightand about 500 a per-axle load.weightandaxle_loadare honoured wherever data exists, which is almost nowhere yet, and no way in Canada carries an axle count at all, soaxle_countexcludes nothing today. - Conditional restrictions are not applied. Spring-thaw load limits, time-of-day truck bans and seasonal postings are not modelled.
- Truck route preference is a willingness, not a guarantee, and the top of the range is
expensive.
use_truck_routefollowshgv=designatedways, which are well mapped in British Columbia and poorly mapped in Québec. Even at1there is no guarantee the route uses a designated road: the parameter discounts designated roads and penalises everything else, so what it buys is a preference and what it costs is distance. Measured on a test graph with dense designated tagging, one trip went from 13.9 km atuse_truck_route=0to 33.2 km at1, and several others roughly doubled. If you set1, expect a materially longer route where the designated network is sparse or indirect. Mid-range values are the safer default for most callers, and0does not avoid truck routes, it simply stops preferring them.
One limit is engine behaviour, not data. Restrictions are not applied to the first and last
road segment of a route. A vehicle has to be able to leave where it is parked, so the segment you
start on is never excluded, whatever its posted limit. A 4.2 m vehicle routed from a point on a
3.5 m street is sent along that street, with no error, and the same pair requested in the other
direction fails to route at all. Check clearance and load at the origin and destination yourself.
A truck response carries ENDPOINT_RESTRICTIONS_UNCHECKED to say so whenever those two segments could not be cleared, which is most of the time and not all of it: when the check examines both and finds neither restricted, the warning is withheld rather than repeated as boilerplate.
This is truck-aware routing over open data, with the gaps disclosed. It is not a substitute for a permit check or a bridge-strike-prevention product. Verify oversize or overweight plans against the relevant provincial permit system.
Transit routing
mode=transit is live for TTC on a dedicated Valhalla graph. A request still needs depart_at.
The response adds scheduleType: "scheduled" and ordered legs for walking access, transit
rides and walking egress. Each transit leg carries the agency, route, headsign, scheduled times
and stops that the feed provides. Other static feeds stay schedule-only. Realtime data and
arrive_by are not supported. The feed list and remaining gaps are on the
Coverage page.
What the response tells you
A mode=truck response carries a truck object alongside the usual fields. It does not wrap
them, so code that reads distanceMeters today keeps working, and no other mode gains the field.
{
"distanceMeters": 15627,
"durationSeconds": 1042,
"geometry": { "type": "LineString", "coordinates": [/* … */] },
"truck": {
"profile": { "height": 4.2, "weight": 36, "axle_count": 6 },
"profileComplete": false,
"coverage": {
"truck_network": "osm_only",
"physical_restrictions": "osm_only",
"bridge_weight": "osm_only",
"bridge_clearance": "osm_only",
"hazmat": "osm_only",
"conditional_restrictions": "not_modelled",
"temporary_restrictions": "not_checked"
},
"verification": "partial",
"warnings": [
{
"code": "INCOMPLETE_VEHICLE_PROFILE",
"message": "Vehicle dimensions were not fully specified (width, length, axle_load). …"
},
{
"code": "ENDPOINT_RESTRICTIONS_UNCHECKED",
"message": "Restrictions are not applied to the first and last road segment of a route. …"
},
{ "code": "WEIGHT_DATA_SPARSE", "message": "…" }
]
}
}profile: the vehicle you sent, in the units you sent it in. It is never filled in with the defaults, so a dimension you omitted stays visibly omitted here.profileComplete:trueonly whenheight,width,length,weightandaxle_loadwere all supplied.axle_countis not required for this: the engine does honour an axle-count limit, but no Canadian way records one, so leaving it out changes nothing.coverage: one value per class of restriction, described below.verification: the overall picture, derived fromcoverageand the profile rather than set by hand, so the two cannot disagree.warnings: read these before you rely on a route. Branch oncode, which is stable;messageis prose and may be reworded.
There is deliberately no restrictions_applied field. Reporting an empty list would read as
"nothing restricted this route", which is a claim we cannot support until the chosen roads are
re-examined segment by segment. Silence is the honest answer, and the field will appear when it
can be filled in truthfully.
Coverage values. The point of having five is that "no restriction found" and "no data to look at" are different answers, and collapsing them into one word is how a routing API ends up implying a road is clear.
| Value | Means |
|---|---|
authoritative | Checked against a government source for this class |
osm_plus_authoritative | A government source covers part of the area, OpenStreetMap the rest |
osm_only | Community-mapped data only. This is provenance, not a grade |
not_modelled | The rule exists in the world but the engine cannot express it |
not_checked | Nothing looked at this class |
Today every class is osm_only, except conditional restrictions, which are not_modelled, and
temporary restrictions, which are not_checked. Those values will change per class as
authoritative sources are added, and the change will be visible here rather than silent.
Verification values. osm_only when the whole picture is community data and your profile was
complete. partial when your profile was incomplete, or when a government source covers only
some classes. authoritative when every class is government-checked, which no route qualifies
for yet. unknown and candidate_permit_route are reserved for routes that need permit
verification.
Warning codes.
| Code | When | What to do |
|---|---|---|
INCOMPLETE_VEHICLE_PROFILE | A dimension that can exclude a road was not supplied | Send the full profile. The defaults describe some other truck, not yours |
ENDPOINT_RESTRICTIONS_UNCHECKED | The first or last segment could not be cleared. Withheld when both were examined and neither is restricted | Check clearance and load at your origin and destination separately |
WEIGHT_DATA_SPARSE | You sent weight or axle_load | Do not read a route without a weight exclusion as a rated route |
HAZMAT_CLASSES_NOT_MODELLED | You sent hazmat=true | Apply your own per-class (TDG) and time-of-day rules on top |
Industry routing
The graph is Canadian OpenStreetMap plus the NRN-class public roads already in that extract.
Industry profiles (energy, agriculture, remote, mining) change overlays and search, not
the router. There is no winter-road graph, no resource-road costing, and no field-access
network.
curl "https://api.unmap.dev/route?from=-114.0719,51.0447&to=-113.9871,51.0899&mode=auto&use_highways=0" \
-H "Authorization: Bearer $UNMAP_API_KEY"const rural = await router.route([-114.0719, 51.0447], [-113.9871, 51.0899], {
mode: "auto",
useHighways: 0,
});use_highways is the one preference the graph can honour today: OSM already classifies
motorways and trunks. Setting it to 0 biases the same public-road engine away from those
classes. It does not prefer lease roads, open a winter road, or find a field entrance.
What a later industry router would need, and why those parameters are not exposed:
| Ask | Why it is not a query flag |
|---|---|
| Resource / industrial / lease roads | Access is permissioned and often seasonal. OSM track / service is not a lease graph. |
| Winter, ice, seasonal roads | Open/closed is a date, not a geometry. No licensed national extract is in the graph. |
| Field access / farm entrances | Entrances are not reliable OSM highway edges. |
avoid: ["restricted"] or avoid: ["winter"] | The graph does not model the restriction. A flag would invent a route. |
Do not treat a profile preset as routing input (remote.roads was retired; the basemap's own roads layer is visualization too). See
Coverage and Remote profile.
Avoidance
avoid keeps a route away from tolls, motorways or ferries:
curl "https://api.unmap.dev/route?from=-79.3832,43.6532&to=-79.8711,43.2557&avoid=tolls" \
-H "Authorization: Bearer $UNMAP_API_KEY"const noTolls = await router.route([-79.3832, 43.6532], [-79.8711, 43.2557], {
avoid: ["tolls"],
});It is a preference, not a guarantee, and the difference matters. Under the hood these are costing weights, not exclusions: asking to avoid tolls makes every toll road enormously expensive to the engine, but if the only way through is a toll bridge, the route takes the toll bridge. The response does not tell you that it did.
So avoid is the right tool for "prefer the free way if there is one" and the wrong tool for
"this vehicle is not permitted on toll roads". For a hard rule, check the returned geometry
against the roads you care about, or use "Avoid areas" below, which is one.
| value | what it weights against | modes |
|---|---|---|
tolls | toll roads and toll bridges | auto, car, truck |
highways | motorways and trunk roads | auto, car, truck |
ferries | ferry crossings | all five |
Combine them with commas: avoid=tolls,ferries. Asking for the same one twice is the same
request as asking once. A value outside the table is a 400 rather than a silently ignored word,
and so is asking a bicycle to avoid tolls, because a bicycle has no toll setting to turn down.
avoid=highways and use_highways are the same setting: one is the end of the dial, the other
is the dial. Sending both is a 400 even when they agree, so that there is one answer to "what
did I ask for" rather than a precedence rule to remember.
There is no unpaved. It is the obvious fourth entry and it is missing on purpose. The
driving engine has no notion of road surface at all; the nearest thing it has covers farm and
forest tracks, which is not what most of rural Canada's gravel is tagged as. Cycling has a real
surface setting, so one avoid=unpaved would have meant two different things depending on the
mode, and the driving one would have been close to a no-op that looked like a feature. It comes
back when we can say what it covers.
Avoid areas
Not enabled yet. POST /route and POST /isochrone return 404 while this
feature is disabled. On /matrix, /match, and /optimized-route, sending
avoid_areas returns 400 with code invalid_route_option. The examples below
will become available after engine validation and activation.
Keep a route out of a closure, a construction zone or an evacuation area by sending the geometry:
curl -X POST "https://api.unmap.dev/route" \
-H "Authorization: Bearer $UNMAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": [-114.0719, 51.0447],
"to": [-113.9871, 51.0899],
"avoid_areas": {
"type": "Polygon",
"coordinates": [[[-114.05,51.055],[-114.03,51.055],[-114.03,51.065],[-114.05,51.065],[-114.05,51.055]]]
}
}'// Any GeoJSON Polygon, MultiPolygon, or FeatureCollection of them.
import type { AvoidAreas } from "@unmap/routing";
const closure: AvoidAreas = {
type: "Polygon",
coordinates: [[[-114.05, 51.055], [-114.03, 51.055], [-114.03, 51.065], [-114.05, 51.065], [-114.05, 51.055]]],
};
const around = await router.route([-114.0719, 51.0447], [-113.9871, 51.0899], {
avoidAreas: closure,
});Private preview, currently unavailable. Valhalla defines excluded polygons as constraints,
but version 3.8.3 can snap a location across one. AVOID_AREAS_ENABLED therefore remains off and
these requests return 404. We will enable the endpoint only after an engine fix is deployed and
live probes prove a detour, an enclosed destination failure, and the public size cap.
It travels in a body, because a polygon does not fit in a query string. POST /route and
POST /isochrone take the same parameters as their GET forms and return the same responses;
the client library switches by itself when you pass avoidAreas, and stays on GET when you do
not. Sending avoid_areas as a query parameter is a 400 that names POST rather than ignoring
it. /matrix, /match and /optimized-route already had bodies and take it directly.
Interior rings are rejected. The engine cannot preserve a hole in an excluded polygon, so the
API returns 400 invalid_location instead of silently broadening or inverting your request.
Once enabled, the limits are at most 8 areas, 100 vertices and 10 km of perimeter in total. Rings
must be closed, use valid longitude and latitude ranges, and enclose an area. Over a size limit is
a 400 with code too_many_locations; malformed geometry is a 400 with code
invalid_location.
Hills
Routes and isochrones for bicycle and pedestrian account for terrain. Every edge in the graph
carries a grade, measured every 60 m from the same national 30 m elevation model that shades the
outdoor map styles, and cycling and walking times reflect the climb.
Driving does not. Valhalla's auto and truck costing have no grade term, so a drive over
Rogers Pass is priced exactly like the same distance across Saskatchewan. That is the engine's
model, not a gap in our elevation data, and it is why use_hills returns a 400 on the driving
modes instead of being quietly ignored.
use_hills biases the cycling and walking engine:
curl "https://api.unmap.dev/route?from=-123.1207,49.2827&to=-123.0700,49.3400&mode=bicycle&use_hills=0" \
-H "Authorization: Bearer $UNMAP_API_KEY"const gentle = await router.route([-123.1207, 49.2827], [-123.0700, 49.3400], {
mode: "bicycle",
useHills: 0,
});0 will take a longer, flatter way around a climb; 1 takes the direct line up it. The effect is
largest where the terrain is, so expect a visible difference in Vancouver, Calgary's escarpment
and the Gatineau hills, and very little on the prairie.
The elevation model is NRCan's MRDEM-30, at 30 m to 85 degrees north. Most routing engines reach for SRTM, which stops at 60 degrees north; ours has real terrain in Whitehorse, Yellowknife and Iqaluit.
There is no elevation profile on the response and no height lookup endpoint. If you need the terrain itself, the terrain tiles serve the same model directly.
Graph freshness
curl "https://api.unmap.dev/routing/status" \
-H "Authorization: Bearer $UNMAP_API_KEY"{
"region": "canada",
"status": "ready",
"engine": { "name": "valhalla", "version": "3.8.3" },
"graph": {
"builtAt": "2026-09-10T04:12:00.000Z",
"ageHours": 176,
"osmTimestamp": "2026-09-09T20:21:13.000Z"
}
}statusisreadyorunavailable. The call itself returns200either way: a status endpoint that fails when routing is down tells you nothing a failed/routehad not.graphis absent when the engine did not report a build time. Absent means unknown. It does not mean fresh, and there is no placeholder date in its place.ageHoursis whole hours sincebuiltAt, floored.osmTimestampis when the source OSM extract was current, read from the PBF header at graph build time. It is absent until a graph built by the current pipeline is deployed. Absent means unknown. It is not invented from the tileset's build time.
Cached for five minutes, so polling it from a dashboard is cheap.
There is no daily update, and this page will not say there is one until there is. The Canada
graph is rebuilt by hand, because a full build with elevation does not fit inside a hosted CI
runner's six-hour ceiling; the reasoning and the measurements are in
docs/decisions/2026-09-17-routing-freshness-is-not-nightly.md. This endpoint exists so you can
see the real number rather than take a badge's word for it. A graph reported as 40 days old is
more useful for deciding whether to trust a route than a promise of nightly refreshes.
The tileset's own build time still bounds how stale a built route can be. osmTimestamp
answers the other half: how old the roads themselves were when that graph was built.
Notes on route and isochrone
Coverage and what fails
The graph is built from Canadian OpenStreetMap data and nothing else. A Canada-only graph is a deliberate constraint, not an oversight: the whole routing container has to fit inside Cloudflare's container memory ceiling, and a North American graph does not.
What that means in practice, all confirmed against the live API on 2026-09-02:
- An endpoint outside Canada fails. Calgary to Seattle is a
422, not a truncated route to the border. Cross-border routing is not supported at all. - A point in open water or off the road network fails the same way, with a
422. - Being inside Canada is not the same as being connected by road. Calgary to Iqaluit is a
422, because Iqaluit has no road link to the mainland. Routing within Iqaluit works fine. - Valhalla enforces its own maximum distance per travel mode, and the walking one is far shorter
than the driving one. A cross-country
mode=autoroute succeeds where the same pair onmode=pedestrianreturns a422. - On
/matrix, the same coverage gap surfaces as a422for the whole request, exactly like/route, rather than as anullcell for just the affected pair. See "Matrix" above./optimized-routeis expected to fail the same way for the same reason (it solves a matrix underneath), but that is not yet confirmed against the deployed gateway; see "Optimize" above.
Everything else
modegenuinely reaches the costing engine. Walking a given pair of points takes measurably longer than driving it, not just a label change. On the Calgary example above,autoreturns 991 seconds andpedestrianreturns 8,198 over a shorter path.- There are two
503s, and they are not the same thing. If the routing service is not wired up at all, you get one describing the endpoint's expected query shape rather than a400about parameters that were never the actual problem; the service check runs before parameter validation for exactly that reason, and in production you should never see it (it is what running the gateway locally without the container attached looks like). If the container is instead booting or wedged, you get{ "error": "routing temporarily unavailable", "code": "service_unavailable", "retryable": true }withRetry-After: 5. That one is worth a retry; the other is not. - Isochrones cost more than everything else on the platform. A warm three-contour request measured
a median of 1490 ms on 2026-08-03 and around 700 ms on 2026-09-02, from a single client over the
public internet, against roughly 130 to 220 ms for tiles, geocoding, and
/route. Budget for that if you are calling it interactively. - All five endpoints send
Cache-Control: private, max-age=3600, but it only takes effect on/routeand/isochrone. On those two,privatemeans your own client may reuse aGETresponse for an hour, but shared caches and CDNs must not, since the coordinates in a routing request are far less likely to repeat across different callers than a geocoding query or a map tile is./matrix,/matchand/optimized-routearePOST, and per RFC 9111 a stored response to aPOSTcan only ever satisfy a laterGETof the same URI, never anotherPOST, so the header on those three responses does nothing: every matrix, match, and optimize call still runs the request in full, no matter how recently you sent the same body. Unlike geocoding, there is no cache at our edge here either: a hit happens entirely inside your client, so it never reaches the gateway and is never billed. Error responses carry no cache directive at all. - All five endpoints meter as
routingon your usage dashboard. A route, an isochrone, a matrix, a match and an optimization each count as one call, however many contours the isochrone asked for or coordinates or stops the other endpoints carried.
From the client library
@unmap/routing wraps all five endpoints, and @unmap/sdk exposes the same object as
unmap.router:
import { Router, RouterError } from '@unmap/routing'
const router = new Router({ key: 'um_live_...' })
const route = await router.route([-114.0719, 51.0447], [-113.9871, 51.0899], { mode: 'auto' })
route.distanceMeters // 15627
const haul = await router.route([-114.0719, 51.0447], [-113.9871, 51.0899], {
mode: 'truck',
truck: { height: 4.2, weight: 36, axleCount: 6 },
})
const bands = await router.isochrone([-114.0719, 51.0447], { minutes: [10, 20], mode: 'pedestrian' })
bands.features // GeoJSON features, typed as unknown[]
const times = await router.matrix([[-114.0719, 51.0447]], [[-113.9871, 51.0899], [-114.1, 51.05]])
times.durations[0]![1] // seconds; 0 is a real answer on the diagonal. If ANY pair is
// unreachable or beyond Valhalla's own distance limit, the whole call rejects with a
// RouterError (status 422, code 'no_route') instead of returning a null cell.
const cleaned = await router.match([[-114.0719, 51.0447], [-114.0705, 51.0451], [-114.069, 51.0458]])
cleaned.distanceMeters // the snapped, real-road distance
const tour = await router.optimize([
[-114.07, 51.05], [-113.98, 51.04], [-114.12, 51.03], [-114.04, 51.08],
])
tour.order // e.g. [0, 2, 3, 1]: indices into the stops you passed, in visiting order.
// The first and last stops are the fixed start and end. If any stop is unreachable, or the
// set is spread further apart than Valhalla's own distance limit, the whole call rejects
// with a RouterError (status 422, code 'no_route') instead of returning a partial tour.route()andmatch()take[lng, lat]tuples;matrix()takes two arrays of them, origins then destinations;optimize()takes one array of them, the stops. The client hides the fact that all five endpoints spell their coordinates differently on the wire.modeis typed as'auto' | 'car' | 'bicycle' | 'pedestrian' | 'truck' | 'transit'in the published@unmap/routing, so a typo is caught at compile time as well as by the gateway's400.useHillsisuse_hillson the wire, the same wayuseHighwaysisuse_highways.avoidAreasisavoid_areason the wire, and passing it switchesroute()andisochrone()fromGETtoPOST. Same parameters, same response.avoidis an array in the SDK on every endpoint (avoid: ["tolls"]). On the two GET endpoints it is sent as a comma-separated string; on the three POST endpoints it stays an array. Both reach the same validator, so they mean the same thing.router.status()isGET /routing/status, and resolves withstatus: "unavailable"rather than rejecting when routing is down.- The truck profile is a
truckobject with camelCase fields (axleLoad,axleCount,useTruckRoute, plusheight,width,length,weight,hazmat); the client rewrites them into the snake_case fields the gateway expects, on the query string forroute/isochroneand in the JSON body formatrix/match/optimize. useHighwaysis the same 0-to-1 flag asuse_highwayson the wire. Omit it and the client sends nothing, so Valhalla keeps its default.minutesis required onisochrone, and there is no default. The options object on the other four methods is optional in full.- All five methods accept an
AbortSignalassignal. - A non-2xx response throws a
RouterErrorcarrying the HTTP status on.status, and the gateway's machine-readablecodeon.codewhen the body carried one, from any of the five methods. route()andmatch()return a fully typedRouteResult.matrix()returns aMatrixResult(durationsanddistances, each(number | null)[][]).optimize()returns anOptimizedRouteResult, aRouteResultplusorder: number[].isochrone()returns a deliberately minimalFeatureCollectionwhosefeaturesareunknown[], so cast it to your own GeoJSON types (or your mapping library's) if you want the properties above type-checked.
Next steps
- Quickstart step 6 draws one of these routes on a map in a few lines.
- Errors for every status these endpoints can answer with, and the bodies they send.
- Components for a copy-in routing panel built on this API.