Skip to main content
Routal

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 / provides skill 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 UI

Two 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=true is 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 wave

The 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_progress fires 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 fine

If 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

MetricWhat it tells you
grocery.orders_pushed_per_minuteReal-time order velocity. Spike = lunch/dinner rush; drop = OMS broken.
grocery.wave_skipped_overlap_totalHow often a wave fired while a previous one was still running. Sustained > 5% per hour = waves too fast or optimizer slow.
grocery.median_optimize_secondsWall-clock optimize duration. Trending up = plan is too large; chunk into shifts.
grocery.unassigned_at_dispatch_totalStops that didn't get a route at the moment of dispatch. Spike = cold-chain or capacity overflow.
grocery.median_promise_to_delivery_secondsTime from customer order to stop.reported. The headline customer-experience metric.

Common errors

message_idCauseWhat to do
highway.optimization.error.sync_optimization_already_progressA 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_foundCold-chain capacity exhausted, or all vans full.Scale fleet (extra cold van), or refund unfittable orders with apology + voucher.
highway.geocoding.error.wrong_lat_lngAddress resolves to nonsense (out of region, lake, etc.).Reject the order in the OMS before push; customer outreach.
highway.stop.error.report_already_existsTrying 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 RequestsPush 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.
  • Webhooksstop.reported is 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.