Skip to main content
V3 is in beta. General availability (GA) is planned for January 2027.
A heating and electrical contractor in Ghent plans one working day: eight jobs for three technicians who start and end at the same workshop. Some jobs need a gas-certified or electrically qualified technician, two need a boiler from the workshop, two visits to one customer must be done by the same person, and one maintenance visit is optional. Everyone takes a 30-minute lunch break that starts between 12:00 and 13:00. This page solves that day with POST /v3/routing/solve, reads the response, and then reuses the same data for two follow-ups: scoring a manual change with /v3/routing/evaluate and placing a new job with /v3/routing/suggest. The solve and evaluate responses are real output for the requests shown. Yours will differ in the travel figures, which depend on live road data.

The scenario

All times in the request are local time on 4 November 2026, written with the +01:00 offset. How each requirement maps to the request:
  • Time windows. time_windows on each job. They are hard: a job that cannot be reached inside its window is left unassigned.
  • Skills. skills on a job lists what it requires, skills on a vehicle lists what the technician provides. A job goes only to a vehicle that provides every skill it names. See Skills.
  • Boilers. delivery: { "boilers": 1 } on the two install jobs is stock loaded at the workshop and dropped at the job. It counts against the van’s capacity: { "boilers": 1 }, so the two installs cannot share a van. See Capacity.
  • Same technician. One same_vehicle relation names the two Deinze visits. See Relations.
  • Lunch. A windowed break of 1,800 seconds on each shift, with one window that bounds when the break may start. See Driver breaks.
  • Optional visit. drop_fee: 30 on the Bruges check. It is served only when serving it costs less than 30. See Priority and optional tasks.
  • Workshop. One depots[] entry, referenced by id from each shift’s start and end.
  • Cost. objective.costs prices a technician’s time at 40 per hour of route and driving at 0.30 per kilometer, labeled EUR.

Solve the day

options.time_limit_s gives the search two seconds. Add ?validate_only=true to the URL to check the body first without solving.

Read the response

The response below is the real output for that request, with one thing trimmed: each stop’s snapped_location (the coordinate snapped to the road network) is removed to keep it short. Response times are in each shift’s own offset, +01:00 here, so they read as local time.

Summary

  • tasks reads served: 7, unserved: 1: seven of the eight jobs are on a route and one is in unassigned[]. The call still returns HTTP 200.
  • vehicles_used is 2. routes[] has an entry for anna and one for bram. chloe serves nothing, and a vehicle shift with no stops gets no routes[] entry. This request puts no price on using a vehicle; see Objective function for how to add one.
  • distance_m and time.travel_s are sums over the routes. time.travel_s is driving time only: 6,709 s + 6,236 s = 12,945 s.

Routes and stops

Each route names its vehicle and shift by the ids you sent, and lists its stops in visiting order between a start and an end stop. Those two stops carry depot: "workshop" because the shift references the depot by id. Anna’s day, in local time with the seconds dropped: What to take from it:
  • Skills. Every gas job is on anna or bram, and both electrical jobs are on anna, who has both skills.
  • Capacity. Each van carries one boiler, so the two installs are on different routes. load_peak is the highest load on the route: { "boilers": 1 } on both.
  • The relation. boiler-install-deinze and boiler-commission-deinze are both on anna, as the same_vehicle relation requires.
  • Time windows. arrival is when the vehicle reaches the stop. The commissioning visit starts at 14:00, the moment its window opens.
  • Breaks. A break stop has no id and no location. Its arrival and departure are the start and end of the break. It is listed directly before the next stop on the route, and in this response each break starts after the vehicle has reached that stop, so a break’s arrival can be later than the arrival of the stop listed after it. Anna reaches Deinze at 11:42, takes her break from 12:00 to 12:30, and then does the 90-minute install, so the install’s service_start is 12:30. That is why the install’s departure minus its arrival is longer than its service_s: the interval also holds the break and the time before it. Bram has no job left when his break window opens at 12:00. A break whose window opens before the shift’s to is still taken on a route that is in use, so his lands at the end of the route: he reaches the workshop at 12:13 and the end stop has arrival 12:13 and departure 12:43.
  • Durations. time.travel_s is driving time only. time.span_s is the route’s span from start to finish, including service, waiting and the break: 28,489 s for anna (08:00 to 15:54) and 17,036 s for bram (08:00 to 12:43).
  • plan. The same routes in the shape a request accepts: task stops only, without start, end and break stops. The two follow-ups below send it back.

