Package tracking
Parcel-level tracking inside a delivery stop. One stop carries many package identifiers, each advancing pending → picked → delivered. Push the identifiers when you create the stop, then reconcile per-parcel status — including partial deliveries — back into your WMS or ERP. This recipe covers the data model (packages vs tasks vs capacity), the delivery-only constraint, and the reconciliation loop with no dedicated webhook.
Who this is for
You deliver more than one tracked parcel to a single address and your system of record needs to know the fate of each parcel, not just whether the stop was completed. Common shapes:
- A 3PL / parcel operation where one delivery address routinely receives several boxes, each with its own barcode, and the customer or shipper expects parcel-level scan visibility.
- A multi-box B2B distributor (pharma, industrial supply, food service) delivering palletized or multi-case orders where partial acceptance is normal — the receiver signs for 8 of 10 cases and rejects 2.
- An e-commerce consolidator shipping multiple line-items that were split across boxes but land at the same door, where the OMS tracks fulfillment at the parcel/SSCC level.
- Anyone who today encodes "3 boxes" in the stop label or a custom field and wants a first-class, status-bearing list instead.
What makes you different from the last-mile e-commerce
recipe: that flow tracks one shipment per stop and closes it with a single
stop.reported outcome. Here, the stop is a container and the unit you
reconcile is the individual package.
Packages, tasks, and capacity — three different things
Stops carry three overlapping-sounding fields. Using the right one is the whole game:
| Field | What it is | Status-bearing? | Use it for |
|---|---|---|---|
packages | A list of individual parcels, each { identifier }, with its own lifecycle | Yes — pending → picked → delivered per parcel | Tracking which boxes were actually handed over (incl. partial deliveries) |
tasks | Driver sub-actions at the stop (signature, photo, barcode scan, checklist) | Completed/not, per task | Proof-of-delivery actions the driver must perform |
weight / volume | Scalar load the stop adds to the vehicle | No — pure capacity input | Letting the optimizer respect vehicle capacity (capacitated distribution) |
A stop can use all three at once: 3 packages to track, a signature + photo
task for proof, and a weight of 14 kg for the optimizer. They are independent.
Packages do not feed the optimizer — declare weight/volume if capacity
matters.
The package lifecycle
Each package has its own status, independent of the stop's status:
| Status | Meaning | Timestamp |
|---|---|---|
pending | Created via the API, not yet handled in the field | — |
picked | Collected / loaded onto the vehicle (scanned out) | picked_at |
delivered | Handed to the recipient (scanned at the door) | delivered_at |
API create Depot / loading At the door
────────── ─────────────── ───────────
packages: [ driver scans parcel driver scans parcel
{ identifier } ──▶ onto the van ──▶ on hand-off
] status: picked status: delivered
status: pending picked_at set delivered_at setThe exact scan points are driven by your driver-app configuration — the API surfaces whatever the field operation reports. Two rules hold regardless:
- You only ever send
identifier.status,picked_at,delivered_at, andupdated_atare server-managed. They are returned on reads and ignored on writes. - A stop can finish with packages in mixed states. The stop reports
completed/incomplete, but the parcels tell you 8 delivered, 2 stillpicked(came back on the van). That nuance is the reason packages exist.
The integration shape
WMS / OMS / ERP (system of record for parcels)
│
│ (1) for each delivery: external_id + packages: [{ identifier }, ...]
▼
POST /v2/stops (deliveries only — pickups reject packages)
│
│ (2) optimize + dispatch — unchanged from any other plan
▼
POST /v2/plan/{id}/optimize/async → POST /v2/route/{id}/dispatch
│
│ (3) execution: drivers scan parcels → picked → delivered
▼
stop.reported ──▶ (4) GET /v2/stop/{stop_id} ──▶ read packages[].status
│ (the webhook does NOT carry package status)
▼
write each parcel's terminal state back to the WMS (partial-aware)The one non-obvious piece: there is no package.* webhook. stop.reported
is your trigger; the parcel detail lives on the stop, which you read back.
Production code
# (1) Create a delivery stop carrying three tracked parcels. Only `identifier`
# is accepted per package — status is server-managed.
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-7741",
"label": "Pharma Plus — Calle Mayor 3",
"location": { "lat": 40.4168, "lng": -3.7038, "address": "Calle Mayor 3, 28013 Madrid" },
"duration": 240,
"weight": 14.0,
"packages": [
{ "identifier": "PKG-7741-01" },
{ "identifier": "PKG-7741-02" },
{ "identifier": "PKG-7741-03" }
],
"tasks": [
{ "type": "signature", "label": "Receiver signature" }
]
}
]'
# (2) Optimize + dispatch — identical to any other plan.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize/async?private_key=YOUR_KEY"
curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY"
# (3) Reconcile after stop.reported — read per-parcel status off the stop.
curl -s "https://api.routal.com/v2/stop/STOP_ID?private_key=YOUR_KEY" | \
jq '.packages[] | { identifier, status, delivered_at }'
# => { "identifier": "PKG-7741-01", "status": "delivered", "delivered_at": "..." }
# { "identifier": "PKG-7741-02", "status": "delivered", "delivered_at": "..." }
# { "identifier": "PKG-7741-03", "status": "picked", "delivered_at": null } ← came back on the vanimport 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 ParcelDelivery = {
orderId: string; // becomes external_id
receiver: string;
addressLine: string;
lat: number;
lng: number;
weightKg: number;
parcelIds: string[]; // your barcodes / SSCCs — become package identifiers
};
/** Create one delivery stop per order, each carrying its tracked parcels. */
export async function pushDeliveries(planId: string, deliveries: ParcelDelivery[]) {
if (deliveries.length === 0) return { created: 0 };
const { error } = await routal.POST('/v2/stops', {
params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } },
body: deliveries.map((d) => ({
external_id: d.orderId,
label: `${d.receiver} — ${d.addressLine}`,
location: { lat: d.lat, lng: d.lng, address: d.addressLine },
duration: 240,
weight: d.weightKg,
// Only `identifier` is read on input. Never set status here.
packages: d.parcelIds.map((identifier) => ({ identifier })),
tasks: [{ type: 'signature', label: 'Receiver signature' }],
})) as never,
});
if (error) throw error;
return { created: deliveries.length };
}
type Package = {
identifier: string;
status: 'pending' | 'picked' | 'delivered';
picked_at?: string;
delivered_at?: string;
};
/**
* Reconcile parcels after `stop.reported`. The webhook payload does NOT carry
* package status — read it off the stop. Partial-aware: every parcel is written
* back individually, never collapsed into one stop outcome.
*/
export async function reconcileStopParcels(stopId: string) {
const { data, error } = await routal.GET('/v2/stop/{stop_id}', {
params: { path: { stop_id: stopId }, query: { private_key: ROUTAL_API_KEY } },
});
if (error) throw error;
const packages = ((data as { packages?: Package[] }).packages ?? []);
const delivered = packages.filter((p) => p.status === 'delivered');
const notDelivered = packages.filter((p) => p.status !== 'delivered');
for (const p of delivered) {
await markParcelInWms(p.identifier, 'delivered', p.delivered_at);
}
for (const p of notDelivered) {
// status is 'picked' (on the van) or 'pending' (never loaded). Trigger the
// right WMS rule: re-attempt tomorrow, return to depot, customer outreach.
await markParcelInWms(p.identifier, p.status, undefined);
}
return {
total: packages.length,
delivered: delivered.length,
partial: delivered.length > 0 && notDelivered.length > 0,
};
}
declare function markParcelInWms(id: string, status: string, at?: string): Promise<void>;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_deliveries(plan_id: str, deliveries: list[dict]) -> dict:
"""deliveries: [{order_id, receiver, address, lat, lng, weight_kg, parcel_ids}, ...]"""
if not deliveries:
return {"created": 0}
payload = [
{
"external_id": d["order_id"],
"label": f"{d['receiver']} — {d['address']}",
"location": {"lat": d["lat"], "lng": d["lng"], "address": d["address"]},
"duration": 240,
"weight": d["weight_kg"],
# Only `identifier` is read on input. Status is server-managed.
"packages": [{"identifier": pid} for pid in d["parcel_ids"]],
"tasks": [{"type": "signature", "label": "Receiver signature"}],
}
for d in deliveries
]
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(deliveries)}
def reconcile_stop_parcels(stop_id: str) -> dict:
"""Call on stop.reported. The webhook does NOT carry package status — read the stop."""
resp = requests.get(
f"{BASE}/v2/stop/{stop_id}",
params={"private_key": ROUTAL_API_KEY},
timeout=30,
)
resp.raise_for_status()
packages = resp.json().get("packages", [])
delivered = [p for p in packages if p.get("status") == "delivered"]
not_delivered = [p for p in packages if p.get("status") != "delivered"]
for p in delivered:
mark_parcel_in_wms(p["identifier"], "delivered", p.get("delivered_at"))
for p in not_delivered:
# 'picked' = still on the van, 'pending' = never loaded.
mark_parcel_in_wms(p["identifier"], p.get("status"), None)
return {
"total": len(packages),
"delivered": len(delivered),
"partial": len(delivered) > 0 and len(not_delivered) > 0,
}Production hardening
Packages are delivery-only — pickups reject the field
A stop's delivery leg can carry packages; the pickup leg cannot. The API
rejects packages set on a pickup (the field is reserved on pickups). If you run
mixed delivery + pickup routes via the chain mechanic (see
reverse logistics), attach the parcels to the
delivery stop only. For pure returns/recovery flows, track the recovered
items with your own custom fields, not packages.
The system owns status — you own the identifier
On input, only identifier is read. Anything else you put inside a package
object (status, picked_at, …) is ignored. This means:
- You cannot "force" a parcel to
deliveredthrough the API — status only moves via the field operation (driver scans). Treat Routal as the source of truth for parcel state and your WMS as the source of truth for which parcels exist. - Keep identifiers stable and unique per stop. Use the real barcode / SSCC / tracking number so the field scan matches what you sent, and so your reconciliation join is unambiguous.
Partial deliveries — the case a single stop status can't express
A stop reports one outcome (completed / incomplete / canceled), but a
multi-parcel delivery is frequently partial: the receiver takes most boxes and
refuses a damaged one. Always reconcile at the parcel level — iterate
packages[] and write each parcel's terminal state back individually. Collapsing
to the stop outcome silently loses the rejected box and creates an inventory
discrepancy.
Reconciliation without a package webhook
There is no package.created / package.delivered event, and the
stop.reported payload does not embed package status. The robust pattern:
- Terminal reconciliation: subscribe to
stop.reported, and on each eventGET /v2/stop/{stop_id}to read finalpackages[].status. This is the authoritative close-out per stop. - In-flight visibility (optional): if you need to see
pickedbefore the stop closes (e.g. confirm everything was loaded at the depot), pollGET /v2/plan/{id}/stopson an interval and diffpackages[].status. Don't poll tighter than you need — terminal state always arrives via the webhook.
Idempotency — treat the package set as declarative
Push stops idempotently on external_id (search-then-create — see
Idempotency). When you re-send or update a stop, treat its
packages array as the full declarative set of parcels that belong to that
stop — send every parcel you want present, not a delta. Resolve parcel adds/drops
in your WMS first, then push the complete list.
Common errors
| Symptom | Cause | What to do |
|---|---|---|
| Packages set on a pickup are dropped / rejected | Pickups cannot carry packages — the field is reserved on the pickup leg. | Attach packages to the delivery stop; track recovered items via custom fields. |
Sent status: "delivered", read back pending | Status is server-managed; only identifier is read on input. | Don't send status. Let driver scans advance it; reconcile by reading the stop. |
Stop is completed but a parcel is still picked | Partial delivery — not an error. The box came back on the van. | Reconcile per parcel; trigger your re-attempt / return-to-depot rule for the non-delivered ones. |
| Package status never updates in the field | No scan happened, or the driver app isn't configured to scan parcels. | Confirm the operation scans packages; verify identifiers match the barcodes drivers scan. |
highway.stop.error.custom_fields_invalid and friends | Generic stop validation, unrelated to packages. | See last-mile e-commerce → Common errors. |
Next steps
- Last-mile e-commerce — the one-shipment-per-stop flow with customer tracking links; reuse its push + webhook scaffolding.
- Capacitated distribution — when the
parcels also need
weight/volumeso the optimizer respects vehicle capacity. - Reverse logistics — pickups, returns, and the delivery+pickup chain mechanic (and why packages live on the delivery leg).
- Lifecycle — how stop, route, and plan statuses advance, and where package transitions sit within execution.
- Webhooks — the
stop.reportedenvelope you trigger reconciliation from.
