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 can differ in the travel figures, if the road data has changed since, and in timings.

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. required_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_load: { "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_cost: 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. Send the body to POST /v3/routing/validate/solve first to check it without solving.

Read the response

The response below is the real output for that request, with three things trimmed: each stop’s snapped_location (the coordinate snapped to the road network), and the top-level search_progress and warnings, 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, unassigned: 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: 8,756 s + 4,064 s = 12,820 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 and service_start is when the work begins. Bram reaches Eeklo at 08:29, before its window opens, so the stop has wait_s: 1805 and service_start 09:00.
  • Breaks. A break stop has no location, and carries an id only when the request break has one. Its arrival and departure are the start and end of the break, and service_s is its length. It is listed directly before the stop it falls inside, and 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, within that stop’s arrival to departure. Anna reaches Wetteren at 11:55 and takes her break from 12:00 to 12:30 before the two-hour job, so its service_start is 12:30 and the stop’s departure minus arrival is its service_s plus the break plus wait_s: 264, the idle before the break window opens. That idle counts in time.wait_s but not in the waiting cost component; it is paid through the span. 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: the end stop has arrival 11:37, wait_s 1,331 (the idle until 12:00) and departure 12:30.
  • 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: 31,520 s for anna (08:00 to 16:45) and 16,200 s for bram (08:00 to 12:30). The parts add up to it: for anna, 8,756 s travel + 20,700 s service + 264 s waiting + 1,800 s break = 31,520 s.
  • 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_cost, and serving it would cost at least that fee, so the solver paid the fee instead. The 30 shows up in score.components.drop_costs, and score.unassigned_priority stays 0 because the job was not must-serve. A priced_out job has one reason and no constraint: no rule kept it out. Branch on category: 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. violation_count is 0 on a solve response without pinned plan stops. 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.violation_count 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:08 local time and takes his lunch break there, from 12:08 to 12:38. The job’s window opens at 15:00, so service_start is 15:00 and wait_s is 8,518 s (the waiting that is left after the 30-minute break), and he leaves at 15:45. His route now ends at 16:22 instead of 12:30.
  • unassigned is empty: every job of problem is in the plan.
The change has a price. cost rises from 626.768 to 782.51311, which is 155.74511 more: Serving the visit this way costs 185.74511 against a fee of 30, which is why the solve left it out. If the visit must always be planned, remove drop_cost from the job, or raise the fee to what a missed visit costs you. See Evaluate for the violation codes.

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 travel_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, service_start 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 code.

Suggest

Reading and applying insertions.