Skip to main content
V3 is in beta. General availability (GA) is planned for January 2027.

Overview

A plan built in the morning is out of date by the first coffee break. The V3 Routing API keeps no state, so a repair is a new request that carries the current plan with it: you edit problem to describe what changed, send the routes you want kept as plan, and pick the endpoint that fits the size of the change. Three facts about plan shape every pattern below. The response’s plan is the same object a request takes, so it goes back in unchanged. A plan that names a task, vehicle, shift or depot that is not in problem is a 400 pointing at /plan/routes/{i} or /plan/routes/{i}/stops/{k}, so every edit to problem has a matching edit to plan. And a response’s plan never carries locked_count or pinned: you set them again on every call.

Disruption, field, call

Every row sends the whole problem, not a delta: the API has no memory of the previous request.

What is hard and what is a hint

A task in a plan can be tied to a vehicle or not, and to a position or not. The four cases use different fields, and only three are hard rules. What each means when a hard rule is broken:
  • An unlocked stop (the hint) that violates capacity, skills, eligibility, a limit or a hard time window is taken out of its position, reinserted where it fits, or returned in unassigned[] with a reason.
  • A locked or pinned stop that violates a hard rule can be neither moved nor dropped, so solve returns 400, item invalid_value, pointer /plan, naming the stop, the vehicle and the rule. On an async run the run ends failed instead. Send the same body to evaluate to see every broken rule stop by stop before you decide what to relax.
  • pinned is accepted and ignored by evaluate and suggest. locked_count is inert on evaluate; on suggest, no insertion lands before the locked stops.
  • A pinned or locked stop switches off all_or_none for its whole group, and a pinned stop that shares a same_vehicle, same_day or same_route group with another pinned or locked stop the relation forbids is a 400.
The rule of thumb: locked_count for what the driver has done or has been told, pinned for what the customer has been promised a vehicle for, nothing for everything else.

What a warm start reuses, and when a solve is cold

A plan on solve seeds the search instead of constructing from scratch, so the budget goes into improving a plan that is already good. Stops move wherever the search finds a better place, except the ones you lock or pin. The first search_progress entry is your plan’s score, so you can read how much the repair changed it. Three consequences for a repair:
  • It ends early. A warm-started solve stops once it has gone 5% of the budget, and at least 150 ms, without finding a better plan, and reports summary.search.termination: "no_improvement". The repair in the example below was sent with time_limit_s: 1 and returned after 150 ms.
  • The default budget is enough to start with. Omitted, time_limit_s is 150 ms plus 4 ms per task. Raise it for the morning solve after measuring summary.score at a few budgets on your own data, up to 60 on solve and 3600 on a run; leave the repair at the default or the same budget, and let the early stop return it sooner. A value below 0.1 is raised to the 100 ms floor, so a smaller number buys nothing.
  • Travel data is reused when it can be. Without travel.departure_time, a request on the osm or tomtom engine with at most 200 distinct locations can reuse travel data looked up within the last hour, so a repair that keeps the same locations skips most of the lookup. departure_time fetches fresh data on every call.
A solve is cold when it has no plan, when the plan is rejected with a 400 (an id that is not in problem, a stop listed twice, a locked or pinned stop that cannot be driven), and, in effect, when so many stops are unlocked and infeasible that the search rebuilds most routes. Keep the plan valid against the edited problem and the warm start holds. options.seed defaults to 42, and the same seed on the same request usually returns the same plan, though the search is bounded by time rather than by work, so it is not guaranteed. Keep one seed across a day’s repairs so a repeated call does not reshuffle routes for no reason; vary it only when you deliberately explore alternatives.

One insertion: suggest

