Skip to main content
The V3 Routing API describes a vehicle routing problem with a small set of orthogonal primitives instead of dozens of overlapping special-case fields. A minimal request is two arrays: jobs and vehicles.
This page describes the request/response schema for POST /v3/routing/solve, which /v3/routing/evaluate and /v3/routing/suggest also build on. The request is strict-schema throughout: an old field name, a typo, or a feature this page marks unsupported gets an immediate 422 (unknown field) or a field-named 400, never a silent no-op.

Design principles

Few orthogonal primitives

Eligibility, skills, and sequencing are each a single field, not five overlapping ones. A pickup-then-delivery job is a distinct shipments[] entry type, not a flag on a job.

Everything soft is money

No abstract weight vector. Lateness costs a rate per hour, dropping a task costs a fee, a non-preferred vehicle costs an amount — one unit you trade against, with an itemised cost breakdown in every response.

Penalties live on the thing

A task’s drop price (drop_fee) and preferences sit on the task object itself — read one job and you see exactly what it costs to bend each rule for it.

Strict schema, named rejections

Unknown fields are 422s. Fields the solver doesn’t honor yet are field-named 400s. There is no silently-ignored option.

A request at a glance

The smallest valid request — minimise travel cost, serve everything:
A request has up to seven top-level fields: jobs, shipments, vehicles, relations, objective, options, and initial_routes (a warm-start — see RouteAssignment). Only jobs (or shipments) and vehicles are required.

Jobs

A job is a unit of work at one location — a delivery, pickup, service call, or inspection. Its visit fields (location, service_duration_s, time_windows) sit directly on the object. A distinct pickup-then-delivery entry type, shipments[], is covered below.
Place
Where the job is performed: { "coordinate": [lon, lat] } (GeoJSON order), or the bare [lon, lat] array as shorthand. Omit location for a virtual (location-less) job — it still has service duration, time windows, demand, and skills, but requires no travel. There is no location_id or depot reference form: Place is inline-coordinate only.
integer
Seconds paid once on arrival at a new location, distinct from service_duration_s and skipped when the immediate predecessor in the route is already at this location. Not scaled by service_factor.
integer
Nominal on-site service time in seconds. The serving vehicle’s service_factor scales it, so the response’s stops[].service_s may differ from the value you sent.
array
One or more { "from", "to" } windows (ISO 8601). Hard by default — arriving outside every window makes the job unassignable. An optional per-window cost grades preference among a task’s own alternative windows (not a soft-lateness mechanism — see Constraints).
object
Named multi-dimensional load, e.g. { "weight": 30, "volume": 2 }. Matched against vehicle.capacity by name — order never matters, and a demand dimension no vehicle declares is a 400.
boolean
default:"true (implicit)"
Omitted or true = must serve, infeasible if it cannot be. false is only meaningful together with drop_fee — a droppable task with no price is rejected with 400, since an optional task with nothing at stake would never need serving.
number
What leaving this task unserved costs, in the objective’s currency. The only way to make a task droppable: it trades directly against travel cost and is served whenever serving it is cheaper than the fee. Replaces the removed unassigned_cost field, which ranked drops in a tier that never actually traded against money.
array
Hard capability requirements: [{ "name": "fridge" }]. The serving vehicle must provide every named skill (subset match). There is no soft (violation_cost) form on skills[] — for a soft affinity use tags + preferred_tags (vehicle-level) or preferences (below).
object
Hard filter: allowed (whitelist, omit = all vehicles eligible) or excluded (blacklist), mutually exclusive.
string
Hard-pins the job to one vehicle id; survives re-optimisation. Stronger than eligible_vehicles (a set filter) and hard, unlike preferences.
array
Soft vehicle affinity: [{ "vehicle": "v2", "violation_cost": 800 }]. Each entry prices independently — a job listing several preferred vehicles pays every entry whose vehicle didn’t serve it.
array of strings
Free-form labels used for relation group matching and, when a shift scopes itself via serves_tags (hard) or a vehicle prices preferred_tags (soft), for tag matching. ANY-overlap, unlike the ALL-subset skills rule.
object
Soft point deadline: { "at": "...", "per_late_hour": 60.0 }. Arriving after at is priced, never infeasible, and never forces waiting for early arrival. Independent of objective.costs.per_late_hour. Not yet supported on shipments[] (400).
string (ISO 8601 instant)
Soft preference for which calendar day (UTC) a multi-shift task lands on, priced by objective.non_preferred_date_cost (unset/0 = inert).

Shipments

A shipment is a bound pickup→delivery pair — a distinct shipments[] entry type, not a job with a flag. The solver guarantees the pickup precedes the delivery on the same route; carried demand counts against capacity only between the two legs.
pickup/delivery are Stop objects — each has its own location, setup_duration_s, service_duration_s, and time_windows; every other job field (demand, mandatory, drop_fee, skills, eligible_vehicles, preferences, tags, preferred_date) is shared and applies to the pair as a whole (one droppability/eligibility decision, one drop_fee charged once). target_arrival is not yet supported on shipments (400).
For a shipment whose two legs move different amounts (a cargo swap), set demand on pickup/delivery individually instead of the shared shipment-level demand. The two forms are mutually exclusive.

Vehicles & shifts

