Skip to content

50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / optimisation · raw .md

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_URL on the gateway to a vroom-express sidecar; the self-host guide and the distro's docker-compose.yml include a ready-made service to uncomment.
  • When the sidecar is not configured, the endpoint returns 503 with problem type urn: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

LimitHosted valueSource of truth
Unique locations per request, synchronous200Fixed. 422 optimisation-too-large, whose body carries max_locations and an async_lane pointer.
Unique locations per request, asynchronous2,000Configurable: 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 requestNo separate capA 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 requestNo separate capAs above. A job contributes one location, a shipment two, and repeated coordinates deduplicate.
Matrix span, furthest two points1,500 km on the motor costings (auto, truck, motor_scooter); 200 km on bicycle and pedestrianConfigurable: 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 size2,500 source x target pairsConfigurable: 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.
Billing10 units per started blockFixed. See "Metering and fair use" below for the worked table.
Costings acceptedauto, truck, bicycle, pedestrian, motor_scooterFixed. Anything else is a 400.
Route geometry in the responseNot returnedFixed. options.g: true is a 400; fetch legs from POST /route.
Solver availabilityEnabledConfigurable: SN_VROOM_URL. Unset, every call is 503 optimisation-not-enabled.
Trips per reload-enabled vehicle2 to 5Fixed. 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 aboveFixed. Only policed when a request uses reloads; a caller vehicle numbered into it is a 400 naming the vehicle.
Single relaxation amount604,800 s (7 days)Fixed. 400 beyond, and on a non-positive or absent cap.
Solves per requestat most 2Fixed. Never a third: the relaxed problem cannot itself ask to be relaxed.
Territories per request64Fixed. 400 beyond.
Positions per territory ring1,000Fixed. 400 beyond; simplify the boundary first.
Locations per POST /v1/cluster request5,000Fixed. 422 cluster-too-large.
Clusters per POST /v1/cluster request500, and at most 500,000 locations x clustersFixed. 400 on the cluster count, 422 cluster-too-large on the product.

The endpoints underneath it

LimitHosted valueSource of truth
/matrix elements per request (sources x targets)10,000, so up to 100 x 100Fixed. 422 matrix-too-large.
/v1/jobs/matrix elements per request40,000Configurable: SN_MATRIX_MAX_ELEMENTS_ASYNC. The asynchronous lane for the same matrix.
/matrix billing block25 elements per unitFixed.
/isochrone contours per request10Fixed. 400 beyond.
/route waypoints per unit20Fixed. 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.

MapMapMapbox
Distance matrix, size per request10,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 itGenerally 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 size200 unique locationsv1: "There must be between two and 12 coordinates" (Optimization v1). v2: "Maximum number of 1000 locations per routing problem"
Isochrone contours per request10"The Isochrone API can support a maximum of 4 isochrone contours per request" (Isochrone API)
Where you may draw the resultNo 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 categoriesA top-level adr profile on /route and /optimise; adr_tunnel_code and hazmat in costing_options.truck on /matrix and /isochroneNo 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

