If you consume a parcel-tracking API, the field you are most tempted to branch on is status. It is also the field most likely to be wrong about the thing your user actually cares about: whether the parcel is at their door.
This is not a data-quality problem. It is a modelling problem, and once you see it the fix is small.
One parcel, several authors
A cross-border parcel is usually carried by three different companies: the line the seller booked, the operator that moves it across the border and clears customs, and the local carrier that makes the doorstep delivery. Each one writes scans into its own system, in its own words.
When company B hands the parcel to company C, company B records an event. From B's point of view, it delivered the parcel. To its partner.
So the scan list contains a line that reads, quite literally:
Delivered to final mile carrier
...and a naive status == "Delivered" check marks the order complete while the box is sitting in a depot forty miles away.
The status field is a derived label, the scan list is the record
Most tracking APIs (ours included) expose two things:
- a normalised
status- a small enum likein_transit | out_for_delivery | delivered | exception - the raw
eventsarray - what each carrier actually published, with a timestamp and a location
The enum has to be produced by mapping carrier text onto buckets, and carrier text is written by whoever wrote it. "Delivered to final mile carrier", "Delivered to agent", "Delivered to service point" and "Delivered, front door" all contain the same verb and mean three different things.
The events array does not have this problem, because it carries the one field that disambiguates: location.
The disambiguation rule
For the latest event, look at the location, not the status word:
| Location looks like | Meaning | UI you should render |
|---|---|---|
| a facility, depot, hub, airport, or another company's name | handover between carriers | still in transit |
| a shop, locker, access point, parcel shop | awaiting collection | action required by the customer |
| the recipient's own town, often with a place note (front door, porch, mailbox, neighbour) | real delivery | delivered |
The middle row is the one worth special-casing. It is not in transit and it is not finished - the parcel is physically done travelling but a clock is running, because pickup points typically return a parcel to the sender after about a week. Collapsing it into delivered is how a support queue fills up with "it says delivered and my porch is empty", and collapsing it into in_transit is how a customer finds out too late.
A pragmatic implementation
You do not need NLP for this. A short rule over the latest event gets most of the way:
const HANDOVER = /(final[- ]mile|local post|delivery partner|partner carrier|tendered|transferr?ed to)/i;
const PICKUP = /(service ?point|access ?point|parcel ?shop|pickup point|locker|post office)/i;
function refine(status, latest) {
if (status !== 'delivered') return status;
const text = `${latest.description} ${latest.location ?? ''}`;
if (PICKUP.test(text)) return 'awaiting_collection';
if (HANDOVER.test(text)) return 'in_transit';
return 'delivered';
}
Two things that matter more than the regex itself:
- Keep the raw text. If you only persist your own enum, you have thrown away the evidence and you cannot re-derive anything later when a carrier changes its wording. Store the carrier's string verbatim and normalise on read.
- Do not match on "locker" or "mailbox" alone. A parcel locker owned by the recipient is a real delivery - a rule that treats every locker as a pickup point will mislabel a very large number of genuinely delivered domestic parcels. Anchor on the shop or network words instead.
That second point is not hypothetical: in a sample we checked, the single largest group of "delivered + locker" scans were ordinary residential parcel lockers, not pickup points.
The second tracking number
One more consequence of the handover. After it, the destination carrier may issue its own tracking number for the same parcel. Both are valid, both describe the same box, and neither one contains the whole journey. If your UI assumes one order has one number, that is where the trail appears to die.
If you are building this: 24hTrack (https://www.24htrack.com) is a free package tracker for 3,200+ carriers - paste any tracking number and the carrier is detected automatically, no sign-up. There is a REST API and an MCP server (npm i 24htrack-mcp) if you want an AI agent to do the lookup, and the open guides are at https://github.com/doanhnd9989/package-tracking-guide.
TL;DR
status tells you what the last company to touch the parcel thought it had finished. The events array, read with its location field, tells you where the parcel is. Branch on the second one.
Top comments (0)