A vehicle carries capacity, skills, a cost model, and one or more shifts. The shift is where the working window and depot locations live — there is no vehicle-level count (fleet size is one entry per physical vehicle).
string
default:"car"
Routing profile for this vehicle’s distance/time matrix: "car", "truck", or "bike". Vehicles on different profiles get independently fetched matrices.
object
Named dimensions, matched against job/shipment demand by name.
array
Soft: [{ "tag": "north", "violation_cost": 25.0 }]. Serving a task that lacks a preferred tag incurs that entry’s cost; entries apply independently.
object
Hard route caps: max_distance_m, max_route_duration_s (whole-route span — travel, service, and wait all count, not pure drive time). There is no separate pure-drive-time cap.
object
Per-vehicle overrides. fixed (one-time, when used), per_travel_hour, per_stop — at most one of these three may be set per vehicle (a second is 400). per_travel_km is a distance-rate override and combines freely with whichever of the three you picked.
number
default:"1.0"
Travel-speed multiplier, (0, 5]. Effective travel time = matrix_time / speed_factor — above 1.0 is faster.
number
default:"1.0"
Work-rate multiplier for service_duration_s only, (0, 5] — never setup_duration_s, depot/reload handling time, waiting, or breaks. A shift may override it for its own day via shifts[].service_factor.
Place
Per-shift depot location. start is where the vehicle departs, end where it must return. Absent = open start/end (no depot on that side).
integer
Seconds past to the vehicle may run, priced via objective.costs.per_overtime_hour (or the vehicle’s own override).
integer
Maximum job stops for this shift. Lives on the shift, not vehicle.limits.
array of strings
Hard scope: when set, this shift serves only tasks carrying at least one of these tags (untagged tasks remain serveable by every shift).
array
Scheduled driver breaks (floating with a window or a drive/duty trigger, or fixed). See Constraints.
array
Mid-shift depot visits to reset capacity and start a fresh trip (multi-trip). Capped with max_trips. See Constraints.

Relations

A single relations array expresses every inter-job ordering, grouping, and synchronisation rule. Each relation is a tagged object with a type, and every relation is hard — there is no per-relation violation_cost.
relations[] has exactly these four variants. consecutive and same_route were considered and removed from the schema (never wired, always rejected) rather than left as permanent 400s — an unknown relation type is now an unknown-field rejection, not a not-yet-supported one.

Objective

The whole block is optional — omit it for “minimise travel cost, serve everything, use as few vehicles as it takes.”
  • minimize_vehicles — a bool, default true. Whether using fewer vehicles outranks minimising cost. This is the one real degree of freedom; it replaced an objective.priorities token list that could never actually be reordered.
  • costs — fleet-wide rates: per_travel_km, per_travel_hour, per_wait_hour, per_overtime_hour, per_late_hour (soft time-window lateness, fleet-wide only), per_vehicle (flat per-route activation charge), plus an informational currency label. Omitting costs entirely seeds a realistic baseline — €0.20/km and €35/hour — and nothing else; every other rate defaults to 0/unset.
  • non_preferred_date_cost — flat charge for serving a task on a day other than its preferred_date.
  • grouping — keeps geographically clustered tasks consecutive in a route (the “passing-waypoint” feature); presence enables it.
Soft penalties that belong to a specific task live on that object (drop_fee, preferences[].violation_cost, target_arrival.per_late_hour) — not in this global block. See Objective Function for the full two-tier ranking and defaulting behaviour, and Constraints for every hard/soft rule.

Response

A solve returns a summary, per-stop routes, and unassigned jobs with structured reasons.
Key response fields:
  • summary.status — solved, partial, or infeasible
  • summary.estimated_cost — always present. components.travel/waiting are always reported; drop_fees/overtime appear only when priced; lateness/vehicles_fixed/preferences/imbalance are declared in the schema but never populated today — don’t chart them as zero, they’re absent because they aren’t wired yet
  • routes[].vehicle — the plain vehicle.id string, not a { type, instance } object (there is no vehicle count to disambiguate — see Migration from V2)
  • routes[].stops[].type — start, job, pickup, delivery, end always; break and reload appear too, on routes whose vehicle has scheduled driver breaks or depot reloads respectively
  • routes[].stops[].load_after — named load map after serving this stop
  • unassigned[].job_id — the id of the unassigned job (a shipment’s bare id, for either leg)
  • unassigned[].reasons — structured { code, message } objects from a closed machine-code set (TIME_WINDOW_VIOLATED, CAPACITY_EXCEEDED, SKILLS_MISMATCH, …)
  • unassigned[].relaxations — best-effort, actionable suggestions (e.g. { "op": "extend_time_window", "amount": 900, "unit": "s" }); empty when no simple relaxation exists

Warm-starting with initial_routes

initial_routes seeds the search from an existing plan (re-optimisation) instead of constructing from scratch:
Each entry names a vehicle, an ordered job_ids list (a shipment id appears twice — pickup then delivery), an optional shift_index (which of the vehicle’s shifts this fills, default 0), and locked_count — how many leading job_ids the search must not move (a dispatched prefix). Unknown vehicle/job ids are rejected with 400. /v3/routing/evaluate and /v3/routing/suggest also accept initial_routes for solve-payload compatibility, but ignore it — routes is their actual input.

Constraints

Every constraint field, hard vs. soft, with examples.

Migration from V2

Field-by-field mapping from the V2 VRP API.

Objective Function

The two-tier lexicographic ranking behind estimated_cost.

API Reference

Full request and response schema for POST /v3/routing/solve.