Skip to content

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:

live result
the code that produced 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 },
  });
});
npm i @unmap/routing

@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 a 400, not a via point.
  • mode (optional, default auto): one of auto, car, bicycle, pedestrian, truck, transit. car and auto are the same driving profile; car exists because auto reads as "automatic" to anyone who does not know Valhalla. Anything else is a 400: { "error": "mode must be one of auto, car, bicycle, pedestrian, truck, transit" }. Until 2026-09-03 an unrecognised value silently fell back to auto; it no longer does.
  • depart_at (mode=transit only): an ISO 8601 date-time. A value with an offset or Z identifies 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 no arrive_by option.
  • Truck profile (optional, mode=truck only): height, width, length in metres, weight and axle_load in tonnes, axle_count as a whole number, hazmat as true or false, and use_truck_route from 0 to 1. See "Truck routing" below for defaults, ranges, and what the data does and does not cover. Any of these on a mode other than truck is a 400.
  • avoid (optional): a comma-separated list of tolls, highways, ferries. A preference, not an exclusion: see "Avoidance" below before you rely on it. tolls and highways are driving modes only; ferries works on every road mode. Transit rejects all three. An unknown value, an empty value, or one the mode has no setting for is a 400. avoid=highways and use_highways are the same setting and cannot both be sent.
  • use_highways (optional, driving modes only): 0 to 1. Willingness to take motorways and trunks. 0 avoids them, 1 prefers them. Omitted, Valhalla uses its own default (0.5). Allowed on auto, car, and truck. On bicycle or pedestrian it is a 400: { "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): 0 to 1. Willingness to climb. 0 avoids hills even if the route gets longer, 1 is indifferent. Omitted, Valhalla uses its own default (0.25 on bicycle, 0.5 on pedestrian). Allowed on bicycle and pedestrian. On auto, car or truck it is a 400: { "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"
{
  "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 GeoJSON LineString of [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 on code, 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."
    }
  ]
}
codeWhat it meansWhat to do
ENDPOINT_RELOCATEDThe route does not begin or end at the point you sent. where says which end, and the message carries the distanceTreat the endpoint as unreached. This is what a well pad, a remote lease or a new subdivision looks like
ENDPOINT_RELOCATION_NOT_CHECKEDThe route came back without a usable shape, so the check could not runDo not read it as "the endpoints are fine"
CLOSURE_IN_FORCE_NEAR_ROUTEA provincial authority has a road shut within 250 m of the path, right nowExpect the route to be wrong. The graph is rebuilt weekly, so it cannot route around a closure
CLOSURE_SCHEDULED_NEAR_ROUTEThe same, starting later. effectiveFrom says whenNothing, 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 single lng,lat pair /route takes.
  • 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, default auto): 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 with avoid=ferries is 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"
live result
the code that produced it
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: always time here. 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 by contour instead.

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, default auto): the same five travel modes as /route.
  • Truck profile (optional, mode=truck only): 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"}'
// 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 0 is 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 exactly 0 on the diagonal, never null.
  • If any origin-destination pair is unreachable, or beyond the engine's own road-distance limit, the whole request fails as a 422 instead of returning a cell for it. A null cell never appears in practice. This corrects an earlier version of this page, which said a null cell meant unreachable; probing the deployed API showed Valhalla fails the whole sources_to_targets request 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 own max_matrix_distance, which this deployment does not set and has not yet read. The (number | null)[][] type and the null handling in the SDK are both kept: they cost nothing, are correct for a real 0, 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, default auto): the same five travel modes as /route.
  • Truck profile (optional, mode=truck only): 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"}'
// 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, default auto): the same five travel modes as /route.
  • Truck profile (optional, mode=truck only): 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"}'
