Nightly batch — B2B distribution
The default mode for B2B distributors. Orders close yesterday at 18:00, optimization runs overnight, the fleet rolls out at dawn. This recipe walks the full nightly job — when to fire it, how to make it idempotent against a daily cron, and how to recover when something misses the window.
Who this is for
You run a B2B distribution operation that serves the same network of customers — restaurants, supermarkets, bakeries, hospitals, industrial facilities, hotel chains — on a roughly fixed weekly rhythm. Your customers place orders the day before delivery, you close the order book at a fixed hour (typically 16:00–18:00), and your drivers leave the depot at dawn the next morning.
Routal sits between your order management system (an ERP, a WMS, a home-grown order portal) and your drivers. Every night the integration pushes the day's orders, runs the optimizer, and emails the resulting routes to each driver so they can open the app before they get in the van.
If that sounds like your operation, this is your recipe. If you take orders throughout the day and have to slot them into routes that have already left the depot, jump to Nightly batch + live dispatch.
What the rhythm looks like
Day -1 Day 0 (execution)
───────────────────────────────────── ─────────────────────────────────────
08:00 Sales team takes orders 05:00 Drivers open the app
throughout the day → routes flip to in_transit
→ plan flips to in_progress
17:50 Sales team finishes 08:00 First deliveries land
18:00 ORDER BOOK CLOSES 13:00 Mid-day check (any straggler?)
18:05 ⚡ Nightly batch fires 18:00 Last delivery
1. Create tomorrow's plan → routes flip to finished
2. Bulk-add the day's stops → plan flips to finished
3. Run the optimizer
4. Dispatch each route ⚡ Reconciliation job
(driver gets a magic-link email) checks no event was missed
22:00 Operations supervisor reviews
in planner.routal.com,
tweaks if neededThe integration runs once per business day at the cutoff hour. If it fires twice (deploy, manual retry, scheduler hiccup) it must be a no-op the second time. If it never fires (cron host down, network blip) the supervisor must be able to run it manually from the dashboard or by hitting the same endpoint.
What this recipe builds
A single nightly job, idempotent on (project_id, execution_date), that:
- Pulls today's order book from your source system.
- Ensures the plan exists for tomorrow's
execution_date. - Reconciles by
external_idso stops already pushed aren't duplicated. - Bulk-creates the missing stops with locations, time windows, and capacity.
- Runs the optimizer.
- Dispatches each route (sends the magic-link email).
It's the canonical daily-orders flow specialized for the cutoff-at-18:00 cadence — with the cron, the rolling window, and the recovery logic that production crews actually need.
Quickstart
The Quickstart below is a single function you run from your scheduler at the
cutoff hour. It assumes your source system gives you the list of orders for a
specific execution_date.
# This recipe orchestrates 4 calls in sequence. The cURL form is useful for
# debugging individual steps — for the full nightly job, see the TS or Python
# tabs. Run these at 18:05 against tomorrow's execution_date.
EXEC_DATE="2026-05-22" # tomorrow, in the project's local time zone
# (1) Create tomorrow's plan (or fetch it if it already exists from a retry).
curl -X POST "https://api.routal.com/v2/plan?private_key=YOUR_KEY&project_id=YOUR_PROJECT_ID" \
-H 'Content-Type: application/json' \
-d "{ \"label\": \"${EXEC_DATE} nightly\", \"execution_date\": \"${EXEC_DATE}\" }"
# → { "id": "PLAN_ID", "status": "planning", "total_stops": 0, ... }
# (2) Bulk-add the day's orders. Each one carries the source order number as
# external_id so a retry of step 2 is a no-op for stops already created.
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-9001",
"label": "Acme Restaurant — Centre",
"location": { "lat": 41.20, "lng": 2.05, "address": "123 Industrial Park, Unit B" },
"duration": 600,
"time_windows": [[28800, 43200]],
"weight": 32,
"volume": 0.4,
"phone": "+10000000001"
},
{
"external_id": "ORDER-9002",
"label": "Beta Bakery Supply",
"location": { "lat": 41.25, "lng": 2.10 },
"duration": 480,
"time_windows": [[32400, 50400]],
"weight": 12,
"volume": 0.15
}
]'
# (3) Optimize. Not safe to retry blindly — see Production hardening.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY"
# (4) For each route the optimizer produced, dispatch (sends magic-link email).
curl -G "https://api.routal.com/v2/plan/PLAN_ID/routes" \
--data-urlencode 'private_key=YOUR_KEY'
# → list of routes. For each: dispatch.
curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY"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 SourceOrder = {
id: string; // order number from your ERP/WMS
customer: string;
lat: number;
lng: number;
address?: string;
serviceMinutes: number;
windowFromSec: number;
windowToSec: number;
weightKg?: number;
volumeM3?: number;
phone?: string;
};
/**
* Run the nightly batch for a given execution date.
* Designed to be called from a scheduler at the order cutoff hour.
* Idempotent on (PROJECT_ID, executionDate).
*/
export async function runNightlyBatch(
executionDate: string, // 'YYYY-MM-DD'
orders: SourceOrder[],
log = console,
): Promise<{ planId: string; stopsCreated: number; routesDispatched: number }> {
const label = `${executionDate} nightly`;
const planId = await ensurePlan(executionDate, label);
log.info('nightly_batch.plan_ready', { planId, executionDate, orderCount: orders.length });
// Reconcile: only push stops whose external_id is not already on the plan.
const alreadyThere = await findExistingExternalIds(planId, orders.map((o) => o.id));
const newOrders = orders.filter((o) => !alreadyThere.has(o.id));
if (newOrders.length > 0) {
await bulkAddStops(planId, newOrders);
}
log.info('nightly_batch.stops_added', {
planId,
new: newOrders.length,
skipped: orders.length - newOrders.length,
});
await optimizeOnce(planId);
log.info('nightly_batch.optimized', { planId });
const dispatched = await dispatchAllRoutes(planId);
log.info('nightly_batch.dispatched', { planId, routes: dispatched });
return { planId, stopsCreated: newOrders.length, routesDispatched: dispatched };
}
async function ensurePlan(executionDate: string, label: string): Promise<string> {
// Plans are listed in reverse chrono — paginate just enough to find today's.
// If your project produces many plans/day, narrow with a search by label.
const { data, error } = await routal.GET('/v2/plans', {
params: {
query: {
private_key: ROUTAL_API_KEY,
project_id: PROJECT_ID,
limit: 50,
sort: 'created_at:desc',
},
},
});
if (error) throw error;
const existing = data!.docs?.find(
(p) => p.label === label && p.execution_date?.startsWith(executionDate),
);
if (existing) return existing.id!;
const { data: created, error: createErr } = await routal.POST('/v2/plan', {
params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } },
body: { label, execution_date: executionDate } as never,
});
if (createErr) throw createErr;
return created!.id!;
}
async function findExistingExternalIds(planId: string, orderIds: string[]): Promise<Set<string>> {
if (orderIds.length === 0) return new Set();
const { data, error } = await routal.POST('/v2/stops/search', {
params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } },
body: {
limit: 1000,
predicates: [
{ field: 'plan_id', operator: 'eq', value: planId, type: 'string' },
{ field: 'external_id', operator: 'in', value: orderIds, type: 'string' },
],
} as never,
});
if (error) throw error;
return new Set((data!.docs ?? []).map((s) => s.external_id!).filter(Boolean));
}
async function bulkAddStops(planId: string, orders: SourceOrder[]): Promise<void> {
const { error } = await routal.POST('/v2/stops', {
params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } },
body: orders.map((o) => ({
external_id: o.id,
label: o.customer,
location: { lat: o.lat, lng: o.lng, address: o.address },
duration: o.serviceMinutes * 60,
time_windows: [[o.windowFromSec, o.windowToSec]],
weight: o.weightKg,
volume: o.volumeM3,
phone: o.phone,
})) as never,
});
if (error) throw error;
}
async function optimizeOnce(planId: string): Promise<void> {
const { error } = await routal.POST('/v2/plan/{id}/optimize', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
if (error?.message_id === 'highway.optimization.error.sync_optimization_already_progress') {
throw new Error(
'optimization already running for ' + planId +
' — wait, then check plan state before re-firing',
);
}
if (error) throw error;
}
async function dispatchAllRoutes(planId: string): Promise<number> {
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;
let count = 0;
for (const route of routes ?? []) {
const { error: dispatchErr } = await routal.POST('/v2/route/{id}/dispatch', {
params: { path: { id: route.id! }, query: { private_key: ROUTAL_API_KEY } },
});
if (dispatchErr) throw dispatchErr;
count++;
}
return count;
}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 run_nightly_batch(execution_date: str, orders: list[dict]) -> dict:
"""
Run the nightly batch for a given execution date.
Designed to be called from a scheduler at the order cutoff hour.
Idempotent on (PROJECT_ID, execution_date).
"""
label = f"{execution_date} nightly"
plan_id = ensure_plan(execution_date, label)
already = find_existing_external_ids(plan_id, [o["id"] for o in orders])
new_orders = [o for o in orders if o["id"] not in already]
if new_orders:
bulk_add_stops(plan_id, new_orders)
optimize_once(plan_id)
dispatched = dispatch_all_routes(plan_id)
return {"plan_id": plan_id, "stops_created": len(new_orders),
"routes_dispatched": dispatched}
def ensure_plan(execution_date: str, label: str) -> str:
resp = requests.get(
f"{BASE}/v2/plans",
params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID,
"limit": 50, "sort": "created_at:desc"},
timeout=30,
)
resp.raise_for_status()
for p in resp.json().get("docs", []):
if p.get("label") == label and (p.get("execution_date") or "").startswith(execution_date):
return p["id"]
created = requests.post(
f"{BASE}/v2/plan",
params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID},
json={"label": label, "execution_date": execution_date},
timeout=30,
)
created.raise_for_status()
return created.json()["id"]
def find_existing_external_ids(plan_id: str, order_ids: list[str]) -> set[str]:
if not order_ids:
return set()
resp = requests.post(
f"{BASE}/v2/stops/search",
params={"private_key": ROUTAL_API_KEY, "project_id": PROJECT_ID},
json={
"limit": 1000,
"predicates": [
{"field": "plan_id", "operator": "eq", "value": plan_id, "type": "string"},
{"field": "external_id", "operator": "in", "value": order_ids, "type": "string"},
],
},
timeout=30,
)
resp.raise_for_status()
return {s["external_id"] for s in resp.json().get("docs", []) if s.get("external_id")}
def bulk_add_stops(plan_id: str, orders: list[dict]) -> None:
payload = [
{
"external_id": o["id"],
"label": o["customer"],
"location": {"lat": o["lat"], "lng": o["lng"], "address": o.get("address")},
"duration": o["service_minutes"] * 60,
"time_windows": [[o["window_from_sec"], o["window_to_sec"]]],
"weight": o.get("weight_kg"),
"volume": o.get("volume_m3"),
"phone": o.get("phone"),
}
for o in orders
]
resp = requests.post(
f"{BASE}/v2/stops",
params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID},
json=payload,
timeout=60,
)
resp.raise_for_status()
def optimize_once(plan_id: str) -> None:
resp = requests.post(
f"{BASE}/v2/plan/{plan_id}/optimize",
params={"private_key": ROUTAL_API_KEY},
timeout=180,
)
if resp.status_code == 400:
body = resp.json()
if body.get("message_id") == "highway.optimization.error.sync_optimization_already_progress":
raise RuntimeError(
f"optimization already running for {plan_id} — "
"wait, then check plan state before re-firing"
)
resp.raise_for_status()
def dispatch_all_routes(plan_id: str) -> int:
resp = requests.get(
f"{BASE}/v2/plan/{plan_id}/routes",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
)
resp.raise_for_status()
count = 0
for r in resp.json():
requests.post(
f"{BASE}/v2/route/{r['id']}/dispatch",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
).raise_for_status()
count += 1
return countProduction hardening
Scheduling — fire from a real scheduler, not from cron
Two reasons:
- Idempotency on accidental double-fires. Cloud schedulers (Cloud Scheduler, EventBridge, GitHub Actions cron, Temporal, Airflow) call your endpoint instead of running locally. The endpoint is the natural place to add the "already ran today?" check.
- Visibility. Cron in a single host fails silently if the host is down. A scheduler at least logs the missed window and lets you alert on it.
Trigger pattern:
18:05 every working day → HTTPS POST /internal/run-nightly-batch
with body { "execution_date": "<tomorrow ISO>" }Your endpoint loads the orders for that date, calls runNightlyBatch, and
returns the summary. Wrap the whole thing in a "if I already wrote to my own
DB that the batch ran for this date, return early" guard.
Time zones — the cutoff is local, not UTC
If your scheduler runs in UTC and the operation is in a different time zone, "18:00 cutoff" depends on which 18:00 you mean. Two patterns that work:
- Schedule in local time. Most cloud schedulers accept a time zone. Use the depot's time zone so 18:00 always means "18:00 at the depot".
- Schedule in UTC and compute the local date inside the job. Useful if you operate across multiple time zones from a single job runner.
// pattern 2 — compute tomorrow in the depot's TZ from UTC
const depotTz = 'America/Mexico_City';
const tomorrowLocal = new Date().toLocaleDateString('en-CA', { timeZone: depotTz });
// → '2026-05-22' regardless of what the runner host thinks the date isAvoid using Date.now() and adding 24h — daylight saving will burn you twice a
year.
Rolling window — keep 14 days of plans, archive the rest
Plans accumulate. After a year of daily plans you have ~250 plans per project
and GET /v2/plans pagination becomes a real cost when the integration
boots. Two operational patterns:
- Don't list — search by execution date.
POST /v2/stops/searchis for stops, but the equivalent for plans isGET /v2/planswith the date already encoded in the label. Cache the plan id locally after creation; only callensurePlanwhen the cache misses. - Soft-delete old plans.
DELETE /v2/plan/{id}removes plans you no longer need to query (stops and routes cascade-soft-delete). Routal is a planning tool, not a system of record — your ERP should keep the authoritative copy of every order's outcome via webhooks.
Recover from a missed window
If 18:05 came and went without the batch firing (cron host down, deploy collision, network), the supervisor on call needs to be able to run it manually. Three options, cheapest first:
- Re-trigger the same endpoint. Idempotency on
(project_id, execution_date)means a manual POST does the right thing. - Run from a CLI. Ship a small script that calls the same
runNightlyBatchfunction locally — useful when the network from the runner host to your ERP is down but the supervisor's laptop is fine. - Plan it in the dashboard. Worst case the supervisor opens planner.routal.com and creates the plan manually. Stops can be uploaded from a CSV. This is the always-available escape hatch.
Add a heartbeat: at 18:30 every working day, an alerting job checks that the day's plan exists with stops and at least one dispatched route. If it doesn't, page the on-call supervisor.
Daily quota — stay under the rate limit
The nightly batch tends to spike right after the cutoff hour. A 2,000-stop import as 4 chunks of 500 + one optimize + N dispatches stays well under the 2,000 requests/minute cap. If you're operating multiple projects from the same credential, stagger them:
18:05 Project A — run batch
18:10 Project B — run batch
18:15 Project C — run batchDon't have all projects fire at the exact same minute — that's the single fastest way to get rate-limited at midnight.
What happens when a stop has no feasible window
POST /v2/plan/{id}/optimize can return highway.optimization.error.no_result_found
when the constraints can't be satisfied — the most common cause in a B2B
nightly batch is a time window narrower than the driving time from the depot.
Recovery path:
- Log the optimization request (stops + vehicles + their constraints).
- Email the supervisor with the plan id and a list of "stops with windows shorter than 60 min" or other diagnostic flags.
- The supervisor relaxes constraints in the dashboard, manually adds a vehicle, or moves the impossible stop to the next day.
- Re-run the optimizer (this time without the offending stop or with the relaxed window).
Don't auto-relax constraints in the integration — the integrator is not the one who decides whether "deliver to Acme Restaurant before 9am" can slip.
Observability — the metrics that matter
Three counters and one gauge cover 95% of the production support load:
| Metric | What it tells you |
|---|---|
routal.nightly_batch.runs_total{outcome} | Did the nightly job actually run? Alert on outcome=missing for >1h after the cutoff. |
routal.nightly_batch.stops_created | Volume sanity check. Alert if today's count is <50% or >200% of the 7-day rolling average. |
routal.nightly_batch.routes_dispatched | Did each route get its email? Should equal route count. |
routal.nightly_batch.errors_total{message_id} | Cumulative highway.* codes hit. Investigate any new code. |
Tag every log line with plan_id and execution_date so the supervisor can
grep "what happened with tomorrow's plan" without a war room.
The reconciliation job
The nightly batch is push. The execution day is react — driver opens the app, stop reports come in, plan flips to finished. The integration listens via webhooks.
Add one more job that runs every 6 hours on the execution day: list every stop on the day's plan and reconcile its status with your source system. Catches webhook losses, deploy windows, the rare event Routal didn't deliver. Cheap to build, expensive when missing.
Common errors
message_id | Cause | What to do |
|---|---|---|
highway.optimization.error.sync_optimization_already_progress | A previous optimization for the same plan is still running. | Do not retry. Wait, then check GET /v2/plan/{id} for the latest state. The first run will complete and update the plan. |
highway.optimization.error.no_result_found | The optimizer can't fit all stops to the available vehicles under the declared constraints. | Page the supervisor with diagnostic data. Don't auto-relax. |
highway.optimization.error.too_much_requests | The optimizer backend itself was rate-limited. Separate from the public 429. | Wait 60–90 seconds, re-fire once. If persistent, check statuspage. |
highway.stop.error.custom_fields_invalid | A stop sent custom fields that don't match the project definitions. | Fetch the project's custom-field schema, fix the payload — usually a typo in a field name. |
400 Bad Request (no message_id) | Payload validation failure — most commonly a missing label or time_windows outside [0, 172800]. | Read message for the offending field, fix the source data. |
429 Too Many Requests | Too many projects firing the nightly batch at the same minute. | Stagger schedules; bulk-create in chunks of 500 instead of looping. |
Next steps
- Nightly batch + live dispatch — if some orders arrive during the day instead of all the night before.
- Capacitated distribution — when weight, volume, or cold chain are non-negotiable constraints.
- Resource lifecycle — what changes status automatically and what doesn't.
