Documentation menu
Route optimisation
POST /optimise (alias /optimize) solves multi-vehicle, multi-stop
problems: which vehicle visits which stops, in what order, within time
windows and capacities. It is the planning layer on top of routing, and it
carries our differentiator all the way through: when you optimise with
costing: "truck" (optionally with an adr profile; ADR is the European
agreement on carriage of dangerous goods by road; tunnel codes B–E restrict
which tunnels a hazmat load may use, see conventions
for the full table), the travel-time matrix underneath is computed by our
routing engine with those dimensional and ADR constraints, so the plan
never assumes a leg a lorry cannot legally drive.
Availability
Status, honestly: the hosted gateway at https://api.mapmap.ai is live:
sign up for a key, or run the self-host distro.
Base URL and authentication conventions are on the
conventions page.
The solver itself runs as an optional sidecar on the deployment:
- Self-host: set
SN_VROOM_URLon the gateway to a vroom-express sidecar; the self-host guide and the distro'sdocker-compose.ymlinclude a ready-made service to uncomment. - When the sidecar is not configured, the endpoint returns
503with problem typeurn:sn-gateway:problem:optimisation-not-enabled.
The gateway never lets the solver do its own routing: it computes the full
travel-time/distance matrix through its own engine (with your costing,
costing_options and adr profile applied) and hands the solver explicit
matrices. The optimisation respects exactly the same constraints as
/route and /matrix.
Limits, published
Nothing here is behind a waitlist, a beta form or a sales call. These are the numbers the running gateway enforces, and the right-hand column says where each one comes from, so a self-hosted deployment can be checked against its own configuration rather than against this page.
Two kinds of limit. Configurable ones are environment variables on the
gateway, and a self-hosted deployment moves them by editing its own
compose.env. Fixed ones are compiled into the gateway build and move
only with a release; each is listed with the status and problem type you get
when you cross it, so you can detect the boundary without reading this table
at runtime.
Optimisation
| Limit | Hosted value | Source of truth |
|---|---|---|
| Unique locations per request, synchronous | 200 | Fixed. 422 optimisation-too-large, whose body carries max_locations and an async_lane pointer. |
| Unique locations per request, asynchronous | 2,000 | Configurable: SN_OPTIMISE_MAX_LOCATIONS_ASYNC. POST /v1/jobs/optimise submits the same problem as a job and answers immediately with an id; the 200 above is what fits inside the request deadline, not what the solver can do. See asynchronous jobs. |
| Vehicles per request | No separate cap | A request needs at least one vehicle. Each vehicle's start and end count towards the 200 locations, so a fleet all sharing one depot costs one location for the whole fleet. |
| Jobs and shipments per request | No separate cap | As above. A job contributes one location, a shipment two, and repeated coordinates deduplicate. |
| Matrix span, furthest two points | 1,500 km on the motor costings (auto, truck, motor_scooter); 200 km on bicycle and pedestrian | Configurable: SN_ENGINE_MAX_MATRIX_DISTANCE_M, set to 1500000 on the hosted gateway. The gateway default is 400,000 m, matching the engine's stock service_limits.<costing>.max_matrix_distance. 422 matrix-span-too-large. |
| Internal matrix block size | 2,500 source x target pairs | Configurable: SN_ENGINE_MAX_MATRIX_PAIRS, at its 2,500 default on the hosted gateway. Sets how many blocks a problem is chunked into, and so what it bills. |
| Billing | 10 units per started block | Fixed. See "Metering and fair use" below for the worked table. |
| Costings accepted | auto, truck, bicycle, pedestrian, motor_scooter | Fixed. Anything else is a 400. |
| Route geometry in the response | Not returned | Fixed. options.g: true is a 400; fetch legs from POST /route. |
| Solver availability | Enabled | Configurable: SN_VROOM_URL. Unset, every call is 503 optimisation-not-enabled. |
| Trips per reload-enabled vehicle | 2 to 5 | Fixed. 400 outside the range, naming the vehicle. Each trip is a whole extra vehicle in the solver's search, and every trip added shortens all the others. |
Reserved vehicle-id band (reloads) | 6,000,000,000 and above | Fixed. Only policed when a request uses reloads; a caller vehicle numbered into it is a 400 naming the vehicle. |
| Single relaxation amount | 604,800 s (7 days) | Fixed. 400 beyond, and on a non-positive or absent cap. |
| Solves per request | at most 2 | Fixed. Never a third: the relaxed problem cannot itself ask to be relaxed. |
| Territories per request | 64 | Fixed. 400 beyond. |
| Positions per territory ring | 1,000 | Fixed. 400 beyond; simplify the boundary first. |
Locations per POST /v1/cluster request | 5,000 | Fixed. 422 cluster-too-large. |
Clusters per POST /v1/cluster request | 500, and at most 500,000 locations x clusters | Fixed. 400 on the cluster count, 422 cluster-too-large on the product. |
The endpoints underneath it
| Limit | Hosted value | Source of truth |
|---|---|---|
/matrix elements per request (sources x targets) | 10,000, so up to 100 x 100 | Fixed. 422 matrix-too-large. |
/v1/jobs/matrix elements per request | 40,000 | Configurable: SN_MATRIX_MAX_ELEMENTS_ASYNC. The asynchronous lane for the same matrix. |
/matrix billing block | 25 elements per unit | Fixed. |
/isochrone contours per request | 10 | Fixed. 400 beyond. |
/route waypoints per unit | 20 | Fixed. Chunked above the engine's own ceiling, and billed per block. |
Full detail on the matrix limits, including the per-costing span table, is on the OD matrix page.
How that compares
A published limit is only worth reading next to somebody else's. Mapbox is the nearest comparison and its documentation is public, so this is checkable in both directions. Every figure below is quoted from the page linked beside it, checked on 1 September 2026.
| MapMap | Mapbox | |
|---|---|---|
| Distance matrix, size per request | 10,000 elements, up to 100 x 100 | "Maximum 25 input coordinates per request" on driving, walking and cycling; "Maximum 10 input coordinates per request" on driving-traffic (Matrix API) |
| Optimisation, how you get it | Generally available. Sign up, get a key, call it. | v1 is generally available; v2 is a "Public Beta" you request access to: "If you are interested in early access to the Optimization v2 API Beta, sign up here" (Optimization API v2) |
| Optimisation, problem size | 200 unique locations | v1: "There must be between two and 12 coordinates" (Optimization v1). v2: "Maximum number of 1000 locations per routing problem" |
| Isochrone contours per request | 10 | "The Isochrone API can support a maximum of 4 isochrone contours per request" (Isochrone API) |
| Where you may draw the result | No requirement to render it on our maps; attribution obligations are on the licensing page | "Results must be displayed on a Mapbox map using one of the Mapbox libraries or SDKs" (Isochrone API) |
| ADR dangerous goods, tunnel categories | A top-level adr profile on /route and /optimise; adr_tunnel_code and hazmat in costing_options.truck on /matrix and /isochrone | No ADR concept documented (Directions API) |
Read that honestly, because the interesting row cuts against us. Mapbox's
Optimization v2 accepts a larger problem than we do, 1,000 locations against
our 200. Two things sit beside it. It is a beta you sign up for, where
POST /optimise is generally available to anybody holding a key today. And
neither v1, nor v2, nor Directions carries any ADR concept: the truck-shaped
parameters the Directions API documents are max_height, max_width and
max_weight, and when a route breaches one, the documented behaviour is a
notifications entry of type violation describing the breach. There is no
hazmat parameter, no ADR tunnel category, and no tunnel value in
exclude. If somebody shows you a comparison claiming otherwise, check the
page.
Two more things worth being straight about. The 25 and the 12 are Mapbox's matrix and Optimization v1 caps, not v2's, and it would be easy to quote the small number against the big product. And if 200 locations is genuinely the constraint that bites for you, say so: the ceiling is a fair-use number rather than an engineering one, and larger sustained problems are a conversation, not a refusal.
Request
You need an API key (Authorization: Bearer snk_…; get one in the
quickstart). Locations on this endpoint are
{ "lat": …, "lon": … } objects with named keys, so there is no
coordinate-order ambiguity. Durations are seconds, distances metres.
Top-level body
| Field | Type | Required | Description |
|---|---|---|---|
vehicles | array | yes (≥ 1) | The fleet. |
jobs | array | see note | Single-stop jobs. At least one job or shipment is required. |
shipments | array | see note | Pickup+delivery pairs that must ride the same vehicle, pickup first. |
costing | string | no (default "auto") | Costing model for the travel-time matrix: "auto", "truck", "bicycle", "pedestrian" or "motor_scooter". |
costing_options | object | no | Valhalla-style costing options passed to the matrix verbatim, e.g. {"truck": {"height": 4.0}}. |
adr | object | no | ADR vehicle profile, same shape as on POST /route (dimensions, tunnel_code, hazmat). Requires "costing": "truck" (else 400); merged into costing_options.truck. |
territories | array | no | Named polygons bounding which vehicle may serve which stop, [{"id": "kent", "polygon": [[lon, lat], …]}]. See "Territories and clustering". |
relax_if_unassigned | object | no | {time_windows_by_s, allow_overtime_s}. If the first solve leaves work unassigned, re-solve once with these relaxations and report which plan came back. See "Auto-relaxation". |
options | object | no | Only g (geometry) exists, and it is not supported: {"g": true} is a 400. Fetch leg geometry via POST /route instead. |
Vehicles
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Caller-chosen id, echoed back on the vehicle's route. |
start | {lat, lon} | at least one of start/end | Start location. |
end | {lat, lon} | at least one of start/end | End location; omitted, the route ends at its last stop. |
capacity | integer array | no | Multidimensional capacity; same length as job delivery/pickup and shipment amount. |
skills | integer array | no | Skills this vehicle provides; a task requiring a skill the vehicle lacks is never assigned to it. |
time_window | [start, end] | no | Working window in seconds (see "Times" below). |
breaks | array | no | Mandatory rest breaks (see "Driver hours & compliance"). |
max_travel_time | integer | no | Cap on driving time in seconds (excludes service, setup and waiting). A daily driving limit maps here. |
max_tasks | integer | no | Cap on the number of tasks this vehicle may serve. |
costs | object | no | Cost model, {fixed, per_hour, per_task_hour, per_km}, for cost-optimal (not just time-optimal) plans. Any omitted field uses the solver default. |
territory_ids | string array | no | Ids from territories this vehicle is confined to. Omitted or empty, it serves anywhere. |
reloads | object | no | {max_trips, reload_time_s, depot} — let this vehicle return to its depot, reload and go out again. Requires time_window. See "Reload runs". |
Jobs
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Caller-chosen id, echoed back in steps and unassigned. |
location | {lat, lon} | yes | Job location. |
service_s | integer | no | On-site service time in seconds. |
delivery | integer array | no | Quantities delivered to the job (matches vehicle capacity). |
pickup | integer array | no | Quantities picked up at the job. |
skills | integer array | no | Skills the job requires. |
time_windows | array of [start, end] | no | Acceptable arrival windows in seconds. |
Shipments
| Field | Type | Required | Description |
|---|---|---|---|
pickup | object | yes | The pickup end: id, location, optional service_s, optional time_windows (same shapes as on jobs). |
delivery | object | yes | The delivery end, same shape as pickup. |
amount | integer array | no | Quantities moved (matches vehicle capacity). |
skills | integer array | no | Skills the shipment requires. |
Times
time_window, time_windows and the response's arrival values are plain
seconds on any consistent epoch you choose. The example below uses
seconds since midnight: 28800 = 08:00, 64800 = 18:00. Arrivals in the
response come back on the same scale.
Driver hours & compliance
Fleets do not just optimise for time; they optimise inside the law. Two mechanisms, both of which the underlying solver does not model on its own:
Explicit breaks. Give a vehicle a breaks array. Each break is a
mandatory rest with no location: the solver inserts it into the route
wherever the windows allow:
| Field | Type | Required | Description |
|---|---|---|---|
id | integer | yes | Echoed back on the break step. |
time_windows | array of [start, end] | yes | When the break may be taken (same epoch as time_window). |
service_s | integer | no | Break duration in seconds. |
max_load | integer array | no | The break is only allowed while the load is at or below this. |
description | string | no | Free text, echoed back on the step. |
EU drivers' hours (auto-generated). Set eu_drivers_hours at the top
level to have the gateway generate a compliant break for every vehicle that
has a time_window but no explicit breaks:
{ "eu_drivers_hours": true }
Pass true for the Regulation (EC) No 561/2006 defaults (45 minutes' rest
after at most 4.5 hours' driving) or an object to override them:
{ "eu_drivers_hours": { "driving_before_break_s": 16200, "break_duration_s": 2700 } }
A break is generated only when the shift is long enough to need one; short
shifts and vehicles without a time_window are left alone, and an explicit
breaks array always takes precedence. This is a single-shift
approximation: it covers a day fleet's mid-shift rest, but does not model
split breaks, daily/weekly rest, or duty-time-exact enforcement; for those,
post-validate the returned plan. Breaks appear in the route as steps of
type: "break".
Example
A London depot, deliveries in Birmingham and Manchester, back to the depot, 44-tonne artic carrying hazmat with ADR tunnel code C:
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/optimise" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"vehicles": [
{ "id": 1,
"start": { "lat": 51.5074, "lon": -0.1278 },
"end": { "lat": 51.5074, "lon": -0.1278 },
"capacity": [10], "time_window": [28800, 64800] }
],
"jobs": [
{ "id": 10, "location": { "lat": 52.4862, "lon": -1.8904 },
"service_s": 300, "delivery": [2] },
{ "id": 11, "location": { "lat": 53.4808, "lon": -2.2426 },
"service_s": 300, "delivery": [3] }
],
"costing": "truck",
"adr": {
"dimensions": { "height_m": 4.0, "width_m": 2.55,
"length_m": 16.5, "gross_weight_t": 40.0 },
"tunnel_code": "C",
"hazmat": true
}
}'
Worked example: which vehicle takes the hazmat load
The question a mixed fleet actually asks. A London depot, two drops, and one of them is a dangerous-goods consignment with ADR tunnel restriction code C. Two lorries are available and only one of them is ADR-licensed. Which one gets the hazmat drop, and does its route respect the tunnel categories code C is forbidden from?
Two mechanisms answer it, and they do different jobs:
- The top-level
adrprofile constrains the roads. It is merged intocosting_options.truckand applied to the travel-time matrix, so every duration the solver reasons about was computed by an engine that already refused tunnels of the forbidden categories, along with anything over the declared height, width, length or weight. skillsconstrain the assignment. A job is only ever given to a vehicle whoseskillsare a superset of the job's, so marking the hazmat drop"skills": [1]and the ADR-licensed lorry"skills": [1]pins one to the other. The general-haulage lorry declares no skills and can therefore only take the unmarked drops.
curl -fsS -X POST "$BASE/optimise" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"costing": "truck",
"adr": {
"dimensions": { "height_m": 4.0, "width_m": 2.55,
"length_m": 16.5, "gross_weight_t": 40.0 },
"tunnel_code": "C",
"hazmat": true
},
"vehicles": [
{ "id": 1,
"start": { "lat": 51.5074, "lon": -0.1278 },
"end": { "lat": 51.5074, "lon": -0.1278 },
"capacity": [12], "skills": [1], "time_window": [28800, 64800] },
{ "id": 2,
"start": { "lat": 51.5074, "lon": -0.1278 },
"end": { "lat": 51.5074, "lon": -0.1278 },
"capacity": [12], "time_window": [28800, 64800] }
],
"jobs": [
{ "id": 10, "location": { "lat": 51.4545, "lon": -2.5879 },
"service_s": 900, "delivery": [4], "skills": [1] },
{ "id": 11, "location": { "lat": 51.7520, "lon": -1.2577 },
"service_s": 300, "delivery": [3] }
]
}'
Job 10 comes back on vehicle 1 and never anywhere else. The figures below
are illustrative; the assignment, the profile echo and the shape are the
parts to read:
{
"code": 0,
"profile": "truck",
"summary": { "cost": 26400, "routes": 2, "unassigned": 0,
"duration": 26400, "service": 1200, "waiting_time": 0 },
"unassigned": [],
"routes": [
{ "vehicle": 1, "cost": 16800, "duration": 16800, "service": 900,
"waiting_time": 0,
"steps": [
{ "type": "start", "arrival": 28800, "duration": 0, "service": 0,
"waiting_time": 0, "load": [4],
"location": { "lat": 51.5074, "lon": -0.1278 } },
{ "type": "job", "id": 10, "arrival": 37200, "duration": 8400,
"service": 900, "waiting_time": 0, "load": [0],
"location": { "lat": 51.4545, "lon": -2.5879 } },
{ "type": "end", "arrival": 46500, "duration": 16800, "service": 0,
"waiting_time": 0, "load": [0],
"location": { "lat": 51.5074, "lon": -0.1278 } }
] },
{ "vehicle": 2, "cost": 9600, "duration": 9600, "service": 300,
"waiting_time": 0,
"steps": [
{ "type": "start", "arrival": 28800, "duration": 0, "service": 0,
"waiting_time": 0, "load": [3],
"location": { "lat": 51.5074, "lon": -0.1278 } },
{ "type": "job", "id": 11, "arrival": 33600, "duration": 4800,
"service": 300, "waiting_time": 0, "load": [0],
"location": { "lat": 51.7520, "lon": -1.2577 } },
{ "type": "end", "arrival": 38700, "duration": 9600, "service": 0,
"waiting_time": 0, "load": [0],
"location": { "lat": 51.5074, "lon": -0.1278 } }
] }
]
}
Add "eu_drivers_hours": true and each windowed vehicle also gets a mandatory
rest inserted as a break step, as described under "Driver hours &
compliance" above.
Four things to be straight about.
One matrix, one costing. The adr profile is a property of the request, not
of a vehicle, so there is exactly one travel-time matrix and every vehicle in
the fleet is costed under it. In the example above, vehicle 2 is also routed
as a 40-tonne ADR code C artic even though it carries none of the hazmat. That
is the conservative direction, but it will overstate vehicle 2's times where
the two profiles would really diverge. A fleet whose vehicles need genuinely
different road access wants one request per costing, and the plans merged by
the caller.
skills are integers you assign meaning to. There is no registry of skill
1. It means "ADR-licensed" only because both the job and the vehicle in your
request say so. Get the pairing wrong and the solver will happily give the
hazmat drop to the wrong lorry, because nothing in the wire format knows what
the number stands for.
An unassignable hazmat job is never silently dropped. If no ADR-licensed
vehicle can reach the drop in its window, job 10 appears in unassigned
with its id and location, and the rest of the plan still returns. Check
summary.unassigned before dispatching.
A 200 is not a compliance certificate. Unlike POST /route, the
optimisation response carries no adr echo and no enforcement block: the only
thing it asserts is profile: "truck". What actually happened is that the
declared dimensions and tunnel code were applied to the matrix the plan was
built from. If you need an audit trail per tunnel, check the load against
POST /adr/check (the check_adr_tunnel MCP tool) and fetch the
committed legs from POST /route, whose response does carry the enforcement
detail. See conventions for the ADR 8.6.4 code and
category tables.
Response
Truncated but shape-complete: every field shown here is always present on
a 200 (except distance and load, which appear when the matrix carries
distances and the problem carries quantities, and id, which only job,
pickup and delivery steps have):
{
"code": 0,
"profile": "truck",
"summary": { "cost": 26000, "routes": 1, "unassigned": 0,
"duration": 26000, "service": 600, "waiting_time": 0,
"distance": 655000 },
"unassigned": [],
"routes": [
{ "vehicle": 1, "cost": 26000, "duration": 26000, "distance": 655000,
"service": 600, "waiting_time": 0,
"steps": [
{ "type": "start", "arrival": 28800, "duration": 0,
"service": 0, "waiting_time": 0, "load": [5],
"location": { "lat": 51.5074, "lon": -0.1278 } },
{ "type": "job", "id": 10, "arrival": 35400, "duration": 6600,
"service": 300, "waiting_time": 0, "load": [3],
"location": { "lat": 52.4862, "lon": -1.8904 } },
{ "type": "job", "id": 11, "arrival": 41000, "duration": 11900,
"service": 300, "waiting_time": 0, "load": [0],
"location": { "lat": 53.4808, "lon": -2.2426 } },
{ "type": "end", "arrival": 55400, "duration": 26000,
"service": 0, "waiting_time": 0, "load": [0],
"location": { "lat": 51.5074, "lon": -0.1278 } }
] }
]
}
Reading it:
codeis always0on HTTP 200; solver failures surface as HTTP errors, never as a non-zero code in a 200 body.profileechoes the matrix costing used, one of"auto","truck","bicycle","pedestrian"or"motor_scooter".costis the solver's objective; with the matrices the gateway supplies it equals travel time in seconds.- Step
durationis cumulative travel time up to that step;arrivalis on your chosen epoch (see "Times" above). - Unassignable tasks are never silently dropped: each appears in
unassignedas{ "id": 12, "type": "job", "location": { "lat": …, "lon": … } }. - Route geometry is not returned by the solver; request each leg from
POST /routeif you need lines on a map. routes[].tripappears only when that vehicle declaredreloads;vehicleis always your own id.- A
relaxationblock appears only when you sentrelax_if_unassigned, and it states which of the two solves produced the plan above.
Unreachable pairs: if a location pair is unreachable under the chosen costing, the matrix cell is penalised with a very large sentinel cost rather than an error, so the solver avoids it whenever any alternative exists. If a returned duration looks absurdly large (millions of seconds), a stop is likely unreachable; check its coordinates and your truck constraints.
Reload runs: tipping, reloading, and going back out
A refuse lorry does not run one long round. It fills up, drives to the tip,
empties, and comes back for more. Modelled as one vehicle with one capacity
that problem is unsolvable the moment the body is full, and the honest answer
would be half the streets in unassigned.
reloads says the vehicle may go back to its depot, reload, and go out again.
| Field | Type | Required | Description |
|---|---|---|---|
max_trips | integer, 2–5 | yes | How many trips this vehicle may run in its shift. A budget, not a prediction — read the sequencing note below before you raise it. |
reload_time_s | integer | no (default 0) | Time at the depot between trips: tipping, the weighbridge, reloading. Held out of the shift before it is divided, so it is never accidentally spent driving. |
depot | {lat, lon} | no | Where the vehicle reloads. Omitted, its own start is used (or its end if it declared only that). |
Two things become required, and one changes meaning:
- the vehicle needs a
time_window— the trips are sequenced by dividing the shift, and there is no shift to divide without one; - its
startandend, if given, must be the depot: every trip begins and ends there. Astartsomewhere else is a400saying so; capacity,max_tasks,max_travel_timeandcostsare copied to every trip verbatim, so they are per trip, not per shift. A full body per trip is the point of the feature;max_tasks: 40on a three-trip lorry means forty stops per trip, andcosts.fixedis incurred once per trip used.
Breaks are the exception: each break — yours, or the EC 561/2006 break
eu_drivers_hours generates from the whole shift — is placed in exactly
one trip, the earliest whose window can hold it. A break that fits no trip
window is a 400 listing the windows, rather than a plan that quietly drops
a driver's rest.
The waste round, end to end
One lorry, a ten-tonne body, a shift from 06:00 to 14:00 (21600 to 50400
seconds past midnight) and half an hour at the tip:
curl -sS -X POST "$BASE/optimise" \
-H "Authorization: Bearer $MAPMAP_KEY" \
-H 'Content-Type: application/json' \
-d '{
"costing": "truck",
"vehicles": [{
"id": 1,
"start": { "lat": 51.5310, "lon": -0.1050 },
"end": { "lat": 51.5310, "lon": -0.1050 },
"capacity": [10000],
"time_window": [21600, 50400],
"reloads": { "max_trips": 3, "reload_time_s": 1800 }
}],
"jobs": [
{ "id": 101, "location": { "lat": 51.5421, "lon": -0.1130 }, "pickup": [3200], "service_s": 600 },
{ "id": 102, "location": { "lat": 51.5488, "lon": -0.0975 }, "pickup": [4100], "service_s": 600 }
]
}'
The answer keeps your vehicle id and adds the trip:
{
"summary": { "routes": 2, "unassigned": 0, "…": "…" },
"routes": [
{ "vehicle": 1, "trip": 1, "steps": [ "…" ] },
{ "vehicle": 1, "trip": 2, "steps": [ "…" ] }
],
"reloads": {
"vehicles": [{
"vehicle": 1,
"depot": { "lat": 51.5310, "lon": -0.1050 },
"depot_basis": "vehicle.start",
"reload_time_s": 1800,
"shift": [21600, 50400],
"trips_allowed": 3,
"trips_used": 2,
"trips_run": [1, 2],
"trip_windows": [
{ "trip": 1, "window": [21600, 30000] },
{ "trip": 2, "window": [31800, 40200] },
{ "trip": 3, "window": [42000, 50400] }
]
}],
"mechanism": "…", "sequencing": "…", "approximation": "…", "per_trip": "…"
}
}
Reading it:
routes[].vehicleis your id, always. The trips are separate routes on the same vehicle, ordered bytrip, andsummary.routestherefore counts trips, not vehicles.trips_usedcounts the trips that came back with a route. Two of three means the third was not needed — that is the answer, not an omission.trip_windowsare the windows the solver was actually working in — here an eight-hour shift less two half-hour tips is 25,200 s to drive in, 8,400 s per trip. They are reported because you cannot reconstruct them, and because the next section is about their limits.
How the trips are sequenced, and where the model stops
The solver has no inter-vehicle constraint of any kind — nothing that says
"vehicle B starts when vehicle A finishes". Each reload-enabled vehicle is
split into one virtual vehicle per trip, and the sequencing is done by
dividing the shift [S, E] into consecutive windows separated by
reload_time_s:
trip 1: [S, S + L]
trip 2: [S + L + r, S + 2L + r]
trip 3: [S + 2L + 2r, E] (the last trip runs to the end)
r = reload_time_s, L = ((E - S) - (max_trips - 1) * r) / max_trips
The solver enforces each vehicle's window over its whole route, so trip n+1 provably begins no earlier than trip n's window ends, plus the reload time. That guarantee is real and it is the one that matters: no plan ever has a lorry in two places at once, and no plan ever under-counts the time at the tip.
The approximation, precisely. The division happens before the solve, because nothing can know a trip's true end time until the solve that produces it. So it is conservative, and it is incomplete:
- a trip that would finish early cannot lend its spare time to the next one. A lorry that empties its first load in 90 minutes of a 3-hour slot idles until the slot ends;
- consequently, stops can come back unassigned that a truly sequential model could have served. The plan is feasible, not optimal, and the gap widens the more uneven the real trips are;
max_tripsis a budget that shortens every window. Asking for five trips on a shift that supports three cuts each window to a fifth of the day and can make the whole plan worse. Ask for the number you expect to run.
If you need the tighter answer, re-plan after each tip: solve the remaining
stops as a fresh /optimise problem with the trips and the shift the fleet
actually has left.
What does not compose
POST /optimise/replan refuses a problem whose vehicles declare
reloads, with a 400 naming them. A completion in progress is a stop id
and nothing else, so nothing in the body says which trip a vehicle is on — and
guessing would re-partition the day around the guess. Re-plan a tipping fleet
by sending the outstanding work to /optimise with the trips it has left.
Everything else composes: territories confines every trip of a split vehicle
exactly as it confines an ordinary one (and the territories block still talks
about your fleet, not the virtual one), emissions/avoid_zones behave
unchanged, and relax_if_unassigned re-partitions the trips around the widened
shift and reports the windows the returned plan was solved in.
Auto-relaxation: when it does not all fit
Tight windows and a full day mean some stops come back unassigned, and the
dispatcher's next move is always the same: guess which constraint was binding,
edit the body, send it again. relax_if_unassigned says that up front — "if it
does not all fit, you may push the windows by half an hour and run the shift an
hour late, but tell me you did".
| Field | Type | Required | Description |
|---|---|---|---|
time_windows_by_s | integer | one of the two | Widen every task time window by this many seconds at each end. [09:00, 12:00] with 1800 becomes [08:30, 12:30]. |
allow_overtime_s | integer | one of the two | Extend every vehicle's shift end by this many seconds. Starts never move earlier: a driver cannot begin before they begin. |
Both are ceilings you state, and at most 604800 (seven days) each. An
empty object, a zero or a negative is a 400, because it would buy a second
solve that could not help.
The rules, which are the whole feature:
- At most one second solve. Ever. The relaxed problem cannot itself ask to be relaxed, so there is no loop that widens until everything fits.
- Never beyond your caps. The widening applies exactly the numbers you gave. It does not search for the smallest relaxation that would work — that would take many solves, and you would be billed for all of them.
- The relaxed plan is used only if it assigns more work. If it does not, you get the first, unrelaxed plan with every constraint intact.
- Breaks are never widened — neither yours nor an EC 561/2006 break
generated for you. Nor are capacities, skills, territories,
max_tasksormax_travel_time. Only time windows move.
The relaxation block
Present whenever relax_if_unassigned was given, and it always says which
plan you are holding:
{
"relaxation": {
"requested": { "time_windows_by_s": 1800, "allow_overtime_s": 3600 },
"second_solve": true,
"applied": true,
"first_solve": { "unassigned": 2, "unassigned_ids": [104, 108] },
"second_solve_unassigned": 0,
"still_unassigned": [],
"widened": {
"time_windows_by_s": 1800,
"allow_overtime_s": 3600,
"task_windows": 14,
"vehicle_shifts": 3
},
"metering": { "solves_billed": 2, "basis": "…" },
"basis": "…"
}
}
second_solve— whether the solver ran a second time at all.applied— which plan you got.trueis the relaxed one;falsemeans the original, with nothing given away.widened— what actually moved, in the plan you are holding. Whenappliedisfalsethese are all zero and null: nothing was traded.still_unassigned— the ids the returned plan could not place, whichever plan that is.reason— present when no second solve ran, saying why: the first solve placed everything, or the problem declares no time windows for the relaxation to widen.note— present when a second solve ran and did not help, saying that the original plan was returned.
What it costs
A second solve is billed as a second solve. A request carrying a usable
relax_if_unassigned reserves two, and the one that does not run is handed
back — quota units and prepaid pence both, through the same reserve-then-refund
machinery POST /route's rationale fan-out uses. On the asynchronous lane the
doubling is taken at submission (a 202 comes back long before the worker
knows what happened) and refunded when the job completes.
The travel-time matrix is computed once whatever happens: a relaxation
moves times, never locations, so the second solve never re-runs the routing
engine. relaxation.metering.solves_billed states which of the two you paid
for, in the same body as the plan it bought.
Territories and clustering
Two things a fleet with fixed rounds asks for on day one, and they compose:
territories says who is allowed where, and POST /v1/cluster says which
stops belong together when a day is too big for one optimisation.
"Dave does Kent"
Most fleets that have been running for a while do not want a globally optimal plan. They want the plan that respects the rounds their drivers already know: Dave does Kent, Priya does the M25 ring, and the depot van picks up whatever falls outside both. Optimising that as a free-for-all produces a technically shorter plan that nobody will run.
Draw the rounds as territories and confine each vehicle to its own:
curl -fsS -X POST "$BASE/optimise" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"territories": [
{ "id": "kent",
"polygon": [[0.15, 50.90], [1.45, 50.90], [1.45, 51.50],
[0.15, 51.50], [0.15, 50.90]] },
{ "id": "m25",
"polygon": [[-0.55, 51.25], [0.30, 51.25], [0.30, 51.70],
[-0.55, 51.70], [-0.55, 51.25]] }
],
"vehicles": [
{ "id": 1, "start": { "lat": 51.28, "lon": 0.52 },
"territory_ids": ["kent"] },
{ "id": 2, "start": { "lat": 51.51, "lon": -0.13 },
"territory_ids": ["m25"] },
{ "id": 3, "start": { "lat": 51.51, "lon": -0.13 } }
],
"jobs": [
{ "id": 10, "location": { "lat": 51.28, "lon": 1.08 } },
{ "id": 11, "location": { "lat": 51.51, "lon": -0.09 } },
{ "id": 12, "location": { "lat": 51.38, "lon": 0.22 } },
{ "id": 13, "location": { "lat": 52.63, "lon": 1.30 } }
]
}'
Job 10 is in Kent, so vehicle 1 or vehicle 3 may take it. Job 11 is inside the M25 ring: vehicle 2 or vehicle 3. Job 12 sits in both rounds where they overlap, so vehicle 1, 2 or 3 may take it — overlapping territories are a choice, never a requirement to hold them all. Job 13 is in Norwich, outside every territory drawn, so only vehicle 3 may take it: leaving one vehicle unrestricted is what stops out-of-area work quietly falling off the plan.
Polygons are GeoJSON [lon, lat] rings — the same order as exclude_polygons
and /v1/zones boundaries, and the opposite of the {lat, lon} objects
everywhere else on this endpoint. Rings may be open or closed; an open one is
closed for you.
The semantics, precisely
| Case | Who may serve it |
|---|---|
Vehicle with no territory_ids (or an empty list) | Everything, everywhere — inside a territory or outside all of them. |
| Task inside one listed territory | Any vehicle holding that territory, plus every unrestricted vehicle. |
| Task inside several listed territories | Any vehicle holding at least one of them, plus every unrestricted vehicle. Membership of several is a disjunction, not a conjunction. |
| Task inside no declared territory | Unrestricted vehicles only. |
| Shipment (pickup + delivery) | A vehicle eligible at both ends. A shipment across two disjoint territories therefore needs an unrestricted vehicle. |
| Task no vehicle may serve | Returned in unassigned, and named in territories.unservable. Never forced onto a vehicle that is not allowed there. |
Three more things worth being straight about.
Containment is strict. A stop whose coordinate lies exactly on a boundary line is outside every territory. Two adjacent rounds sharing an edge leave a hairline gap, so overlap them slightly if a stop might land on the seam.
It is a point-in-polygon test, not a road-network question. A stop is in a territory if its coordinate is inside the polygon, whatever roads connect it. Rings are also tested as drawn, in plane coordinates, so a territory whose edges are hundreds of kilometres of straight line will classify stops within a few kilometres of an edge against the drawn line rather than the great circle. Draw denser rings if that matters.
It is a hard constraint, not a preference. Eligibility is compiled into solver constraints, so a territory is never traded away for a shorter plan. It costs nothing extra: territories do not change what a request bills.
Reserved skill numbers. Territory eligibility is enforced through the same
skills mechanism you can use yourself, so the gateway reserves the integers
2,000,000,000 and above for it. A request that carries both territories
and a hand-assigned skill in that range is a 400 naming the offender, rather
than a plan in which your "refrigerated" skill quietly also means "may serve
Kent". Below two billion the namespace is entirely yours, and a request with no
territories is not policed at all.
The response block
With territories in the request, the response gains one:
{
"territories": {
"definitions": [
{ "id": "kent", "ring_positions": 5, "jobs_inside": 2,
"jobs_assigned": 2, "jobs_unassigned": 0,
"shipments_touching": 0, "vehicles": [1] }
],
"outside_all": { "jobs": [13], "jobs_assigned": 1 },
"unservable": { "jobs": [], "shipments": [], "note": "…" },
"vehicles": { "restricted": 2, "unrestricted": 1 },
"semantics": "…",
"basis": "…"
}
}
jobs_inside is a fact about the polygons you drew; jobs_assigned is what
the plan did with them. A territory with stops inside it and none assigned is
the shape of a round nobody was allowed or able to run, and it is visible
without diffing the routes by hand.
Clustering: POST /v1/cluster
The 200-location synchronous cap is a request-deadline number, and the honest answer to a thousand-stop day is not "split it somehow". It is: cluster the stops into rounds that fit, then optimise each round.
curl -fsS -X POST "$BASE/v1/cluster" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"locations": [
{ "id": "d-1001", "lat": 51.28, "lon": 1.08, "load": 3 },
{ "id": "d-1002", "lat": 51.29, "lon": 1.11, "load": 5 },
{ "id": "d-1003", "lat": 51.51, "lon": -0.09, "load": 2 }
],
"max_cluster_size": { "locations": 150 }
}'
| Field | Type | Required | Description |
|---|---|---|---|
locations | array | yes | [{id, lat, lon, load?}]. Ids must be unique and non-empty; load is any non-negative weight — parcels, kilograms, minutes. At most 5,000. |
clusters | integer | one of the two | How many groups to produce. Balanced by stop count. |
max_cluster_size | object | one of the two | A per-cluster ceiling, from which the count is derived: {"locations": 150} or {"load": 800}. Exactly one key. |
territories | array | no | Same shape as on /optimise. Given, no cluster straddles a territory: each is clustered on its own, and so are the stops inside none of them. |
seed | integer | no (default 42) | Seeds the k-means++ start. The same request with the same seed always returns the same clusters. |
Give exactly one of clusters and max_cluster_size — both, or neither, is a
400. They answer different questions and the endpoint answers whichever you
ask: clusters balances by stop count; a load balance is
max_cluster_size: {"load": …}. Loads are summed and reported either way.
The response:
{
"clusters": [
{ "id": 0, "territory": "kent",
"centroid": { "lat": 51.285, "lon": 1.095 },
"locations": ["d-1001", "d-1002"], "count": 2, "load": 8.0 }
],
"parameters": { "seed": 42, "clusters": 2, "locations": 3,
"territories": 0, "locations_outside_all_territories": 0,
"iterations": 3, "converged": true },
"balance": { "basis": "locations", "max_locations_per_cluster": 150,
"max_load_per_cluster": null,
"largest_cluster_locations": 2, "largest_cluster_load": 8.0,
"relaxed": false, "note": "…" },
"basis": "Straight-line clustering. …"
}
This is straight-line clustering, and every response says so. Distances are
measured between coordinates on a local planar projection, not along the road
network: no road, river, motorway junction or one-way system is consulted, and
two stops either side of an estuary look adjacent. That is the right tool for
deciding which stops belong together and the wrong one for deciding what order
to visit them in — which is exactly what /optimise is for, and where your
costing, your truck dimensions and any ADR constraints actually apply. No
routing engine is touched here and no claim about drive time is made.
balance.relaxed is the one field to check. A load ceiling with lumpy loads is
a bin-packing problem that may have no solution at the derived cluster count
(three parcels of 6 will not fit two clusters of 10). Rather than refuse, the
endpoint places every stop, puts the overflow in the emptiest cluster and sets
relaxed: true with a note. Raise the ceiling or ask for more clusters.
The recipe
POST /v1/cluster → clusters of at most 150 stops, one per round
for each cluster:
POST /optimise → the order, over our own matrix, with your costing
A 900-stop day is one clustering call (9 units) plus six optimisations of 150
stops each. Add territories to the clustering call and each round stays
inside one driver's patch; pass the same territories to each /optimise call
and the vehicles stay inside it too.
If the whole day genuinely has to be solved as one problem rather than split,
use the asynchronous lane instead: POST /v1/jobs/optimise takes 2,000
locations. Clustering is the answer when the rounds are real; the async lane is
the answer when they are not.
Metering
POST /v1/cluster bills one Standard call per started 100 locations — one
unit for a 100-stop day, fifty at the 5,000 ceiling. It commissions no engine
work, so it is priced on the stops handed over rather than on blocks of
somebody else's compute, and it is an order of magnitude cheaper than
optimising the same stops, which is the right relationship for a step whose
only purpose is to make those optimisations affordable.
territories on /optimise changes nothing about what that endpoint bills.
Errors
Errors are application/problem+json (see
conventions for the envelope and the 402-vs-429
distinction). The one that matters for retry logic: your input's fault is
400, the solver's fault is 502.
| Status | type URN | Meaning | Retry? |
|---|---|---|---|
| 400 | urn:sn-gateway:problem:bad-request | Invalid problem: no vehicles; no jobs or shipments; a vehicle with neither start nor end; an invalid coordinate; options.g: true; adr without "costing": "truck"; or the solver rejected the input. | No; fix the request. |
| 400 | urn:sn-gateway:problem:bad-request | Invalid territories: a ring with fewer than three distinct positions or more than 1,000; a ring enclosing no area; a duplicate or empty territory id; more than 64 territories; a territory_ids naming a territory the request never declared, or naming any at all when the request declares none; or a hand-assigned skill at or above 2,000,000,000 alongside territories. On POST /v1/cluster: neither or both sizing fields, a duplicate or empty location id, a negative or non-finite load, more clusters than locations, or a load ceiling one stop exceeds on its own. | No; fix the request. |
| 401 | urn:sn-gateway:problem:unauthorized | Missing or invalid API key. | No. |
| 422 | urn:sn-gateway:problem:optimisation-too-large | More than 200 unique locations on POST /optimise, or more than 2,000 on POST /v1/jobs/optimise. Body carries max_locations and locations, plus async_lane when the synchronous cap is the one that fired. | Not as sent. Up to 2,000 locations, resubmit to POST /v1/jobs/optimise; beyond that, split the problem. |
| 422 | urn:sn-gateway:problem:matrix-span-too-large | The problem spans further than the routing engine will compute a matrix over (on the hosted deployment, 1,500 km between the furthest two points on the motor costings auto, truck and motor_scooter, and 200 km on bicycle and pedestrian). Body carries max_span_km, span_km and furthest_pair: {source_index, target_index} naming the offending pair, plus limit_env, engine_setting and self_host_docs for raising it on your own deployment. | No; narrow the area, and splitting does not help (see below). |
| 422 | urn:sn-gateway:problem:cluster-too-large | POST /v1/cluster with more than 5,000 locations, or a locations x clusters assignment over 500,000. Body carries max, got and limit (locations or assignment_work) so you can tell which ceiling fired without parsing the prose. | Not as sent; split the area or ask for fewer clusters. |
| 429 | urn:sn-gateway:problem:quota-exceeded or …:rate-limited | Monthly quota or rate limit; rate-limited bodies carry retry_after (seconds). | After retry_after. |
| 502 | urn:sn-gateway:problem:upstream-error | The solver sidecar is unreachable or failed. | Yes; transient. |
| 503 | urn:sn-gateway:problem:optimisation-not-enabled | No solver sidecar on this deployment (SN_VROOM_URL unset). | Not until the operator enables it. |
Example 422:
{
"type": "urn:sn-gateway:problem:optimisation-too-large",
"title": "Optimisation problem too large",
"status": 422,
"detail": "optimisation problem too large: 240 unique locations exceeds the fair-use cap of 200 per request; split the problem or contact the operator",
"max_locations": 200,
"locations": 240
}
Metering and fair use
One optimisation request bills 10 calls of its class (Standard for
costing: "auto", Premium for truck/ADR) per 2,500-pair block of its
internal matrix: one block, so the familiar flat 10, up to 50 unique
locations; 40 at 100; 160 at the 200 cap. That internal matrix is not
billed separately. See pricing for the per-class rates.
Size is capped at 200 unique locations per request (422 beyond) —
and that cap is the request deadline talking, not the solver. The same
problem goes up to 2,000 locations on the asynchronous lane,
POST /v1/jobs/optimise, which answers with a job id you poll for free or
take a signed webhook on; the 422 above names it. See
asynchronous jobs.
Re-planning the same day part-way through it is
POST /optimise/replan, which
takes this body plus what has been completed and what has changed. It bills
on exactly this formula, applied to the remaining problem: a locked
prefix is not re-solved, so it is not billed.
relax_if_unassigned bills two solves when the second one runs, and one
when it does not. The request reserves the worst case up front and the unused
solve is refunded — from the response on the synchronous lanes, on completion
on the asynchronous ones. The internal matrix is computed once either way,
because a relaxation moves times and never locations, so the doubling prices
the second solver run and nothing else. The block in the answer says which you
paid for: see "What it costs".
reloads costs nothing extra. A reload split adds virtual vehicles, never
locations — the depot is already one of the problem's own coordinates — so a
tipping round bills exactly what the same stops bill without it.
Above 50 locations the internal N×N matrix is larger than the routing engine accepts in one call (2,500 source×target pairs), so it is computed as several 50×50 blocks, issued concurrently and stitched into one grid before the solver sees it: 200 locations is 40,000 pairs, sixteen blocks. Every cell lands where it belongs, and a block that fails fails the request rather than handing the solver a grid with holes in it. Billing follows the blocks so that a large problem pays for the engine work it commissions; every problem size that has ever succeeded costs exactly what it always did. Talk to sales for larger sustained problems.
One limit chunking does not lift. The engine also caps a matrix's
span: the great-circle distance between the furthest two points, held as
service_limits.<costing>.max_matrix_distance. On the hosted deployment
that is 1,500 km on every motor costing optimisation accepts, auto,
truck and motor_scooter, raised from the engine's stock 400 km (car and
truck in August 2026, the rest in September 2026); bicycle and
pedestrian stay at 200 km, deliberately. The offending pair sits in
whichever block it lands in, so no amount of splitting hides it, and a
problem spread wider is refused with
422 matrix-span-too-large before any engine work is commissioned. A day's
delivery round is comfortably inside it, and so now is a Great Britain-wide
problem; a Europe-wide one is not. Self-hosted deployments carry whatever
their own valhalla.json says, which is the stock 400 km unless the
operator raised it.
Benchmark it yourself
We have not published a table of our own timings here, because a number you
cannot reproduce is a number you should not believe. What ships instead is the
harness: scripts/vrp-bench/ in the repository, plain Node with no
dependencies and no build step.
It generates deterministic capacitated-VRP instances from a seed (one depot,
N jobs drawn uniformly over a disc around it, a fleet sized so capacity
actually binds), posts them at whichever gateway you point it at, and prints a
markdown table of solution cost, plan duration, distance and wall time. Same
seed, same problems, on any machine, on any day, with an instance fingerprint
printed alongside so two people can confirm they measured the same thing.
Dry run is the default and opens no network connections at all:
node scripts/vrp-bench/bench.mjs
| Stops | Vehicles | Unique locations | Span (km) | Billable units | Instance |
| --- | --- | --- | --- | --- | --- |
| 49 | 2 | 50 / 200 | 48.2 | 10 | `97ebc06a` |
| 99 | 4 | 100 / 200 | 49.9 | 40 | `0aa06a77` |
| 199 | 8 | 200 / 200 | 49.9 | 160 | `8891d0f5` |
Those three sizes are 50, 100 and 200 unique locations once the depot is
counted, which is exactly where the matrix block boundaries fall, so a dry run
reproduces the billing table above before it spends anything. Add --run with
MAPMAP_URL and MAPMAP_API_KEY set to get real timings against the hosted
gateway or your own:
MAPMAP_URL=https://api.mapmap.ai MAPMAP_API_KEY=snk_... \
node scripts/vrp-bench/bench.mjs --run
--help lists the rest: --seed, --stops, --vehicles, --costing,
--repeat for a median over several runs, and --out to write the table to a
file. /optimise is metered, so the keyless demo lane will not serve it; the
harness reports the 401 rather than printing an empty table.
For agents
The same capability is exposed as the optimise_routes
MCP tool, so an agent can plan a fleet's day end to end: check
compliance with check_adr_tunnel, optimise the visits, then fetch
geometries per leg.
Next steps
- Conventions: base URL, auth, error envelope, ADR tunnel codes
- Analysis APIs: the matrix endpoint that powers optimisation
- Self-host: enable the solver sidecar with
SN_VROOM_URL - MCP server:
optimise_routesandcheck_adr_tunnelfor agents