FieldTypeRequiredDescription
vehiclesarrayyes (≥ 1)The fleet.
jobsarraysee noteSingle-stop jobs. At least one job or shipment is required.
shipmentsarraysee notePickup+delivery pairs that must ride the same vehicle, pickup first.
costingstringno (default "auto")Costing model for the travel-time matrix: "auto", "truck", "bicycle", "pedestrian" or "motor_scooter".
costing_optionsobjectnoValhalla-style costing options passed to the matrix verbatim, e.g. {"truck": {"height": 4.0}}.
adrobjectnoADR vehicle profile, same shape as on POST /route (dimensions, tunnel_code, hazmat). Requires "costing": "truck" (else 400); merged into costing_options.truck.
territoriesarraynoNamed polygons bounding which vehicle may serve which stop, [{"id": "kent", "polygon": [[lon, lat], …]}]. See "Territories and clustering".
relax_if_unassignedobjectno{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".
optionsobjectnoOnly g (geometry) exists, and it is not supported: {"g": true} is a 400. Fetch leg geometry via POST /route instead.

Vehicles

FieldTypeRequiredDescription
idintegeryesCaller-chosen id, echoed back on the vehicle's route.
start{lat, lon}at least one of start/endStart location.
end{lat, lon}at least one of start/endEnd location; omitted, the route ends at its last stop.
capacityinteger arraynoMultidimensional capacity; same length as job delivery/pickup and shipment amount.
skillsinteger arraynoSkills this vehicle provides; a task requiring a skill the vehicle lacks is never assigned to it.
time_window[start, end]noWorking window in seconds (see "Times" below).
breaksarraynoMandatory rest breaks (see "Driver hours & compliance").
max_travel_timeintegernoCap on driving time in seconds (excludes service, setup and waiting). A daily driving limit maps here.
max_tasksintegernoCap on the number of tasks this vehicle may serve.
costsobjectnoCost 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_idsstring arraynoIds from territories this vehicle is confined to. Omitted or empty, it serves anywhere.
reloadsobjectno{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

FieldTypeRequiredDescription
idintegeryesCaller-chosen id, echoed back in steps and unassigned.
location{lat, lon}yesJob location.
service_sintegernoOn-site service time in seconds.
deliveryinteger arraynoQuantities delivered to the job (matches vehicle capacity).
pickupinteger arraynoQuantities picked up at the job.
skillsinteger arraynoSkills the job requires.
time_windowsarray of [start, end]noAcceptable arrival windows in seconds.

Shipments

FieldTypeRequiredDescription
pickupobjectyesThe pickup end: id, location, optional service_s, optional time_windows (same shapes as on jobs).
deliveryobjectyesThe delivery end, same shape as pickup.
amountinteger arraynoQuantities moved (matches vehicle capacity).
skillsinteger arraynoSkills 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:

FieldTypeRequiredDescription
idintegeryesEchoed back on the break step.
time_windowsarray of [start, end]yesWhen the break may be taken (same epoch as time_window).
service_sintegernoBreak duration in seconds.
max_loadinteger arraynoThe break is only allowed while the load is at or below this.
descriptionstringnoFree 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:

json
{ "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:

json
{ "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:

sh
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 adr profile constrains the roads. It is merged into costing_options.truck and 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.
  • skills constrain the assignment. A job is only ever given to a vehicle whose skills are 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.
sh
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:

json
{
  "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):

json
{
  "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:

  • code is always 0 on HTTP 200; solver failures surface as HTTP errors, never as a non-zero code in a 200 body.
  • profile echoes the matrix costing used, one of "auto", "truck", "bicycle", "pedestrian" or "motor_scooter".
  • cost is the solver's objective; with the matrices the gateway supplies it equals travel time in seconds.
  • Step duration is cumulative travel time up to that step; arrival is on your chosen epoch (see "Times" above).
  • Unassignable tasks are never silently dropped: each appears in unassigned as { "id": 12, "type": "job", "location": { "lat": …, "lon": … } }.
  • Route geometry is not returned by the solver; request each leg from POST /route if you need lines on a map.
  • routes[].trip appears only when that vehicle declared reloads; vehicle is always your own id.
  • A relaxation block appears only when you sent relax_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.

FieldTypeRequiredDescription
max_tripsinteger, 2–5yesHow 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_sintegerno (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}noWhere 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 start and end, if given, must be the depot: every trip begins and ends there. A start somewhere else is a 400 saying so;
  • capacity, max_tasks, max_travel_time and costs are 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: 40 on a three-trip lorry means forty stops per trip, and costs.fixed is 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:

bash
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:

json
{
  "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[].vehicle is your id, always. The trips are separate routes on the same vehicle, ordered by trip, and summary.routes therefore counts trips, not vehicles.
  • trips_used counts 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_windows are 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:

ini
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_trips is 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".

FieldTypeRequiredDescription
time_windows_by_sintegerone of the twoWiden every task time window by this many seconds at each end. [09:00, 12:00] with 1800 becomes [08:30, 12:30].
allow_overtime_sintegerone of the twoExtend 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:

  1. 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.
  2. 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.
  3. 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.
  4. Breaks are never widened — neither yours nor an EC 561/2006 break generated for you. Nor are capacities, skills, territories, max_tasks or max_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:

json
{
  "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.
  • appliedwhich plan you got. true is the relaxed one; false means the original, with nothing given away.
  • widened — what actually moved, in the plan you are holding. When applied is false these 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:

sh
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

CaseWho 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 territoryAny vehicle holding that territory, plus every unrestricted vehicle.
Task inside several listed territoriesAny vehicle holding at least one of them, plus every unrestricted vehicle. Membership of several is a disjunction, not a conjunction.
Task inside no declared territoryUnrestricted 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 serveReturned 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:

json
{
  "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.

sh
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 }
}'
FieldTypeRequiredDescription
locationsarrayyes[{id, lat, lon, load?}]. Ids must be unique and non-empty; load is any non-negative weight — parcels, kilograms, minutes. At most 5,000.
clustersintegerone of the twoHow many groups to produce. Balanced by stop count.
max_cluster_sizeobjectone of the twoA per-cluster ceiling, from which the count is derived: {"locations": 150} or {"load": 800}. Exactly one key.
territoriesarraynoSame 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.
seedintegerno (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:

json
{
  "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

sql
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.

Statustype URNMeaningRetry?
400urn:sn-gateway:problem:bad-requestInvalid 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.
400urn:sn-gateway:problem:bad-requestInvalid 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.
401urn:sn-gateway:problem:unauthorizedMissing or invalid API key.No.
422urn:sn-gateway:problem:optimisation-too-largeMore 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.
422urn:sn-gateway:problem:matrix-span-too-largeThe 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).
422urn:sn-gateway:problem:cluster-too-largePOST /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.
429urn:sn-gateway:problem:quota-exceeded or …:rate-limitedMonthly quota or rate limit; rate-limited bodies carry retry_after (seconds).After retry_after.
502urn:sn-gateway:problem:upstream-errorThe solver sidecar is unreachable or failed.Yes; transient.
503urn:sn-gateway:problem:optimisation-not-enabledNo solver sidecar on this deployment (SN_VROOM_URL unset).Not until the operator enables it.

Example 422:

json
{
  "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:

sh
node scripts/vrp-bench/bench.mjs
go
| 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:

sh
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_routes and check_adr_tunnel for agents