Grocery — same-day delivery
Dark store and grocery e-commerce — the customer ordered 90 minutes ago and expects the doorbell to ring inside a two-hour window. Orders land continuously, the integration micro-batches them into short waves, the optimizer slots each wave onto active vehicles, and the customers app sends arrival ETAs as the driver gets close. This recipe covers the wave cadence, the cold-chain stop shape, and the rate-control patterns that keep dispatchers ahead of the order book.
Who this is for
You run a same-day grocery or dark-store operation. The customer placed their order 30-120 minutes ago, paid online, and expects the doorbell to ring inside a tight delivery window. Common shapes:
- A dark-store grocer (10-30 minute fulfillment SLA) operating from hyperlocal hubs in dense urban areas, dispatching on bikes / e-bikes / vans.
- A legacy supermarket chain offering same-day from their existing stores via a fulfillment partner, with a slightly longer window (90-120 min) and van-based fleet.
- A specialty grocer (organic, premium, gourmet) with a smaller daily volume but tight cold-chain requirements (frozen meat, fresh produce, refrigerated dairy on the same van).
- A convenience / on-demand vertical — pharmacy, beverage, late-night essentials — where the order shape mirrors grocery (small items, residential delivery, customer waiting) even if the product catalog differs.
What makes you different from last-mile parcel e-commerce:
- The customer is actively waiting. A 2-hour ETA window for a non-perishable parcel is fine; for an order with ice cream in it, the SLA is the product.
- Orders are not known the night before. You can't run a nightly batch — the day's plan grows continuously and the optimizer must absorb new stops into in-progress routes without disrupting drivers already on the road.
- Cold-chain compliance is binding, not nice-to-have. A van without a
freezer cannot serve a frozen-foods stop. Use
requires/providesskill matching (see Capacitated distribution for the full mechanics).
What the rhythm looks like
Continuous push + wave optimization + selective dispatch:
T-0 T+5 min T+10 min T+15 min T+20 min
───────── ────────── ─────────── ─────────── ───────────
Order A Order C Order E Order G Order I
arrives arrives arrives arrives arrives
Order B Wave 1 fires Wave 2 fires Wave 3 fires Wave 4 fires
arrives ⚡ optimize ⚡ optimize ⚡ optimize ⚡ optimize
(A + B → R1) (C onto R1 (E new R2, (G onto R2,
if feasible, D onto R1) I new R3)
else R1.b)
Dispatch R1 Dispatch R2 Dispatch R3
Driver gets Driver R1 Driver R2 Driver R3
magic link already gets magic gets magic
on the road link link
T+60 min
──────────
Order A
delivered
→ stop.reported
→ OMS shows
"Delivered"
→ customer
rating UITwo notes specific to same-day grocery:
- Wave cadence is your operational lever. Faster waves (every 3-5 min) = fresher commitments but more optimizer churn and more dispatch noise. Slower waves (every 15-20 min) = larger batches but worse customer-promise accuracy. Most ops settle at 5-10 min waves.
keep_current_assignment=trueis load-bearing. Without it, every wave re-optimization could rewrite the route of a driver already on the road, and the customers app would push a new ETA based on a new sequence. Drivers and customers both hate this. Always use it on live re-optimize.
The integration shape
OMS / dark-store backend
│
│ order-created event (webhook from OMS to your integration)
▼
[ order: { id, items[], cold_chain_skus[], delivery_address, slot_window } ]
│
│ (1) push as Routal stop on today's plan
▼
POST /v2/stops (idempotent on external_id = OMS order ID)
│
│ (2) cron fires every N minutes
▼
POST /v2/plan/{id}/optimize?keep_current_assignment=true
│
│ (3) dispatch only newly formed routes
▼
POST /v2/route/{id}/dispatch (one per route, skip those already in_transit)
│
│ (4) webhooks during execution
▼
stop.reported → OMS marks "delivered" + customer notification
route.finished → driver returns to depot or accepts next waveThe two main differences vs. nightly-batch + live-dispatch:
- Plan duration is shorter. A grocery plan often covers a single shift (e.g., 14:00-22:00) instead of a full day. Some ops create a fresh plan every 4 hours; others use one plan per day but optimize many times.
- Wave optimization runs against a fixed cron. Each fire is fast (≤ 30s)
because of
keep_current_assignment=true— most of the existing assignment is locked, only new stops + a few unassigned ones are placed.
Production code
# (1) Push a new order as a stop as soon as it lands in your OMS.
# external_id = OMS order ID. cold-chain SKUs add requires=["frozen"].
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-DKS-22301",
"label": "B. Garcia — 18 Sant Pere més Alt",
"location": { "lat": 41.3870, "lng": 2.1741, "address": "Sant Pere més Alt 18, 08003 Barcelona" },
"duration": 120,
"time_windows": [[51000, 58200]],
"phone": "+34699111222",
"email": "garcia@example.com",
"weight": 4.2,
"requires": ["refrigerated"]
}
]'
# (2) Wave optimize — fires from a cron every N minutes. Always pass
# keep_current_assignment=true to protect drivers on the road.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY&keep_current_assignment=true"
# (3) Dispatch each route that's not already in_transit.
# Use GET /v2/plan/{id}/routes to enumerate and check status.
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 GroceryOrder = {
id: string;
customer: string;
lat: number;
lng: number;
address: string;
phoneE164: string;
email: string;
weightKg: number;
windowFromSec: number; // promised slot start (seconds from midnight)
windowToSec: number; // promised slot end
coldChain: 'ambient' | 'refrigerated' | 'frozen';
};
/** Push a single order as soon as it lands in the OMS. */
export async function pushOrder(planId: string, order: GroceryOrder) {
const { 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} — ${order.address}`,
location: { lat: order.lat, lng: order.lng, address: order.address },
duration: 120,
time_windows: [[order.windowFromSec, order.windowToSec]],
phone: order.phoneE164,
email: order.email,
weight: order.weightKg,
requires: order.coldChain === 'ambient' ? [] : [order.coldChain],
},
] as never,
});
if (error) throw error;
}
/** Wave optimize. Fires from cron every N minutes. */
export async function waveOptimize(planId: string) {
const { error } = await routal.POST('/v2/plan/{id}/optimize', {
params: {
path: { id: planId },
query: { private_key: ROUTAL_API_KEY, keep_current_assignment: true },
},
});
if (error) {
// sync_optimization_already_progress is expected if a wave overlaps —
// just skip this tick; the next one will catch up.
if ((error as { message_id?: string }).message_id === 'highway.optimization.error.sync_optimization_already_progress') {
return { skipped: 'overlap' };
}
throw error;
}
return { skipped: null };
}
/** Dispatch every route that isn't already in_transit. */
export async function dispatchNewRoutes(planId: string) {
const { data } = await routal.GET('/v2/plan/{id}/routes', {
params: { path: { id: planId }, query: { private_key: ROUTAL_API_KEY } },
});
const routes = (data as Array<{ id: string; status: string }>) ?? [];
for (const route of routes) {
if (route.status === 'not_started') {
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 push_order(plan_id: str, order: dict) -> None:
"""order: {id, customer, address, lat, lng, phone, email, weight_kg, window_from_sec, window_to_sec, cold_chain}"""
requires = [] if order["cold_chain"] == "ambient" else [order["cold_chain"]]
payload = [{
"external_id": order["id"],
"label": f"{order['customer']} — {order['address']}",
"location": {"lat": order["lat"], "lng": order["lng"], "address": order["address"]},
"duration": 120,
"time_windows": [[order["window_from_sec"], order["window_to_sec"]]],
"phone": order["phone"],
"email": order["email"],
"weight": order["weight_kg"],
"requires": requires,
}]
requests.post(
f"{BASE}/v2/stops",
params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID},
json=payload,
timeout=30,
).raise_for_status()
def wave_optimize(plan_id: str) -> dict:
resp = requests.post(
f"{BASE}/v2/plan/{plan_id}/optimize",
params={"private_key": ROUTAL_API_KEY, "keep_current_assignment": "true"},
timeout=120,
)
if not resp.ok:
body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
if body.get("message_id") == "highway.optimization.error.sync_optimization_already_progress":
return {"skipped": "overlap"}
resp.raise_for_status()
return {"skipped": None}
def dispatch_new_routes(plan_id: str) -> None:
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():
if route.get("status") == "not_started":
requests.post(
f"{BASE}/v2/route/{route['id']}/dispatch",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
).raise_for_status()Production hardening
Wave cadence — pick a number and stick to it
The cron interval is the most consequential operational tunable. Three failure modes if you get it wrong:
- Too fast (1-3 min): waves overlap,
sync_optimization_already_progressfires constantly, drivers see new ETAs more often than makes sense. - Too slow (20+ min): an order placed at minute 14 of a 20-min wave waits ~6 minutes before any optimize considers it — bad for a sub-2-hour SLA.
- Variable: worse than either fixed extreme. Drivers and customers learn to expect rhythm; randomness erodes trust.
Start at 5-minute waves for a sub-2-hour SLA, 3-minute waves for sub-60. Tune only after a week of metrics.
Cold chain — explicit requires/provides per SKU class
A van without a freezer cannot serve a frozen stop. Routal enforces this as a
hard constraint when you set the matching requires on the stop and the
matching default_provides on the vehicle:
// vehicle
{ default_provides: ['ambient', 'refrigerated', 'frozen'] } // fully-equipped truck
{ default_provides: ['ambient'] } // bike or unrefrigerated van
// stop
{ requires: ['frozen'] } // ice cream, frozen meat
{ requires: ['refrigerated'] } // dairy, fresh produce
{ requires: [] } // ambient — any vehicle is fineIf a stop's requires can't be served by any active vehicle, the optimizer
returns no_result_found. Catch it, log which skill was unmet, and either
scale the fleet (call in an extra cold-chain van) or refund the order.
See Capacitated distribution for the full constraint matrix (weight, volume, skill matching).
Idempotency on the wave push
Your OMS may fire the order-created event more than once (retry on its own
network failure, replay during deploy). Make the per-order push idempotent
via external_id:
async function pushOrderIdempotent(planId: string, order: GroceryOrder) {
// Cheap pre-check: skip the POST if the order is already on the plan.
const { data } = await routal.POST('/v2/stops/search', {
params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } },
body: {
limit: 1,
predicates: [
{ field: 'external_id', operator: 'eq', value: order.id, type: 'string' },
],
} as never,
});
const existing = (data as { docs?: Array<{ id: string }> } | undefined)?.docs?.[0];
if (existing) return { skipped: 'already_exists' };
await pushOrder(planId, order);
return { skipped: null };
}Rate limit — cap concurrent pushes
Same-day grocery has spiky traffic: lunch and dinner pushes can land 100+ orders in a 90-second burst. The Routal API caps you at 2,000 req/min per credential. Single-stop pushes are cheap individually but the per-stop POST shape doesn't take an array efficiently for many small orders.
Compromise: micro-batch by 10-25 orders per push with a single-worker queue that drains at most every 200ms. This stays well under 2,000 req/min and keeps P95 push latency under 1 second.
Observability — the metrics that matter
| Metric | What it tells you |
|---|---|
grocery.orders_pushed_per_minute | Real-time order velocity. Spike = lunch/dinner rush; drop = OMS broken. |
grocery.wave_skipped_overlap_total | How often a wave fired while a previous one was still running. Sustained > 5% per hour = waves too fast or optimizer slow. |
grocery.median_optimize_seconds | Wall-clock optimize duration. Trending up = plan is too large; chunk into shifts. |
grocery.unassigned_at_dispatch_total | Stops that didn't get a route at the moment of dispatch. Spike = cold-chain or capacity overflow. |
grocery.median_promise_to_delivery_seconds | Time from customer order to stop.reported. The headline customer-experience metric. |
Common errors
message_id | Cause | What to do |
|---|---|---|
highway.optimization.error.sync_optimization_already_progress | A wave is still running when the next one fires. | Skip this tick. Don't retry — the next cron will pick up. |
highway.optimization.error.no_result_found | Cold-chain capacity exhausted, or all vans full. | Scale fleet (extra cold van), or refund unfittable orders with apology + voucher. |
highway.geocoding.error.wrong_lat_lng | Address resolves to nonsense (out of region, lake, etc.). | Reject the order in the OMS before push; customer outreach. |
highway.stop.error.report_already_exists | Trying to push a terminal report on a stop that already has one. | Webhook dedup is broken — fix the deduper, don't fight Routal. |
429 Too Many Requests | Push velocity exceeded 2,000 req/min. | Throttle the per-order pusher; switch to micro-batches of 10-25. |
Next steps
- Nightly batch + live dispatch — the architectural cousin of this recipe; the wave pattern is the same, the cadence is much faster.
- Capacitated distribution — the full constraint matrix for cold-chain operations, including pre-flight feasibility checks before optimize.
- Webhooks —
stop.reportedis the single event your customer-facing notifications hang off of. - Idempotency — pattern 1 (search-then-create) is the load-bearing guard for the order-created handler.
