Nightly batch + live dispatch
Most of the work is the nightly batch — but new orders keep arriving during operations and have to be slotted into routes that already left the depot. This recipe covers the architectural decision of where to slot, the three optimizer strategies that are safe with drivers already on the road, and the operational patterns that keep dispatcher panic to a minimum.
Who this is for
You run an operation where the majority of orders close the night before (the nightly batch B2B pattern is your default), but new orders keep landing during the day and you can't tell the customer "wait until tomorrow":
- An e-commerce platform with a same-day SLA on a small fraction of orders.
- A meal-box delivery service taking late additions through the morning.
- A B2B distributor whose top customers can phone in an emergency order at 10am.
- A pharmacy distribution network with stat-of-care orders the same day.
You are not a 100% on-demand operation (that's a different recipe entirely — closer to a dispatch engine than a planner). You have a structured nightly batch and a tail of incremental orders during execution. The incremental tail has to be slotted into routes that already left the depot, without disrupting drivers who have a clipboard with their morning's sequence.
If that sounds like your operation, this recipe walks you through the three choices you have to make for every late order: which driver, when to re-optimize, and how to notify them.
What the rhythm looks like
Day -1 Day 0 (execution)
───────────────────────────────────── ─────────────────────────────────────
18:00 Order cutoff for nightly batch 06:00 Drivers dispatched
→ /v2/plan + bulk /v2/stops → routes flip to in_transit
→ /v2/plan/{id}/optimize → plan flips to in_progress
09:30 ⚡ NEW ORDER arrives
1. Pick the best driver
2. POST /v2/stops to active plan
3. Decide: re-optimize? manual?
4. Notify driver via dispatch
11:15 ⚡ Another late order
(same flow, different driver)
13:45 ⚡ Yet another
18:00 Last route finishes
→ plan flips to finishedThe integration gets two responsibilities on the execution day:
- Push late orders into the active plan as soon as they arrive.
- Tell the driver about the new stop without breaking what they're already doing.
This recipe assumes the nightly batch is already running — if it isn't yet, start with Nightly batch — B2B distribution and come back here.
The three injection strategies
Picking the right one depends on how disruptive you want a late order to be.
| Strategy | What it does | When to use |
|---|---|---|
| Naive append | Add stop to a manually-picked route. Driver sees it at the bottom of their queue on next refresh. | Cheap. Use when the new stop is "whenever you can fit it" and the driver chooses the moment. |
| Per-route re-sequence | Add stop to a manually-picked route, then POST /v2/route/{route_id}/optimize. Routal re-sequences only this driver's stops, keeping the assigned vehicle fixed. Stops that no longer fit come back as unassigned. | Most common. The driver's morning is preserved but the new stop lands in the right spot of the sequence. |
| Conservative plan re-balance | Add stop(s) to the plan, then POST /v2/plan/{id}/optimize?keep_current_assignment=true. Routal keeps every already-assigned stop where it is and only assigns the new stops to whichever driver minimizes cost. | Use when you don't want to pick the driver yourself and there's no in-flight driver context to disrupt — typically just before drivers actually arrive at their first stop. |
Never use POST /v2/plan/{id}/optimize with keep_current_assignment=false
once any route has flipped to in_transit. Routal will happily reshuffle
stops between drivers, and the driver who already started their morning will
have stops mysteriously disappear from their queue.
Quickstart
The Quickstart below implements the per-route re-sequence strategy — the
most common one. The TypeScript and Python tabs include a pickBestRoute
helper that ranks routes by remaining capacity; replace it with your own
heuristic if you have driver-position data.
# Late order arrives at 09:30. The current execution day's plan_id is in your
# cache (set by the nightly batch). Driver positions came from the
# route.started webhook earlier this morning.
PLAN_ID="..." # active plan, status=in_progress
ROUTE_ID="..." # driver you picked (see TS/Python for picking logic)
# (1) Add the new stop to the active plan. external_id is the late order's ID
# from your source system — makes the add retry-safe.
curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=${PLAN_ID}&project_id=YOUR_PROJECT_ID" \
-H 'Content-Type: application/json' \
-d '[
{
"external_id": "ORDER-9501",
"label": "Acme Late Order",
"location": { "lat": 41.22, "lng": 2.07, "address": "456 Industrial Park" },
"duration": 480,
"time_windows": [[36000, 64800]],
"weight": 8,
"volume": 0.1
}
]'
# → returns the created stop. Save the stop_id.
# (2) The stop landed on the plan but isn't yet on any route. Two options:
#
# 2a. Assign manually to the route you picked, then re-sequence that route.
curl -X PUT "https://api.routal.com/v2/route/${ROUTE_ID}/stops?private_key=YOUR_KEY" \
-H 'Content-Type: application/json' \
-d '{
"stop_ids": [
"EXISTING_STOP_1",
"EXISTING_STOP_2",
"NEW_STOP_ID",
"EXISTING_STOP_3"
]
}'
# Then ask Routal to find the best sequence for this route:
curl -X POST "https://api.routal.com/v2/route/${ROUTE_ID}/optimize?private_key=YOUR_KEY"
# 2b. Let Routal pick the best driver — conservative re-balance.
curl -X POST "https://api.routal.com/v2/plan/${PLAN_ID}/optimize?private_key=YOUR_KEY&keep_current_assignment=true"import createClient from 'openapi-fetch';
import type { paths } from './routal';
const routal = createClient<paths>({ baseUrl: 'https://api.routal.com' });
const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!;
const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!;
type LateOrder = {
id: string;
customer: string;
lat: number;
lng: number;
serviceMinutes: number;
windowFromSec: number;
windowToSec: number;
weightKg?: number;
volumeM3?: number;
requires?: string[]; // skills the order needs
};
/**
* Inject a late order into the active plan using the
* per-route re-sequence strategy.
*
* Steps:
* 1. Resolve the active plan for today.
* 2. Pick the best route (driver) for this order.
* 3. Add the stop to the plan.
* 4. Insert it into the chosen route's stop list.
* 5. Ask Routal to re-sequence that route.
* 6. Return the new stop position so dispatch can notify the driver.
*/
export async function injectLateOrder(order: LateOrder): Promise<{
planId: string;
routeId: string;
stopId: string;
sequence: number;
}> {
const planId = await resolveActivePlanForToday();
const route = await pickBestRoute(planId, order);
const stopId = await addStopToPlan(planId, order);
await appendStopToRoute(route.id, route.currentStopIds, stopId);
const reoptimized = await reSequenceRoute(route.id);
const sequence = reoptimized.stop_ids.indexOf(stopId);
return { planId, routeId: route.id, stopId, sequence };
}
async function resolveActivePlanForToday(): Promise<string> {
const today = new Date().toISOString().slice(0, 10);
const { data, error } = await routal.GET('/v2/plans', {
params: {
query: {
private_key: ROUTAL_API_KEY,
project_id: PROJECT_ID,
limit: 5,
sort: 'created_at:desc',
},
},
});
if (error) throw error;
const active = data!.docs?.find(
(p) =>
p.execution_date?.startsWith(today) &&
(p.status === 'in_progress' || p.status === 'planning'),
);
if (!active) throw new Error(`no active plan for ${today}`);
return active.id!;
}
type RouteSummary = { id: string; currentStopIds: string[]; remainingCapacity: number };
async function pickBestRoute(planId: string, order: LateOrder): Promise<RouteSummary> {
// Naive heuristic: pick the route with the most remaining max_volume that
// also covers the order's `requires` skills. Replace with your own logic
// when you have driver-position data — typically lowest detour cost wins.
const { data: routes, error } = await routal.GET('/v2/plan/{id}/routes', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
if (error) throw error;
const candidates = (routes ?? [])
.filter((r) => r.status !== 'finished')
.map((r) => ({
id: r.id!,
currentStopIds: (r.stops ?? []).map((s: { id?: string }) => s.id!).filter(Boolean),
remainingCapacity: (r.max_volume ?? 0) - (r.volume ?? 0),
provides: r.provides ?? [],
}));
const matching = candidates.filter((r) =>
(order.requires ?? []).every((skill) => r.provides.includes(skill)),
);
if (matching.length === 0) throw new Error('no route can serve this order today');
matching.sort((a, b) => b.remainingCapacity - a.remainingCapacity);
return matching[0];
}
async function addStopToPlan(planId: string, order: LateOrder): Promise<string> {
const { data, error } = await routal.POST('/v2/stops', {
params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } },
body: [
{
external_id: order.id,
label: order.customer,
location: { lat: order.lat, lng: order.lng },
duration: order.serviceMinutes * 60,
time_windows: [[order.windowFromSec, order.windowToSec]],
weight: order.weightKg,
volume: order.volumeM3,
requires: order.requires,
},
] as never,
});
if (error) throw error;
return (data as { id: string }[])[0].id;
}
async function appendStopToRoute(
routeId: string,
currentStopIds: string[],
newStopId: string,
): Promise<void> {
const { error } = await routal.PUT('/v2/route/{id}/stops', {
params: { path: { id: routeId }, query: { private_key: ROUTAL_API_KEY } },
body: { stop_ids: [...currentStopIds, newStopId] } as never,
});
if (error) throw error;
}
async function reSequenceRoute(routeId: string): Promise<{ stop_ids: string[] }> {
const { data, error } = await routal.POST('/v2/route/{id}/optimize', {
params: { path: { id: routeId }, query: { private_key: ROUTAL_API_KEY } },
});
if (error) throw error;
return {
stop_ids: ((data as { stops?: { id: string }[] }).stops ?? []).map((s) => s.id),
};
}import os
import requests
from datetime import date
ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"]
PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"]
BASE = "https://api.routal.com"
def inject_late_order(order: dict) -> dict:
"""
Inject a late order into the active plan using the per-route
re-sequence strategy.
"""
plan_id = resolve_active_plan_for_today()
route = pick_best_route(plan_id, order)
stop_id = add_stop_to_plan(plan_id, order)
append_stop_to_route(route["id"], route["currentStopIds"], stop_id)
new_sequence = re_sequence_route(route["id"])
sequence = new_sequence.index(stop_id)
return {
"plan_id": plan_id,
"route_id": route["id"],
"stop_id": stop_id,
"sequence": sequence,
}
def resolve_active_plan_for_today() -> str:
today = date.today().isoformat()
resp = requests.get(
f"{BASE}/v2/plans",
params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID,
"limit": 5, "sort": "created_at:desc"},
timeout=30,
)
resp.raise_for_status()
for plan in resp.json().get("docs", []):
if (plan.get("execution_date") or "").startswith(today) and \
plan.get("status") in ("in_progress", "planning"):
return plan["id"]
raise RuntimeError(f"no active plan for {today}")
def pick_best_route(plan_id: str, order: dict) -> dict:
resp = requests.get(
f"{BASE}/v2/plan/{plan_id}/routes",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
)
resp.raise_for_status()
candidates = []
for r in resp.json():
if r.get("status") == "finished":
continue
candidates.append({
"id": r["id"],
"currentStopIds": [s["id"] for s in (r.get("stops") or []) if s.get("id")],
"remainingCapacity": (r.get("max_volume") or 0) - (r.get("volume") or 0),
"provides": r.get("provides") or [],
})
requires = order.get("requires") or []
matching = [r for r in candidates if all(s in r["provides"] for s in requires)]
if not matching:
raise RuntimeError("no route can serve this order today")
matching.sort(key=lambda r: r["remainingCapacity"], reverse=True)
return matching[0]
def add_stop_to_plan(plan_id: str, order: dict) -> str:
payload = [{
"external_id": order["id"],
"label": order["customer"],
"location": {"lat": order["lat"], "lng": order["lng"]},
"duration": order["service_minutes"] * 60,
"time_windows": [[order["window_from_sec"], order["window_to_sec"]]],
"weight": order.get("weight_kg"),
"volume": order.get("volume_m3"),
"requires": order.get("requires"),
}]
resp = requests.post(
f"{BASE}/v2/stops",
params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID},
json=payload,
timeout=30,
)
resp.raise_for_status()
return resp.json()[0]["id"]
def append_stop_to_route(route_id: str, current_stop_ids: list[str], new_stop_id: str) -> None:
resp = requests.put(
f"{BASE}/v2/route/{route_id}/stops",
params={"private_key": ROUTAL_API_KEY},
json={"stop_ids": current_stop_ids + [new_stop_id]},
timeout=30,
)
resp.raise_for_status()
def re_sequence_route(route_id: str) -> list[str]:
resp = requests.post(
f"{BASE}/v2/route/{route_id}/optimize",
params={"private_key": ROUTAL_API_KEY},
timeout=60,
)
resp.raise_for_status()
body = resp.json()
return [s["id"] for s in body.get("stops", [])]Production hardening
Where to slot — picking the driver matters
The naive heuristic in the Quickstart (most remaining capacity, skills match) is the right starting point. Three improvements your platform team will want before scaling:
- Use driver positions. The
routal.drivers.2.route.startedwebhook tells you a route is now in transit. After that, the public REST API does not stream driver coordinates today — but you can read the route's progress (route.stops[].status) to know which stops have been visited, and infer roughly where the driver is. If your business needs live coordinates, talk to support — there are options for real-time tracking that are not part of the public REST surface. - Account for already-committed time. A driver whose remaining route ends
at 14:30 can't take a 16:00 delivery. Filter candidates by overlap between
the route's remaining
time_windowand the late order'stime_windows. - Detour cost, not Euclidean distance. If two routes have similar
capacity but route A passes 200m from the new stop while route B passes
3km away, route A wins regardless of who has more headroom. Routal's
per-route optimizer computes detour cost during
POST /v2/route/{id}/optimize— you can call it on candidate routes with the stop temporarily attached and compare the resulting durations.
When NOT to re-optimize at all
Sometimes the right move is to append and let the driver decide:
- The order is "whenever you can fit it" without a hard time window.
- The driver is using their judgment for sequencing today (e.g. construction delays, customer was just on the phone).
- The integration is offering a hint, not enforcing it.
In those cases skip the POST /v2/route/{id}/optimize call — just
PUT /v2/route/{id}/stops to append the new stop ID at the end and stop
there. The driver app refreshes within a minute and the stop appears at the
bottom of their queue. They reorder it themselves on the device if they want
to.
When to use plan-level re-balance (keep_current_assignment=true)
Reach for it when multiple late orders arrive in a short window and you'd
rather let Routal pick the driver for each than write your own
pickBestRoute:
// add all the late orders to the plan first
for (const order of recentLateOrders) {
await addStopToPlan(planId, order);
}
// then let Routal slot them into existing routes without disturbing
// already-assigned stops
await routal.POST('/v2/plan/{id}/optimize', {
params: {
path: { id: planId },
query: { private_key: ROUTAL_API_KEY, keep_current_assignment: true },
},
});This is safe even when routes are in_transit. Existing assignments are kept;
only the new stops get assigned. Cheaper than running pickBestRoute + per-
route optimize once per order — but more disruptive than appending without
optimization, because Routal re-sequences any route that receives a new stop.
Notifying the driver — and the customer
POST /v2/route/{id}/dispatch re-sends the magic-link email to the driver,
which is not what you want during the day — the driver already has the
route open on their device.
The driver app auto-refreshes the stop list periodically; new stops appear without a redispatch. If you need an instant "you have a new stop" push, the driver app does not currently expose a public push API — talk to support if this is a hard requirement for your operation.
For the customer, the new stop now has a phone and possibly an
email field. Routal sends communications via the customers app
(c.routal.com) — see Webhooks for
the shape that triggers when the driver actually completes.
Cap the daily injection rate
A live-dispatch integration that fires POST /v2/route/{id}/optimize once per
late order can add up. A worst case of 1 late order every 2 minutes from 09:00
to 17:00 = 240 optimizes/day per project, which is fine against the
2,000 req/min cap but doubles your optimization spend if Routal bills by
optimization volume.
Two patterns to soften the cost:
- Batch late orders in a 60-second window. Buffer arrivals, add them all at once, then re-balance the plan once.
- Skip re-optimization for low-priority orders. Use the
routal.planner.2.stop.createdwebhook to confirm the stop landed; only re-optimize when the new stop's priority demands it.
What happens when the new stop doesn't fit
POST /v2/route/{id}/optimize will return the new sequence — but if the
route's time window or capacity is now violated, the stop comes back as
unassigned on the route (it's not deleted; it just doesn't appear in the
re-sequenced list). Detect this by reading the response and checking whether
your new stop ID is still in route.stops[].
Recovery options:
- Try a different route via
pickBestRouteexcluding the one you just tried. - Fall back to
POST /v2/plan/{id}/optimize?keep_current_assignment=trueso Routal picks the best driver globally. - Page the dispatcher — the new order may genuinely not be servable today and
needs to be moved to tomorrow's plan (
POST /v2/stop/movewith theplan_idset to tomorrow's plan).
Idempotency on the live add
The same external_id-based reconciliation pattern that's load-bearing in
every Routal integration applies here. Before calling addStopToPlan:
const existing = await findExistingExternalIds(planId, [order.id]);
if (existing.has(order.id)) {
// already pushed — fetch it and continue with the existing stop_id
const stop = await fetchStopByExternalId(planId, order.id);
return slotIntoRoute(stop.id, ...);
}If your source system retries the webhook that delivered the late order to your integration, this guard prevents two stops for the same order.
What changes when the plan flips to in_progress
A few operations behave slightly differently once any route in the plan has
gone in_transit:
POST /v2/plan/{id}/optimize(defaultkeep_current_assignment=false) is never what you want at this point. Routal will gladly reshuffle the driver's morning. Always passkeep_current_assignment=trueduring the day.POST /v2/route/{id}/optimizeis fine — vehicle assignment is fixed and only this route gets re-sequenced.DELETE /v2/route/{id}is rejected withhighway.route.error.lockedif the route was locked ahead of dispatch (a common pattern: lock routes at 06:00 just before dispatch to prevent accidents).POST /v2/stop/moveto tomorrow's plan is safe for stops still inpending. Never move stops that are alreadycompleted/incomplete/canceled— those represent a delivery attempt and moving them rewrites history.
Observability — the metrics that matter
| Metric | What it tells you |
|---|---|
routal.live_dispatch.late_orders_total{outcome} | Volume of late orders and how they were slotted (appended / re_sequenced / re_balanced / bounced_to_tomorrow). |
routal.live_dispatch.injection_latency_ms | P50 / P99 from "order arrives in our queue" to "stop is on a route". The dispatcher cares about this. |
routal.live_dispatch.optimize_route_calls_total | If this is anywhere near 1,000/day per project, batch your late orders. |
routal.live_dispatch.bounce_rate | Late orders that couldn't fit any route today. >5% means the nightly plan is too tight; the optimizer is at its constraint limit. |
Log every injection with plan_id, route_id, stop_id, external_id,
strategy used, and the resulting sequence position. The dispatcher will ask.
Common errors
message_id | Cause | What to do |
|---|---|---|
highway.optimization.error.sync_optimization_already_progress | Another optimization (probably your previous late-order injection) is still running for the same plan. | Wait. The first injection completes within seconds for a single route. If you're calling plan-level optimize back-to-back, batch instead. |
highway.optimization.error.no_result_found | The new stop cannot be fitted on any active route. | Bounce to tomorrow's plan with POST /v2/stop/move, or page the dispatcher. |
highway.route.error.not_in_transit | An action required the route to be in transit but it isn't yet. | Rare in live dispatch — typically means the driver hasn't opened the app yet. Wait for routal.drivers.2.route.started. |
highway.stop.error.move_not_pending_stops | You tried POST /v2/stop/move on a stop that's already terminal. | Never move stops past pending. Create a new stop for any retry. |
Next steps
- Nightly batch — B2B distribution — the prerequisite for this recipe. If your nightly batch isn't running yet, start there.
- Idempotency — Pattern 1 (search-then-create) is the load-bearing guard for the live add.
- API Reference →
POST /v2/route/{id}/optimize— the single-route re-sequence endpoint, exact response shape.
