Skip to main content
Routal

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:

FieldWhat it isStatus-bearing?Use it for
packagesA list of individual parcels, each { identifier }, with its own lifecycleYespending → picked → delivered per parcelTracking which boxes were actually handed over (incl. partial deliveries)
tasksDriver sub-actions at the stop (signature, photo, barcode scan, checklist)Completed/not, per taskProof-of-delivery actions the driver must perform
weight / volumeScalar load the stop adds to the vehicleNo — pure capacity inputLetting 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:

StatusMeaningTimestamp
pendingCreated via the API, not yet handled in the field
pickedCollected / loaded onto the vehicle (scanned out)picked_at
deliveredHanded 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 set

The 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, and updated_at are 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 still picked (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 van
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 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 delivered through 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:

  1. Terminal reconciliation: subscribe to stop.reported, and on each event GET /v2/stop/{stop_id} to read final packages[].status. This is the authoritative close-out per stop.
  2. In-flight visibility (optional): if you need to see picked before the stop closes (e.g. confirm everything was loaded at the depot), poll GET /v2/plan/{id}/stops on an interval and diff packages[].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

SymptomCauseWhat to do
Packages set on a pickup are dropped / rejectedPickups 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 pendingStatus 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 pickedPartial 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 fieldNo 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 friendsGeneric 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/volume so 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.reported envelope you trigger reconciliation from.