Reverse logistics — pickups & returns
Distributors and retailers that don't just deliver — they pick up. Empty kegs from bars, empty pallets from supermarkets, returned items from customers, malfunctioning equipment from clients. This recipe covers pure pickup routes, mixed delivery+pickup routes via Routal's chain mechanic, and the operational patterns that prevent drivers from showing up to "pick up" stops the customer never agreed to.
Who this is for
You operate a fleet that picks things up, not just delivers them. Common shapes:
- A beverage distributor dropping off full kegs / cases of bottled product to bars and restaurants, and picking up the empties at the same visit.
- A palletized distributor to retail (supermarkets, hardware chains) whose drivers leave loaded pallets and bring back the empty ones for re-use.
- A B2C e-commerce returns operation sending a van to pick up returned parcels from households after the customer initiated an RMA online.
- A field service shop that swaps malfunctioning equipment — the technician drops off a working unit and recovers the broken one for repair / refurb.
- A pure reverse-only specialist (e-waste collection, retail returns consolidator, recycling) where every stop is a pickup.
What makes you different from a forward-only delivery operation:
- Two different stop semantics on the same plan. A delivery stop is "give the customer this thing"; a pickup stop is "take this thing from the customer". Routal models both; your integration has to push the right shape.
- Load grows during the day. A typical delivery van leaves loaded and empties out; a typical reverse-logistics van leaves empty and fills up. A mixed van does both, and the optimizer needs accurate capacity to plan a feasible day.
- Pickup coordination is a customer-trust failure mode. A driver showing up to "pick up" a pallet the supermarket didn't agree to send back is an escalation. The integration must only push pickups your source system confirmed.
What the rhythm looks like
Day -1 (afternoon) Day 0 (execution)
───────────────────────────────── ─────────────────────────────────
14:00 Customer-service team confirms 06:00 Drivers open the app
tomorrow's pickup requests with → routes flip in_transit
customers (call, portal,
scheduled in ERP) 08:00-17:00 Mixed deliveries +
pickups by stop sequence
17:30 ERP runs the pickup-list export → driver scans on pickup
→ CSV / JSON for the integration → POD attaches photos
of received returns
17:35 ⚡ Nightly job fires
1. Create tomorrow's plan 17:00 Last stop
2. Push pickup stops → routes flip finished
(with chain_id pairing → plan flips finished
when they share a vehicle
with deliveries) 18:00 Reconciliation
3. Push delivery stops → ERP closes each RMA
(same plan) with the POD evidence
4. Optimize
5. Dispatch routesTwo notes specific to reverse logistics:
- Chain stops when pickup and delivery share a location. If the bar at
Calle Mayor 14 receives 3 kegs of full beer AND returns 5 empty kegs, model
them as TWO stops with the same
chain_id. The optimizer keeps them on the same vehicle in the right order (delivery first viachain_position: 0, pickup second viachain_position: 1). weightandvolumeon a pickup stop are POSITIVE values. They represent what the van picks up, which accumulates against the vehicle'sdefault_max_weight/default_max_volumeas the day progresses. The optimizer plans correctly when these are accurate.
The integration shape
Source systems
├─ ERP / RMA portal: confirmed pickup requests for tomorrow
└─ ERP / OMS: deliveries for tomorrow (if mixed flow)
│
│ (1) merge into a single tomorrow's plan
▼
[ stops: { external_id, type: 'pickup' | 'delivery', location, weight, volume, chain_id? } ]
│
│ (2) push all stops in one bulk call
▼
POST /v2/stops (idempotent on external_id)
│
│ (3) optimize. Capacity tracks running load including pickups.
▼
POST /v2/plan/{id}/optimize
│
│ (4) dispatch every route
▼
POST /v2/route/{id}/dispatch
│
│ (5) webhooks during execution
▼
stop.reported → close RMA ticket / mark empties received in ERP
+ attach POD (signed receipt, photos) for chain-of-custodyThe two key fields:
chain_id— a string ID shared by stops that must end up on the same vehicle. Both delivery and pickup at the same customer share one chain_id.chain_position— 0-indexed ordering within the chain. Delivery is position 0 (driver arrives, hands off the new stuff), pickup is position 1 (same vehicle, same visit, now collecting empties).
Production code
# (1) Push a mixed batch — one delivery stop and one paired pickup stop
# at the same supermarket, chained so they end up on the same vehicle.
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": "DEL-90100",
"label": "BigMart — Calle Mayor 14 — delivery",
"location": { "lat": 40.4153, "lng": -3.7079, "address": "Calle Mayor 14, 28013 Madrid" },
"duration": 900,
"time_windows": [[28800, 43200]],
"weight": 320,
"volume": 2.4,
"chain_id": "VISIT-BIGMART-MAYOR-14-2026-05-22",
"chain_position": 0
},
{
"external_id": "PICKUP-RMA-44521",
"label": "BigMart — Calle Mayor 14 — empty pallets pickup",
"location": { "lat": 40.4153, "lng": -3.7079, "address": "Calle Mayor 14, 28013 Madrid" },
"duration": 600,
"time_windows": [[28800, 43200]],
"weight": 180,
"volume": 1.8,
"chain_id": "VISIT-BIGMART-MAYOR-14-2026-05-22",
"chain_position": 1
}
]'
# (2) Optimize. The vehicle's default_max_weight covers loaded outbound goods
# AND collected inbound empties simultaneously — set it generously.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY"
# (3) 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 ReverseStop = {
id: string;
kind: 'delivery' | 'pickup';
customer: string;
lat: number;
lng: number;
address: string;
weightKg: number;
volumeM3: number;
windowFromSec: number;
windowToSec: number;
durationSec: number;
/** If pickup + delivery share a customer visit, give both the same chainId. */
chainId?: string;
};
/** Push a batch of stops with chained delivery+pickup pairs. */
export async function pushDailyMixed(planId: string, stops: ReverseStop[]) {
// Group by chain — for each chain, assign chain_position by 'delivery'-first.
const byChain = new Map<string, ReverseStop[]>();
const standalone: ReverseStop[] = [];
for (const s of stops) {
if (!s.chainId) standalone.push(s);
else byChain.set(s.chainId, [...(byChain.get(s.chainId) ?? []), s]);
}
type StopPayload = {
external_id: string;
label: string;
location: { lat: number; lng: number; address: string };
duration: number;
time_windows: [number, number][];
weight: number;
volume: number;
chain_id?: string;
chain_position?: number;
};
const payload: StopPayload[] = [];
for (const s of standalone) {
payload.push(toPayload(s));
}
for (const group of byChain.values()) {
// Deliveries before pickups on the same chain.
group.sort((a, b) => Number(a.kind === 'pickup') - Number(b.kind === 'pickup'));
group.forEach((s, idx) => {
payload.push({ ...toPayload(s), chain_id: s.chainId, chain_position: idx });
});
}
for (let i = 0; i < payload.length; i += 250) {
const chunk = payload.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 as never,
});
if (error) throw error;
}
}
function toPayload(s: ReverseStop) {
return {
external_id: s.id,
label: `${s.customer} — ${s.kind}`,
location: { lat: s.lat, lng: s.lng, address: s.address },
duration: s.durationSec,
time_windows: [[s.windowFromSec, s.windowToSec]] as [number, number][],
weight: s.weightKg,
volume: s.volumeM3,
};
}import os, requests
ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"]
PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"]
BASE = "https://api.routal.com"
def to_payload(s: dict) -> dict:
return {
"external_id": s["id"],
"label": f"{s['customer']} — {s['kind']}",
"location": {"lat": s["lat"], "lng": s["lng"], "address": s["address"]},
"duration": s["duration_sec"],
"time_windows": [[s["window_from_sec"], s["window_to_sec"]]],
"weight": s["weight_kg"],
"volume": s["volume_m3"],
}
def push_daily_mixed(plan_id: str, stops: list[dict]) -> None:
"""Push delivery + pickup stops; chain pairs that share a chain_id."""
by_chain: dict[str, list[dict]] = {}
standalone: list[dict] = []
for s in stops:
if not s.get("chain_id"):
standalone.append(s)
else:
by_chain.setdefault(s["chain_id"], []).append(s)
payload = [to_payload(s) for s in standalone]
for chain_id, group in by_chain.items():
# Deliveries before pickups within the same chain.
group.sort(key=lambda x: 1 if x["kind"] == "pickup" else 0)
for idx, s in enumerate(group):
item = to_payload(s)
item["chain_id"] = chain_id
item["chain_position"] = idx
payload.append(item)
for i in range(0, len(payload), 250):
chunk = payload[i:i + 250]
requests.post(
f"{BASE}/v2/stops",
params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID},
json=chunk,
timeout=60,
).raise_for_status()Production hardening
Never push a pickup the customer didn't confirm
The single biggest reverse-logistics escalation is a driver showing up to collect something the customer didn't agree to send back. Treat the pickup record as a two-phase commit:
- Phase 1 — Confirm in your source system (RMA portal acknowledgement,
customer service callback, scheduled in the ERP with a confirmed-by user
stamp). Set a
confirmed_attimestamp on the pickup record. - Phase 2 — Push to Routal only after
confirmed_atis set.
function pickupsReadyForDispatch(allPickups: ReverseStop[]): ReverseStop[] {
return allPickups.filter((p) => p.kind !== 'pickup' || p.confirmed_at);
}Build a daily report in your ERP for unconfirmed pickups so customer service can chase them before tomorrow's push.
Chain delivery + pickup at the same visit
Use chain_id when a single customer visit has both a delivery and a pickup.
Without chaining, the optimizer is free to assign them to different vehicles
that arrive minutes (or hours) apart — both drivers honking, customer
escalating.
The convention used in the code above:
chain_id= stable identifier for the visit (VISIT-<CUSTOMER>-<LOCATION>-<DATE>)chain_position: 0= delivery (the driver hands stuff off first)chain_position: 1= pickup (then collects the empties)
A visit with only a pickup has no chain_id; standalone pickups optimize like
any other stop.
Capacity that GROWS as the day progresses
A van leaves the depot empty (or partially loaded with delivery cargo) and
fills up with empties / returns as the day goes on. default_max_weight and
default_max_volume on the vehicle represent the maximum simultaneous load.
The optimizer tracks running load over the day:
- After a delivery, load decreases by the stop's
weight(cargo leaves the van). - After a pickup, load increases by the stop's
weight(empties enter the van). - At any moment, the running load must stay ≤
default_max_weight.
A common gotcha: the depot return trip. If your van ends the day empty (drops returns at the depot before parking), the integration doesn't have to model that — the optimizer doesn't care what happens after the last stop. If your van does another loop or transfers to a second vehicle, model the depot visit as an explicit stop.
Chain-of-custody — photos and signatures on the pickup
For returns / RMA / asset recovery, the POD on a pickup is the legal evidence that the customer handed the goods over. Use Routal's tasks:
{
external_id: 'PICKUP-RMA-44521',
// ... rest of stop
tasks: [
{ type: 'photo', label: 'Photo of returned items at pickup' },
{ type: 'signature', label: 'Customer signature on receipt' },
{ type: 'checklist', label: 'Serial numbers match RMA ticket?' },
],
}stop.reported webhook delivers the photo URLs, the signature image, and the
checklist answers. Store them against the RMA record in your ERP.
Observability — the metrics that matter
| Metric | What it tells you |
|---|---|
reverse.pickups_pushed_total{day} | Daily volume of confirmed pickups. Drift down = customer-service team falling behind on confirmations. |
reverse.unconfirmed_pickups_total | Pickups in the source system without confirmed_at. Trending up = ops backlog. |
reverse.chained_stops_total | Stops with chain_id. Should match expected delivery-pickup pairs. |
reverse.van_peak_load_pct | Max load during the day as a percentage of default_max_weight. >90% sustained = need bigger vans or smaller windows. |
reverse.rma_close_lag_seconds | Time from stop.reported to RMA closed in ERP. Spikes = webhook handler slow / broken. |
Common errors
message_id | Cause | What to do |
|---|---|---|
highway.optimization.error.no_result_found | Capacity exhausted considering running load (loaded outbound + empties picked up later). | Reduce load by splitting heavy pickups across two vans or two days; relax time windows. |
highway.stop.error.custom_fields_invalid | Pickup-type custom fields (RMA reason, serial number) don't match project schema. | Add the field definition in the dashboard, then re-push. |
highway.stop.error.move_not_pending_stops | Tried to move a pickup that was already attempted / failed. | Create a new pickup stop with a new external_id derived from the same RMA ticket. |
400 Bad Request — chain_position | Two stops on the same chain_id have the same chain_position. | Re-number positions starting from 0; each must be unique within a chain. |
429 Too Many Requests | Push velocity too high. | Chunk by 250 stops per bulk POST. |
Next steps
- Capacitated distribution — the full constraint matrix; reverse logistics shares all the weight / volume / skill mechanics, just with semantics flipped.
- Field service with appointments — if your pickups are equipment swaps (technician brings new unit + takes broken one), the field-service template is closer than this one.
- Webhooks —
stop.reportedis the canonical event for closing RMA tickets and recording chain-of-custody. - Idempotency — make the nightly push idempotent against cron retries; pickups confirmed twice in the ERP should never become two Routal stops.