When one order arrives and the routes are already on the road, a full solve is the wrong tool: it may move every unlocked stop to save a few minutes, and a dispatcher then has to re-brief every driver. suggest leaves the plan as it is and ranks the feasible positions for each task of problem that is not in plan, lowest delta.cost first, within options.time_limit_s (default 1) and up to options.max_insertions per task (default 5, at most 10). Use it when the dispatcher chooses, when the customer is on the phone and needs an arrival time now (placements[].service_start), or when a change must not ripple through other routes. Use solve with plan when the task gets an empty insertions list with reason.code: "infeasible": no position in the committed plan works, but the solver may make room by moving unlocked stops. To apply a suggestion, swap in its routes: for each entry, replace the plan.routes[] entry with the same vehicle and shift, or add it when the plan has none, then send the edited plan to evaluate for the full timeline or to solve as a warm start. Each task is ranked against the same committed plan, so after applying one insertion, call suggest again for the next task. See Applying a suggestion.

Call frequency

  • Batch disruptions. One repair that carries a cancellation, a late driver and two new orders is cheaper and more stable than four repairs in a row, each moving stops the previous one placed. Collect changes, then send one request per plan.
  • One repair in flight per plan. Two overlapping warm starts from the same plan return two plans that disagree. Wait for the response, apply it, then send the next batch from the plan it returned.
  • Poll a run’s status first. On an async run, read GET /v3/routing/runs/{id} until status is succeeded before fetching /result; before then /result is a 409 with run_not_finished. Cancel a run that a newer batch has made obsolete with POST /v3/routing/runs/{id}/cancel.
  • Respect the run limit. At most 10 runs per account may be queued or running; one more is a 429 with Retry-After: 30. A cancelled run keeps counting until its worker stops.
  • Do not shrink the time limit to go faster. Below 0.1 the budget is raised to 100 ms anyway, and a warm start already returns early on its own. Shorten the wall-clock instead by leaving out options.polyline and travel.departure_time on repairs.
  • Back off on 429 and 503. A 429 from the gateway is the rate limit or the ten-run cap, and its body is not the error body; a 503 with code overloaded means no solver is free and carries a Retry-After header. Retry both with backoff, and spread out bursts such as a top-of-the-hour re-solve of every route.

Worked example

One van, three jobs in Ghent, straight-line travel so the figures are reproducible. The shift runs 08:00 to 17:00; boiler-gent has a morning window and radiator-ledeberg an afternoon one. Both requests below were sent to https://api.solvice.io/v3/routing/solve and the responses are trimmed to summary, plan and unassigned.

Morning solve

The response serves all three, visiting thermostat-wondelgem at 08:02, boiler-gent at 08:26 (waiting for its 09:00 window) and radiator-ledeberg at 09:32 (waiting until 13:00), back at base at 13:48:
search_ms is 500 although the budget was 2 seconds: a one-vehicle solve with time windows and fewer than 60 visits is capped at 500 ms. See When a solve returns before the budget.

Repair at 09:40

The driver has finished the first two stops. radiator-ledeberg cancels, and a leak in Sint-Amandsberg must be fixed between 10:00 and 12:00. The repair request differs from the morning one in four places:
  1. radiator-ledeberg is gone from jobs and from plan.routes[0].stops.
  2. leak-sint-amandsberg is added to jobs, with "priority": 5, and is not in plan.
  3. The morning response’s plan is the request plan, with "locked_count": 2 so the two completed stops stay where they are.
  4. time_limit_s drops to 1; the warm start will return sooner anyway.
The response keeps the two locked stops at their morning times, appends the leak (arrival 09:32, service at 10:00 when the window opens) and brings the van home at 10:27 instead of 13:48:
Three things to read off it. termination is no_improvement after 150 ms: the warm start found nothing better than the plan it was given plus the inserted leak, and stopped. search_progress (not shown) opens at unassigned_priority: 5, the leak outside the plan, and reaches 0 in the first improvement. And the returned plan has no locked_count: the next repair sets it again, this time to 3. Sent as a suggest body instead (same problem and plan, options of { "max_insertions": 3 }), the leak gets one insertion, after boiler-gent, with delta.cost of 33.97688: the 88.1696 above minus the 54.19272 the two-stop plan costs on its own. The other two positions are inside the locked prefix and are not offered.

Next steps

Warm-starting with plan

The plan object, locked_count and pinned in full.

Suggest

Ranked insertions for a new task, and how to apply one.

Async runs

Repairs of more than 400 tasks, run limits and polling.

Performance guide

Choosing time_limit_s, and why a warm start returns early.