// 200
{
  "order": [0, 2, 3, 1],
  "distanceMeters": 38200,
  "durationSeconds": 3410,
  "geometry": {
    "type": "LineString",
    "coordinates": []
  }
}
  • order: indices into the stops you 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 /route returns, for the full tour through every stop in order. 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 200 with 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 422 with { "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 400 too_many_locations at the gateway, and two stops return 400 invalid_location with directions to use /route.
  • Any other upstream failure is a 502 with code: "routing_failed". A response the gateway cannot map to a valid tour is also a 502, not a fabricated order: 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"
ParameterUnitDefaultAccepted
heightmetres4.11above 0, up to 10
widthmetres2.6above 0, up to 6
lengthmetres21.64above 0, up to 50
weighttonnes21.77above 0, up to 100
axle_loadtonnes9.070 to 40
axle_countcount5whole number, 2 to 20
hazmatbooleanfalsetrue or false
use_truck_routepreference00 (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 maxheight against 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 maxweight and about 500 a per-axle load. weight and axle_load are honoured wherever data exists, which is almost nowhere yet, and no way in Canada carries an axle count at all, so axle_count excludes 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_route follows hgv=designated ways, which are well mapped in British Columbia and poorly mapped in Québec. Even at 1 there 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 at use_truck_route=0 to 33.2 km at 1, and several others roughly doubled. If you set 1, expect a materially longer route where the designated network is sparse or indirect. Mid-range values are the safer default for most callers, and 0 does 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: true only when height, width, length, weight and axle_load were all supplied. axle_count is 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 from coverage and the profile rather than set by hand, so the two cannot disagree.
  • warnings: read these before you rely on a route. Branch on code, which is stable; message is 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.

ValueMeans
authoritativeChecked against a government source for this class
osm_plus_authoritativeA government source covers part of the area, OpenStreetMap the rest
osm_onlyCommunity-mapped data only. This is provenance, not a grade
not_modelledThe rule exists in the world but the engine cannot express it
not_checkedNothing 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.

CodeWhenWhat to do
INCOMPLETE_VEHICLE_PROFILEA dimension that can exclude a road was not suppliedSend the full profile. The defaults describe some other truck, not yours
ENDPOINT_RESTRICTIONS_UNCHECKEDThe first or last segment could not be cleared. Withheld when both were examined and neither is restrictedCheck clearance and load at your origin and destination separately
WEIGHT_DATA_SPARSEYou sent weight or axle_loadDo not read a route without a weight exclusion as a rated route
HAZMAT_CLASSES_NOT_MODELLEDYou sent hazmat=trueApply 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"

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:

AskWhy it is not a query flag
Resource / industrial / lease roadsAccess is permissioned and often seasonal. OSM track / service is not a lease graph.
Winter, ice, seasonal roadsOpen/closed is a date, not a geometry. No licensed national extract is in the graph.
Field access / farm entrancesEntrances 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"

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.

valuewhat it weights againstmodes
tollstoll roads and toll bridgesauto, car, truck
highwaysmotorways and trunk roadsauto, car, truck
ferriesferry crossingsall 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"

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"
  }
}
  • status is ready or unavailable. The call itself returns 200 either way: a status endpoint that fails when routing is down tells you nothing a failed /route had not.
  • graph is 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.
  • ageHours is whole hours since builtAt, floored.
  • osmTimestamp is 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=auto route succeeds where the same pair on mode=pedestrian returns a 422.
  • On /matrix, the same coverage gap surfaces as a 422 for the whole request, exactly like /route, rather than as a null cell for just the affected pair. See "Matrix" above. /optimized-route is 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

  • mode genuinely 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, auto returns 991 seconds and pedestrian returns 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 a 400 about 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 } with Retry-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 /route and /isochrone. On those two, private means your own client may reuse a GET response 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, /match and /optimized-route are POST, and per RFC 9111 a stored response to a POST can only ever satisfy a later GET of the same URI, never another POST, 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 routing on 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() and match() 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.
  • mode is 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's 400.
  • useHills is use_hills on the wire, the same way useHighways is use_highways.
  • avoidAreas is avoid_areas on the wire, and passing it switches route() and isochrone() from GET to POST. Same parameters, same response.
  • avoid is 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() is GET /routing/status, and resolves with status: "unavailable" rather than rejecting when routing is down.
  • The truck profile is a truck object with camelCase fields (axleLoad, axleCount, useTruckRoute, plus height, width, length, weight, hazmat); the client rewrites them into the snake_case fields the gateway expects, on the query string for route/isochrone and in the JSON body for matrix/match/optimize.
  • useHighways is the same 0-to-1 flag as use_highways on the wire. Omit it and the client sends nothing, so Valhalla keeps its default.
  • minutes is required on isochrone, and there is no default. The options object on the other four methods is optional in full.
  • All five methods accept an AbortSignal as signal.
  • A non-2xx response throws a RouterError carrying the HTTP status on .status, and the gateway's machine-readable code on .code when the body carried one, from any of the five methods.
  • route() and match() return a fully typed RouteResult. matrix() returns a MatrixResult (durations and distances, each (number | null)[][]). optimize() returns an OptimizedRouteResult, a RouteResult plus order: number[]. isochrone() returns a deliberately minimal FeatureCollection whose features are unknown[], 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.