Last-mile e-commerce
B2C parcel delivery — orders flow from your OMS or WMS into Routal throughout the day, the optimizer batches them into dense neighborhood routes, drivers execute with barcode-scan tasks, and customers get tracking links automatically. This recipe covers the OMS→Routal→customer notification loop and the high-density stop patterns specific to parcel logistics.
Who this is for
You run a B2C parcel operation delivering physical goods to end customers who placed the order online. Common shapes:
- A direct-to-consumer brand (apparel, beauty, home goods) shipping out of one or two fulfillment centers to households across a region.
- A marketplace fulfillment operation (3PL serving multiple Shopify / WooCommerce stores) consolidating outbound flow into the same daily fleet.
- A same-day or next-day urban delivery service (food retail, pharmacy e-commerce, beverage on-demand) where the customer expects an arrival window notification before the driver shows up.
- A post-checkout fulfillment layer for retail (click-and-collect's cousin — click-and-ship-from-store) where the order originates in the store POS and needs to be sequenced into a delivery route.
What makes you different from a B2B distributor:
- Every stop has a real human at the door who wants to know "when will the
driver be here?". The integration must populate
phoneandemailon every stop and rely on Routal's customers app at c.routal.com to send tracking links automatically. - Stops per route are high. 50-150 stops per van per day is normal for parcel; a B2B route is usually 8-25 stops.
- Time windows are softer. The customer accepts "between 9am and 9pm", but the integration still has to surface a real ETA when the driver gets close (handled by Routal — see Customer notifications).
- Address quality is variable. Customers type their own delivery
addresses; you'll geocode misses constantly. The integration must handle
highway.geocoding.error.wrong_lat_lnggracefully — flag the order for customer outreach instead of dropping it on the floor.
What the rhythm looks like
E-commerce flow is continuous push during the day, optimize-and-dispatch at a cutoff, react to webhooks during execution:
Throughout the day Cutoff (e.g. 16:00) Execution day
────────────────────────────── ───────────────────── ──────────────────────────────
08:00 Orders arrive in OMS 16:00 ORDER BOOK CLOSES 05:00 Drivers open the app
(Shopify webhook, manual → routes flip in_transit
entry, store POS, etc.) 16:05 ⚡ Push job fires
1. Create tomorrow's plan 09:00 First deliveries land
2. Bulk-push stops (with → c.routal.com sends
phone + email) tracking link
3. Optimize (returns
highway.optimization. 10:00-19:00 Drivers complete
error.no_result_found stops, scan barcodes,
if overflow) attach POD
4. Dispatch routes
(driver email + SMS) 19:00 Last delivery
→ routes flip finished
→ plan flips finished
22:00 Reconciliation job
verifies all stops
closed in OMSTwo notes specific to last-mile e-commerce:
- Cutoff is your operational lever. Move it earlier and you ship the same day; move it later and you risk no-result-found because the optimizer can't fit all overflow on the available fleet.
- Customer notifications are NOT your responsibility once the stop has
phoneandemail. Routal's customers app sends the tracking link on dispatch and an ETA SMS when the driver gets close. Your integration just makes sure the contact fields are populated.
The integration shape
OMS / WMS (Shopify / Woo / Magento / custom)
│
│ (1) read pending orders, normalize address + contact
▼
[ orders: { external_id, address, phone, email, weight?, volume? } ]
│
│ (2) bulk-create as Routal stops in tomorrow's plan
▼
POST /v2/stops (idempotent on external_id)
│
│ (3) optimize once order book closes
▼
POST /v2/plan/{id}/optimize
│
│ (4) dispatch — driver gets magic link, customer gets tracking link
▼
POST /v2/route/{id}/dispatch (one call per route)
│
│ (5) webhooks during execution
▼
stop.reported → close order in OMS, attach POD
route.finished → reconcile day-of metricsThe single biggest difference vs. nightly-batch B2B: every stop carries
phone + email, and Routal's customers app handles tracking outreach
automatically. You don't build a tracking page — you populate two fields.
Production code
# (1) Ensure tomorrow's plan exists (idempotent on execution_date + label).
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": "Daily — 2026-05-22",
"execution_date": "2026-05-22"
}'
# (2) Bulk-push the day's orders. Each stop carries phone + email so the
# customers app at c.routal.com can send tracking links automatically.
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": "ORD-10042",
"label": "Alice Doe — 12 Carrer del Bruc",
"location": { "lat": 41.3955, "lng": 2.1734, "address": "Carrer del Bruc 12, 08010 Barcelona" },
"duration": 180,
"time_windows": [[32400, 75600]],
"phone": "+34600111222",
"email": "alice@example.com",
"weight": 1.4,
"tasks": [
{ "type": "scan", "label": "Scan tracking barcode" },
{ "type": "signature", "label": "Customer signature" }
]
}
]'
# (3) Optimize.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY"
# (4) Dispatch — fire one call per route. The driver gets a magic-link email;
# the customer auto-gets a tracking link from c.routal.com.
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 EcommerceOrder = {
id: string; // order number — becomes external_id
customerName: string;
addressLine: string;
lat: number;
lng: number;
phoneE164: string; // E.164, always — Routal validates
email: string;
weightKg: number;
windowFromSec: number; // e.g. 32400 = 9:00
windowToSec: number; // e.g. 75600 = 21:00
};
/** Idempotent: re-running with the same orders is a no-op. */
export async function pushDailyOrders(planId: string, orders: EcommerceOrder[]) {
if (orders.length === 0) return { created: 0 };
// 1. De-dup against what's already in the plan (cron retries, deploy windows).
const existing = await listExternalIdsInPlan(planId);
const newOnes = orders.filter((o) => !existing.has(o.id));
if (newOnes.length === 0) return { created: 0 };
// 2. Chunk by 250 — bulk endpoints accept arrays but very large arrays
// become harder to debug if one item fails validation.
for (let i = 0; i < newOnes.length; i += 250) {
const chunk = newOnes.slice(i, i + 250);
const { error } = await routal.POST('/v2/stops', {
params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } },
body: chunk.map((o) => ({
external_id: o.id,
label: `${o.customerName} — ${o.addressLine}`,
location: { lat: o.lat, lng: o.lng, address: o.addressLine },
duration: 180,
time_windows: [[o.windowFromSec, o.windowToSec]],
phone: o.phoneE164,
email: o.email,
weight: o.weightKg,
tasks: [
{ type: 'scan', label: 'Scan tracking barcode' },
{ type: 'signature', label: 'Customer signature' },
],
})) as never,
});
if (error) throw error;
}
return { created: newOnes.length };
}
async function listExternalIdsInPlan(planId: string): Promise<Set<string>> {
const { data } = await routal.GET('/v2/plan/{id}/stops', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
return new Set(((data as Array<{ external_id?: string }>) ?? []).map((s) => s.external_id ?? ''));
}
/** Optimize + dispatch every route in the plan. */
export async function dispatchPlan(planId: string) {
const { error: optErr } = await routal.POST('/v2/plan/{id}/optimize', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
if (optErr) throw optErr;
const { data: routes } = await routal.GET('/v2/plan/{id}/routes', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
for (const route of (routes as Array<{ id: string }>) ?? []) {
await routal.POST('/v2/route/{id}/dispatch', {
params: { path: { id: route.id }, query: { private_key: ROUTAL_API_KEY } },
});
}
}import os, requests
ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"]
PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"]
BASE = "https://api.routal.com"
def list_external_ids_in_plan(plan_id: str) -> set[str]:
resp = requests.get(
f"{BASE}/v2/plan/{plan_id}/stops",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
)
resp.raise_for_status()
return {s.get("external_id", "") for s in resp.json()}
def push_daily_orders(plan_id: str, orders: list[dict]) -> dict:
"""orders: [{id, customer, address, lat, lng, phone, email, weight_kg, window_from_sec, window_to_sec}, ...]"""
if not orders:
return {"created": 0}
existing = list_external_ids_in_plan(plan_id)
new_ones = [o for o in orders if o["id"] not in existing]
if not new_ones:
return {"created": 0}
for i in range(0, len(new_ones), 250):
chunk = new_ones[i:i + 250]
payload = [
{
"external_id": o["id"],
"label": f"{o['customer']} — {o['address']}",
"location": {"lat": o["lat"], "lng": o["lng"], "address": o["address"]},
"duration": 180,
"time_windows": [[o["window_from_sec"], o["window_to_sec"]]],
"phone": o["phone"],
"email": o["email"],
"weight": o["weight_kg"],
"tasks": [
{"type": "scan", "label": "Scan tracking barcode"},
{"type": "signature", "label": "Customer signature"},
],
}
for o in chunk
]
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()
return {"created": len(new_ones)}
def dispatch_plan(plan_id: str) -> None:
requests.post(
f"{BASE}/v2/plan/{plan_id}/optimize",
params={"private_key": ROUTAL_API_KEY},
timeout=120,
).raise_for_status()
resp = requests.get(
f"{BASE}/v2/plan/{plan_id}/routes",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
)
resp.raise_for_status()
for route in resp.json():
requests.post(
f"{BASE}/v2/route/{route['id']}/dispatch",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
).raise_for_status()Production hardening
Customer notifications — populate phone + email, the rest is automatic
The single biggest cost for e-commerce ops is "where's my driver?" tickets.
Routal's customers app at c.routal.com handles this for
you as long as every stop has phone and/or email:
- At dispatch the customer receives a link to a live page showing the route on a map and the estimated arrival window for their stop.
- As the driver gets close the customer is pinged again with a tighter ETA.
- On completion the customer can rate the experience and download the POD.
Your integration's job: validate phone (E.164) and email at ingestion time, and refuse to push a stop without at least one. A stop with neither is a guaranteed support ticket.
function requireContact(order: EcommerceOrder) {
if (!order.phoneE164 && !order.email) {
throw new Error(`order ${order.id} has no phone or email — refusing to push`);
}
if (order.phoneE164 && !/^\+[1-9]\d{6,14}$/.test(order.phoneE164)) {
throw new Error(`order ${order.id} phone is not E.164: ${order.phoneE164}`);
}
}Address quality — geocode early, fail the order, never the route
Customer-entered addresses are inconsistent. Routal will reject a stop with an
out-of-range lat/lng (highway.geocoding.error.wrong_lat_lng) and the optimizer
will move stops to the unassigned bucket if they geocode to a point with no
roads (e.g., the middle of a lake).
Pattern:
- Geocode before push. Either call
POST /v2/stops/geocodeupstream, or geocode in your OMS using your own provider (Google Maps, Mapbox) before the nightly push. - Cache and revalidate. Address strings rarely change between a customer's
orders; cache the geocoded
(lat, lng)against a normalized address hash. - Refuse, don't auto-fix. If geocoding returns confidence below a
threshold (e.g., Google's
GEOMETRIC_CENTERorAPPROXIMATE), flag the order for manual review in your OMS. Don't ship a probably-wrong stop and hope.
Overflow — when the day's orders don't fit
POST /v2/plan/{id}/optimize returns
highway.optimization.error.no_result_found when not all stops fit on the
available fleet within their time windows. For e-commerce this usually means
"order book grew past fleet capacity at the cutoff". The integration must
gracefully roll over.
async function dispatchOrRollover(planId: string) {
const { error } = await routal.POST('/v2/plan/{id}/optimize', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
if (!error) return { rolled_over: 0 };
// Identify unassigned stops via /v2/plan/{id}/stops (those with no route_id)
// and roll them to tomorrow's plan via POST /v2/stop/move, then mark them
// 'rolled-over' in the source OMS so customer service can notify.
const overflow = await listUnassigned(planId);
await moveStopsToTomorrow(overflow);
await notifyOmsRollover(overflow);
return { rolled_over: overflow.length };
}Reconciling stop.reported back into the OMS
The webhook fires once per terminal report. Map the three type values into
your OMS state machine:
report.type | OMS action |
|---|---|
service_report_completed | Mark order delivered, attach signature + photo URLs, close ticket. |
service_report_incomplete | Mark order delivery failed, capture cancel_reason, trigger retry rule (next-day re-attempt? customer outreach?). |
service_report_canceled | Mark order canceled, refund flow if applicable. |
Authenticate the webhook handler with a URL token, dedup on
(event_id, data.id, data.updated_at), and ACK 2xx fast — Routal auto-disables
the webhook after 50 consecutive failures.
Observability — the metrics that matter
| Metric | What it tells you |
|---|---|
lastmile.orders_pushed_total{plan_id} | Daily order volume reaching Routal. Drops to 0 = push job broken. |
lastmile.geocoding_failures_total | Bad addresses caught before push. Spike = OMS data quality regressing. |
lastmile.rollover_orders_total | Overflow from no_result_found. Trending up = need more vehicles. |
lastmile.first_attempt_success_rate | completed over (completed + incomplete + canceled). >92% = healthy. |
lastmile.driver_app_open_lag_seconds | Time from route/dispatch to route.started webhook. Long lag = driver onboarding issue. |
Common errors
message_id | Cause | What to do |
|---|---|---|
highway.geocoding.error.wrong_lat_lng | Stop location.lat/lng out of range, or address geocoded to nowhere. | Geocode upstream; flag for manual review when confidence is low. |
highway.optimization.error.no_result_found | Orders exceed fleet capacity inside the day's windows. | Roll overflow to next day's plan; notify customer service via OMS flag. |
highway.optimization.error.sync_optimization_already_progress | Another optimization is still running for the same plan. | Wait, then re-check plan status. Do not retry. |
highway.stop.error.custom_fields_invalid | Custom fields on the stop don't match project definitions. | Fix the field schema in the dashboard, or strip the offending field from the payload. |
400 Bad Request — phone validation | Phone is not E.164 (must start with + and be 7-15 digits). | Validate upstream — refuse the order before pushing. |
429 Too Many Requests | Exceeded the 2,000 req/min cap with too many parallel pushes. | Chunk by 250, sleep 200ms between chunks, or run with a single worker. |
Next steps
- Nightly batch — B2B distribution — the scaffolding pattern this recipe builds on; reuse the cron + recovery logic.
- Nightly batch + live dispatch — if your e-commerce flow accepts orders during execution (same-day cutoffs) instead of a single nightly cut.
- Webhooks — the full event catalog, envelope shape, and authentication patterns.
- Idempotency — the search-then-create pattern that keeps the daily push safe under cron retries.
