Skip to main content
Routal

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 finished

The integration gets two responsibilities on the execution day:

  1. Push late orders into the active plan as soon as they arrive.
  2. 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.

StrategyWhat it doesWhen to use
Naive appendAdd 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-sequenceAdd 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-balanceAdd 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:

  1. Use driver positions. The routal.drivers.2.route.started webhook 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.
  2. 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_window and the late order's time_windows.
  3. 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.created webhook 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:

  1. Try a different route via pickBestRoute excluding the one you just tried.
  2. Fall back to POST /v2/plan/{id}/optimize?keep_current_assignment=true so Routal picks the best driver globally.
  3. Page the dispatcher — the new order may genuinely not be servable today and needs to be moved to tomorrow's plan (POST /v2/stop/move with the plan_id set 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 (default keep_current_assignment=false) is never what you want at this point. Routal will gladly reshuffle the driver's morning. Always pass keep_current_assignment=true during the day.
  • POST /v2/route/{id}/optimize is fine — vehicle assignment is fixed and only this route gets re-sequenced.
  • DELETE /v2/route/{id} is rejected with highway.route.error.locked if the route was locked ahead of dispatch (a common pattern: lock routes at 06:00 just before dispatch to prevent accidents).
  • POST /v2/stop/move to tomorrow's plan is safe for stops still in pending. Never move stops that are already completed / incomplete / canceled — those represent a delivery attempt and moving them rewrites history.

Observability — the metrics that matter

MetricWhat 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_msP50 / P99 from "order arrives in our queue" to "stop is on a route". The dispatcher cares about this.
routal.live_dispatch.optimize_route_calls_totalIf this is anywhere near 1,000/day per project, batch your late orders.
routal.live_dispatch.bounce_rateLate 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_idCauseWhat to do
highway.optimization.error.sync_optimization_already_progressAnother 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_foundThe 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_transitAn 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_stopsYou 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