Routing
Three endpoints cover travel: /api/route returns turn-by-turn routes between two or more points, /api/matrix returns travel cost between every source and every target, and /api/isochrone returns the area reachable from a point within a time or distance budget. All three are served by a Valhalla engine behind the Go API server; the server translates between the UzMap vocabulary described here and Valhalla's own, so you never see Valhalla's field names, costing names or units.
Conventions shared by all three endpoints#
| Topic | Rule |
|---|---|
| Base URL | https://uzmaps.ndc.uz |
| Authentication | ?key=… query parameter or X-API-Key header. Every /api/* path except /api/status is gated, so all three routing endpoints are. |
| Methods | GET with query parameters, or POST with a JSON body (Content-Type: application/json). Any method other than POST is parsed as a GET, except OPTIONS, which the CORS layer answers with 204 before it reaches the handler. |
| Coordinates | Always [lon, lat]. In query strings a point is lon,lat; a list is separated by | or ;. |
| Distances | Metres. |
| Durations | Seconds. |
| Success response | Content-Type: application/json; charset=utf-8, status 200. |
| Error response | {"error": "<message>"} with a 4xx or 5xx status. See Errors. |
| CORS | Access-Control-Allow-Origin: *; allowed headers are Content-Type and Range only. |
| Billing | Counted against the key's quota as the products routing, matrix and isochrone respectively. |
Two consequences of the above are easy to miss:
X-API-Keyis not in the CORS allow-list, so a cross-origin browser request that sets that header is blocked by the browser at preflight. From browser code, pass?key=instead.- Query-string points that fail to parse as
lon,latare dropped silently, not rejected. A typo in one point therefore shows up as "at least two points required" or as a matrix with fewer rows than you sent, rather than as a parse error.
Why two request forms: GET is what a browser address bar or a curl one-liner reaches for; POST is what survives a large point list. A 50×50 matrix carries 100 coordinates, which is exactly the case a URL cannot hold (matrix.go, file comment).
Travel modes#
mode is accepted by all three endpoints. The server maps it to a Valhalla costing model; an unknown or empty mode falls back to car, and the response echoes the mode it actually used.
mode | Engine costing | avoid_tolls, avoid_highways, shortest honoured |
|---|---|---|
car (default) | auto | yes |
taxi | taxi | yes |
truck | truck | yes |
motorcycle | motorcycle | yes |
bus | bus | yes |
foot | pedestrian | no |
bike | bicycle | no |
The avoidance flags only change the costing options of the motor modes. For foot the adapter sends a fixed walking_speed of 5.0; for bike it sends a Hybrid bicycle type with use_roads set to 0.4. Sending avoid_tolls=1 with mode=foot is accepted and does nothing.
Costing options the adapter sends, for reference:
| Costing | Option | Default | With flag |
|---|---|---|---|
| motor modes (route and matrix) | use_tolls | 0.5 | 0 when avoid_tolls |
| motor modes (route and matrix) | use_highways | 1.0 | 0 when avoid_highways |
| motor modes (route only) | use_ferry | 0.5 | — (not settable over HTTP) |
| motor modes (route and matrix) | shortest | unset | true when shortest |
pedestrian | walking_speed | 5.0 | — |
pedestrian (route only) | use_ferry | 0.5 | — |
bicycle | bicycle_type | Hybrid | — |
bicycle | use_roads | 0.4 | — |
/api/route#
Returns one recommended route and up to three alternatives between two or more points.
Request#
The GET and POST forms do not accept the same set of fields. Note in particular that the language parameter is lang on GET but language on POST, and that shortest and heading exist only on POST.
| Field | GET | POST | Type | Default | Notes |
|---|---|---|---|---|---|
| Points | points | points | GET: lon,lat|lon,lat; POST: [[lon,lat], …] | required | At least two. The first and last are sent to the engine as stops (break); every intermediate point is sent as a pass-through (through). |
| Mode | mode | mode | string | car | See Travel modes. |
| Language | lang | language | string | uz | GET accepts uz, ru, en, uz-Cyrl; anything else becomes uz. POST is not validated: a value outside uz/ru/en yields English instructions and is echoed back unchanged. See Languages. |
| Alternatives | alternatives | alternatives | integer 0–3 | GET: 2; POST: 0 | Number of alternative routes to ask for, in addition to the recommended one. A value below 0 or above 3 is replaced by 2. The two forms have different defaults: omitting it on POST asks for no alternatives. |
| Avoid tolls | avoid_tolls=1 | avoid_tolls | GET: literal 1; POST: boolean | off | Motor modes only. |
| Avoid highways | avoid_highways=1 | avoid_highways | GET: literal 1; POST: boolean | off | Motor modes only. |
| Shortest | — | shortest | boolean | false | Prefer the shortest route over the fastest. Motor modes only. Not read on GET. |
| Heading | — | heading | number or null | none | Compass heading in degrees, applied to the first point only, with a 60° tolerance. Not read on GET. |
The adapter has AvoidFerries, DepartAt and per-waypoint Name fields, but neither handler populates them, so ferries cannot be avoided and a departure time cannot be set over HTTP.
The handler gives the engine 25 seconds; the engine client has its own 25-second timeout as well.
Response#
{
"routes": [
{
"id": "route-0",
"mode": "car",
"distance": 6094,
"duration": 419,
"geometry": [[69.2401, 41.2995], [69.2405, 41.2998]],
"bbox": [69.2401, 41.2995, 69.2870, 41.3131],
"legs": [
{
"distance": 6094,
"duration": 419,
"steps": [
{
"type": "depart",
"modifier": "",
"instruction": "Bunyodkor shoh ko‘chasi bo‘ylab yo‘lga chiqing",
"verbal": "Bunyodkor shoh ko‘chasi bo‘ylab yo‘lga chiqing",
"street_names": ["Bunyodkor shoh ko‘chasi"],
"distance": 320,
"duration": 41,
"geometry_start": 0,
"geometry_end": 7
},
{
"type": "arrive",
"modifier": "right",
"instruction": "Manzilga yetib keldingiz, u o‘ng tomonda",
"verbal": "Manzilga yetib keldingiz, u o‘ng tomonda",
"distance": 0,
"duration": 0,
"geometry_start": 41,
"geometry_end": 41
}
]
}
],
"summary": "Bunyodkor shoh ko‘chasi · Kichik halqa yo‘li",
"has_highway": true,
"recommended": true
}
],
"language": "uz",
"engine": "valhalla"
}(Abridged: a real geometry array has one entry per shape point, and a real steps array has one entry per manoeuvre.)
Top level:
| Field | Type | Notes |
|---|---|---|
routes | array of route | The recommended route first, then alternatives. Never empty: a request with no route fails with 502 instead. |
language | string | The language the request resolved to, echoed back. |
engine | string | Always "valhalla". |
warnings | array of string | Omitted when empty. |
Route:
| Field | Type | Notes |
|---|---|---|
id | string | route-0 for the recommended route, route-1, route-2, … for alternatives in engine order. |
mode | string | The mode used, after the fallback to car. |
distance | number | Metres. |
duration | number | Seconds. |
geometry | array of [lon, lat] | The full route line, all legs concatenated. Steps refer to it by index. |
bbox | [min_lon, min_lat, max_lon, max_lat] | |
legs | array of leg | |
summary | string | The one or two street names the route spends the most distance on, joined with ·. Empty when no step has a street name. |
has_toll, has_highway, has_ferry | boolean | Omitted when false — absence means false. |
recommended | boolean | true on routes[0] only. |
Leg (Leg):
| Field | Type | Notes |
|---|---|---|
distance | number | Metres. |
duration | number | Seconds. |
steps | array of step | One per manoeuvre. |
Step (Step):
| Field | Type | Notes |
|---|---|---|
type | string | Normalised manoeuvre type; see the table below. Always present, may be "none". |
modifier | string | One of "", straight, left, right, slight_left, slight_right, sharp_left, sharp_right. Always present. |
instruction | string | Text instruction in the requested language. |
verbal | string | Spoken form. For every language other than uz this is the engine's pre-transition verbal instruction; for uz it is identical to instruction. Omitted when empty. |
street_names | array of string | Names of the road after the manoeuvre; falls back to the road's beginning names when the engine gives none. Omitted when empty. |
distance | number | Metres. |
duration | number | Seconds. |
geometry_start, geometry_end | integer | Indices into the route's geometry array. Offsets account for earlier legs, so they index the concatenated line, not the leg. |
roundabout_exit | integer | Which exit to take. Omitted when 0. |
toll, highway, ferry | boolean | Omitted when false. |
sign | object | Omitted when the engine has no sign data. |
Sign (Sign):
| Field | Type |
|---|---|
exit_number | array of string, omitted when empty |
branch | array of string, omitted when empty |
toward | array of string, omitted when empty |
Manoeuvre types#
The engine's numeric manoeuvre types are normalised to a type and modifier pair by the maneuverMap table in routing/valhalla.go.
type | Possible modifier values | Engine types |
|---|---|---|
none | "" | 0 |
depart | "", right, left | 1–3 |
arrive | "", right, left | 4–6 |
continue | straight | 7, 8 |
turn | slight_right, right, sharp_right, sharp_left, left, slight_left | 9–11, 14–16 |
uturn | right, left | 12, 13 |
ramp | straight, right, left | 17–19 |
exit | right, left | 20, 21 |
fork | straight, right, left | 22–24 |
merge | "", right, left | 25, 37, 38 |
roundabout | "" | 26 |
roundabout_exit | "" | 27 |
ferry | "" | 28 |
ferry_exit | "" | 29 |
transit | "" | 30–36 |
elevator | "" | 39 |
steps | "" | 40 |
escalator | "" | 41 |
building_enter | "" | 42 |
building_exit | "" | 43 |
An engine type outside this table produces an empty type and modifier.
Languages#
| Requested | Engine locale | Instructions |
|---|---|---|
uz (default) | en-US | Generated by the server in Latin Uzbek from the normalised manoeuvre, street names and sign data. The engine's English text is discarded. |
ru | ru-RU | Engine text. |
en | en-US | Engine text. |
anything else (including uz-Cyrl) | en-US | Engine text in English; the requested value is still echoed in language. |
Examples#
Recommended route plus one alternative, Russian instructions:
curl -H "X-API-Key: $KEY" \
"https://uzmaps.ndc.uz/api/route?points=69.2401,41.2995|69.2870,41.3131&mode=car&lang=ru&alternatives=1"Walking route with Uzbek instructions, via POST:
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"points":[[69.2401,41.2995],[69.2870,41.3131]],"mode":"foot","language":"uz"}' \
https://uzmaps.ndc.uz/api/routeShortest driving route avoiding tolls, with the car's current heading (POST-only fields):
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"points":[[69.2401,41.2995],[69.2870,41.3131]],"mode":"car","shortest":true,"avoid_tolls":true,"heading":90,"alternatives":2}' \
https://uzmaps.ndc.uz/api/route/api/matrix#
Returns distance and duration for every source–target pair in one call. A matrix is costed with the same avoidance options as a route (avoid_tolls, avoid_highways, shortest), so that the target you pick from a matrix is the one the route you draw afterwards actually goes to (routing/matrix.go, MatrixRequest comment). The one difference is use_ferry, which the route adapter sends and the matrix adapter does not.
Request#
| Field | GET | POST | Type | Notes |
|---|---|---|---|---|
| Sources | sources | sources | GET: lon,lat|lon,lat; POST: [[lon,lat], …] | Required unless points is given. |
| Targets | targets | targets | same | Required unless points is given. |
| Points | points | points | same | Symmetric shorthand: every point to every point. When non-empty it replaces sources and targets. |
| Mode | mode | mode | string | Default car. |
| Avoid tolls | avoid_tolls=1 | avoid_tolls | GET: literal 1; POST: boolean | Motor modes only. |
| Avoid highways | avoid_highways=1 | avoid_highways | GET: literal 1; POST: boolean | Motor modes only. |
| Shortest | shortest=1 | shortest | GET: literal 1; POST: boolean | Motor modes only. Unlike /api/route, this is accepted on GET. |
There is no language parameter: a matrix has no instructions.
Size limit. sources × targets must not exceed 2500. The cap is on the product, not on either side, so 1×2500 and 50×50 both fit and 51×50 does not. A request over the cap is rejected with 400 before anything reaches the engine. The reason for a cap: the engine computes a matrix by expanding the graph from every source, so cost grows with the product, and an uncapped endpoint would let one key saturate the engine for everyone (routing/matrix.go, MaxMatrixPairs comment).
The handler allows 60 seconds because a full matrix is many graph expansions; the engine client's own 25-second timeout still applies.
Response#
{
"mode": "car",
"sources": 1,
"targets": 2,
"cells": [
{"source": 0, "target": 0, "distance": 6094, "duration": 419},
{"source": 0, "target": 1, "distance": 6695, "duration": 519}
],
"engine": "valhalla",
"rows": [
[
{"source": 0, "target": 0, "distance": 6094, "duration": 419},
{"source": 0, "target": 1, "distance": 6695, "duration": 519}
]
]
}| Field | Type | Notes |
|---|---|---|
mode | string | Mode used, after the fallback to car. |
sources | integer | Number of sources sent. |
targets | integer | Number of targets sent. |
cells | array of cell | Every pair, flat, in row-major order (all targets for source 0, then source 1, …). |
rows | array of array of cell | The same cells grouped by source: rows[i][j] is source i to target j. |
engine | string | Always "valhalla". |
Cell:
| Field | Type | Notes |
|---|---|---|
source | integer | Index into the sources you sent. |
target | integer | Index into the targets you sent. |
distance | number or null | Metres. |
duration | number or null | Seconds. |
A null distance or duration means no path exists between that pair — an island, a gated area, a point the network cannot reach in the requested mode. It is a real answer, not an error: the rest of the matrix is returned as normal, because a single unreachable pair must not fail the whole request (routing/matrix.go, MatrixCell comment; TestMatrixKeepsUnreachablePairsAsNull in routing/matrix_test.go). The engine reports matrix distance in kilometres; the server multiplies by 1000 so this endpoint agrees with the rest of the API (TestMatrixConvertsKilometresToMetres).
Examples#
One source to two targets:
curl -H "X-API-Key: $KEY" \
"https://uzmaps.ndc.uz/api/matrix?sources=69.2401,41.2995&targets=69.2870,41.3131|69.22,41.33&mode=car"Symmetric matrix from a browser (query-string key, because the X-API-Key header is not CORS-allowed):
const res = await fetch('https://uzmaps.ndc.uz/api/matrix?key=' + encodeURIComponent(KEY), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
points: [[69.2401, 41.2995], [69.2870, 41.3131], [69.22, 41.33]],
mode: 'car',
avoid_tolls: true,
}),
});
const matrix = await res.json();
const unreachable = matrix.cells.filter((c) => c.distance === null);/api/isochrone#
Returns the area reachable from one point within one or more time or distance budgets, as a GeoJSON FeatureCollection with one feature per contour. It is raw GeoJSON rather than a bespoke schema because every mapping client can already draw it (routing/matrix.go, IsochroneResponse comment).
Request#
| Field | GET | POST | Type | Default | Notes |
|---|---|---|---|---|---|
| Origin | point | point | GET: lon,lat (exactly one); POST: [lon, lat] | required | A point at exactly 0,0 is treated as missing. |
| Mode | mode | mode | string | car | No avoidance flags; the mode is the only costing input. |
| Minutes | minutes | minutes | GET: comma-separated numbers; POST: array of number | — | Time contours. |
| Metres | metres | metres | same | — | Distance contours. |
| Polygons | polygons | polygons | GET: any value but 0 is true; POST: boolean | GET: true; POST: false | Filled areas rather than lines. |
| Denoise | denoise | denoise | number (0–1) or null | engine default | Drops small disconnected islands. |
| Generalize | generalize | generalize | number or null | engine default | Simplification tolerance in metres. |
The two forms default polygons differently: GET defaults to polygons because a bare browser request wants something it can fill and hit-test, and lines are the specialised choice (matrix.go, handleIsochrone comment); POST defaults to false because that is the JSON zero value. Set it explicitly in POST bodies. Lines are cheaper and suit a heat-style overlay; polygons suit a "can we deliver here" hit test (routing/matrix.go, IsochroneRequest comment).
DepartAt exists in the adapter but is not populated by the handler, so a departure time cannot be set over HTTP.
Contour rules#
| Rule | Status on violation |
|---|---|
Give minutes or metres, not both. | 400 contours must be either minutes or metres, not both |
| Give at least one contour. | 400 contours required: minutes=5,10,15 or metres=1000,2000 |
At most 6 contours in total (MaxIsochroneIntervals). | 400 too many contours: N, limit is 6 |
| Every value must be greater than 0. | 502 contour minutes must be positive / contour metres must be positive |
The six-contour cap exists because each contour is a separate expansion of the graph, so it is the same protection the 2500-pair cap gives the matrix (routing/matrix.go, MaxIsochroneIntervals comment). The non-positive check is made in the adapter rather than the handler, which is why it surfaces as 502 rather than 400.
Distance contours are given in metres and converted to kilometres for the engine on the way in, and back to metres on the way out.
Response#
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"contour": 5, "metric": "time", "fill": "#bf4040", "contour_minutes": 5},
"geometry": {
"type": "Polygon",
"coordinates": [[[69.24, 41.30], [69.25, 41.30], [69.25, 41.31], [69.24, 41.30]]]
}
}
],
"mode": "car",
"engine": "valhalla"
}| Field | Type | Notes |
|---|---|---|
type | string | Always "FeatureCollection". |
features | array of GeoJSON Feature | One per contour, passed through from the engine with geometry intact. Coordinates are [lon, lat]. |
mode | string | Mode used, after the fallback to car. |
engine | string | Always "valhalla". |
Each feature's properties carries the engine's own metadata plus one field the server adds:
| Property | Added by | Notes |
|---|---|---|
contour | engine | The contour value in the engine's units: minutes for a time contour, kilometres for a distance contour. |
metric | engine | "time" or "distance". |
contour_minutes | server | Present on time contours. Equal to contour. |
contour_metres | server | Present on distance contours. contour × 1000. |
anything else (for example fill) | engine | Passed through untouched. |
Read contour_minutes or contour_metres rather than contour, so you never need to know which unit the engine was given. The server decides which one to add from the feature's own metric property, so the label stays correct even if the engine reorders, merges or drops contours; only when a feature carries no recognisable metric does it fall back to whichever axis the request used. A time contour is never also labelled in metres.
If the engine returns no features, the request fails with 502 no reachable area found.
Examples#
Walking reach in 5, 10 and 15 minutes (GET, so polygons by default):
curl -H "X-API-Key: $KEY" \
"https://uzmaps.ndc.uz/api/isochrone?point=69.2401,41.2995&mode=foot&minutes=5,10,15"Driving distance rings as lines, with small islands removed (POST, so polygons must be stated):
curl -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"point":[69.2401,41.2995],"mode":"car","metres":[1000,2000,5000],"polygons":false,"denoise":0.5}' \
https://uzmaps.ndc.uz/api/isochroneDrawing the result with MapLibre GL, which accepts the response as a GeoJSON source directly:
const res = await fetch('https://uzmaps.ndc.uz/api/isochrone?key=' + encodeURIComponent(KEY), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ point: [69.2401, 41.2995], mode: 'car', minutes: [10, 20], polygons: true }),
});
const isochrone = await res.json();
map.addSource('reach', { type: 'geojson', data: isochrone });
map.addLayer({
id: 'reach-fill',
type: 'fill',
source: 'reach',
paint: { 'fill-color': ['get', 'fill'], 'fill-opacity': 0.25 },
});Errors#
Every error is {"error": "<message>"}, except key errors, which the gate writes as {"error", "code", "message", "docs"}.
| Status | Message | Endpoint | Cause |
|---|---|---|---|
| 400 | bad json | all | POST body could not be decoded. |
| 400 | at least two points required (points=lon,lat|lon,lat) | route | Fewer than two parseable points. |
| 400 | sources and targets required (sources=lon,lat|lon,lat&targets=..., or points=... for a symmetric matrix) | matrix | An empty side. |
| 400 | matrix too large: N pairs, limit is 2500 | matrix | sources × targets over the cap. |
| 400 | point required (point=lon,lat) | isochrone | Missing or unparseable origin, more than one point on GET, or a point at exactly 0,0. |
| 400 | contours required: …, contours must be either minutes or metres, not both, too many contours: N, limit is 6 | isochrone | See Contour rules. |
| 401 / 403 / 429 | key errors | all | Missing, unknown, disabled or origin-restricted key, per-minute rate limit exceeded, or monthly quota exhausted (the quota response also carries Retry-After). |
| 502 | no route found | route | The engine returned no trip. |
| 502 | no reachable area found | isochrone | The engine returned no features. |
| 502 | contour minutes must be positive, contour metres must be positive | isochrone | A zero or negative contour. |
| 502 | routing engine unavailable: … | all | The engine did not answer (including the 25-second client timeout). |
| 502 | routing: …, matrix: …, isochrone: … | all | The engine rejected the request; the text after the prefix is the engine's own message (for example No suitable edges near location), or HTTP <status> when it gave none. |
| 503 | routing engine not configured | matrix, isochrone | Guard for a server with no routing client. The constructor always creates one, so this is not reachable through the normal start-up path. |