The unassigned job

Read category first. priced_out means the job fits, it has a drop_fee, and serving it would cost at least that fee, so the solver paid the fee instead. The 30 shows up in score.components.drop_fees, and score.unserved_priority stays 0 because the job was not must-serve. The reason code for a priced_out job is SOLVER_LIMIT, and its message mentions the time budget. That code is shared with the search_limit category, so branch on category and not on the code: a longer time_limit_s would leave this job out again. relaxations is empty for this job. Errors lists every category and reason code.

Score

score.cost is the sum of score.components, in the currency you labeled: Every component is always present, so the ones this request does not use are 0.0. violations is always 0 on a solve response. See Objective function for each component.

Score a manual change with evaluate

A dispatcher decides the Bruges check has to happen today and gives it to bram, who is free after lunch. Before committing that, send the edited plan to /v3/routing/evaluate. The problem is unchanged. The plan is the solve response’s plan with one stop appended to Bram’s route:
Evaluate takes problem and plan and no options.
The real response, trimmed to summary, Bram’s route and unassigned. Anna’s route comes back exactly as in the solve response, and plan echoes the plan you sent. snapped_location is removed as before.
How to read it:
  • score.violations is 0: the edited plan breaks no hard rule. A broken rule is counted there and listed as a violations[] entry on the stop or route it concerns. timings has no search_ms, because nothing is searched.
  • The new stop. Bram reaches Bruges at 12:19 local time and takes his lunch break there. The job’s window opens at 15:00, so service_start is 15:00 and wait_s is 7,853 s (the waiting that is left after the 30-minute break), and he leaves at 15:45. His route now ends at 16:24 instead of 12:43.
  • unassigned is empty: every job of problem is in the plan.
The change has a price. cost rises from 595.80795 to 728.96285, which is 133.1549 more: Serving the visit this way costs 163.1549 against a fee of 30, which is why the solve left it out. If the visit must always be planned, remove drop_fee from the job, or raise the fee to what a missed visit costs you. See Evaluate for the violation types.

Place a new job with suggest

At 11:00 a customer in Destelbergen reports a leaking radiator and can be visited between 13:00 and 17:00. The routes are already under way, so ask where the new job fits without rearranging them. A suggest request takes the same body as evaluate:
  • problem: the same problem with the new job added to jobs.
  • plan: the plan you are keeping. Here it is the solve response’s plan, unchanged.
Every task of problem that is not in plan is a task to place; there is no list of ids to send. The job to add:
And the options:
The response has one suggestions[] entry per task outside plan, in problem order. The entry for radiator-leak-destelbergen has up to three insertions, cheapest first. Each insertion carries a delta (the change in cost, in your currency, and in distance_m and driving duration_s), one placements[] entry per stop it adds (the plan stop’s type and id, the vehicle and shift of the route it goes into, and its arrival and latest_arrival), and routes[]: every plan route it changes, whole, with the new stop in. The job requires gas, so no insertion names chloe. annual-check-brugge is still in problem and not in plan, so it is a task to place too and gets its own entry, listed first. To get suggestions for the new job only, drop annual-check-brugge from problem (and from any relation that names it). Suggest changes nothing. To act on an insertion:
1

Swap in the route

Replace the plan.routes[] entry with the same vehicle and shift as each of the insertion’s routes by that route.
2

Score the result

Send the edited plan to /v3/routing/evaluate, as in the previous section, to get the full timeline and cost.
To mark stops that are already done or under way, set locked_count on a route in plan: no insertion lands before the locked stops. This page does not show a suggest response; Suggest has an annotated one and the full list of insertion fields.

Next steps

The V3 API model

Every request and response field.

Constraints

The rules used here, and the ones this example leaves out.

Evaluate

The plan shape and every violation type.

Suggest

Reading and applying insertions.