Full disclosure: I work on 24hTrack, a free multi-carrier package tracker. The patterns below come from running that ingest path; discount the last section accordingly.
Most shipment pipelines validate a tracking number exactly once: when a customer complains that it does not work. By then the number has already been written to the order, emailed to the buyer, and copied into three systems.
Treat it as untrusted input at ingest, the way you already treat an email address.
A tracking number has three validation outcomes, not two
The usual model is valid | invalid. Real carrier identification is a classifier with three outcomes, and conflating two of them is where the bugs live:
- Recognised - the shape matches exactly one carrier's published format.
- Ambiguous - the shape matches more than one. Bare numeric formats do this constantly: a 12-digit string is FedEx Express or a dozen regional couriers. "2 letters + 9 digits + 2 letters" is any UPU member postal operator; only the trailing country code narrows it.
- Unrecognised - matches nothing published anywhere.
Outcome 3 is the one worth acting on at ingest, because it is almost never a carrier problem. It is a warehouse job code, a consolidator reference, or an order ID that someone pasted into the wrong column. Catching it on day zero costs one validation call. Catching it on day twelve costs a support ticket and a refund window.
Check digits are free validation and almost nobody uses them
Several formats carry a check digit you can verify offline, with no API call:
- USPS IMpb (20-22 digits): mod-10 with alternating 3/1 weights.
- FedEx Express (12 digits): mod-11 with weights 1,3,7 repeating.
- UPU S10 (
XX123456789YY): mod-11 on the 8 serial digits, with 5 and 0 as special cases.
// USPS IMpb mod-10, weights alternating 3 and 1 from the right
function uspsCheckDigitOk(tn) {
const d = tn.replace(/\D/g, '');
if (d.length < 20 || d.length > 22) return false;
const body = d.slice(0, -1), check = Number(d.slice(-1));
let sum = 0;
for (let i = body.length - 1, w = 3; i >= 0; i--, w = w === 3 ? 1 : 3) {
sum += Number(body[i]) * w;
}
return (10 - (sum % 10)) % 10 === check;
}
A failed check digit on a well-formed-looking number is nearly always a transcription error - a digit dropped, two swapped. That is a different support reply from "the carrier has not scanned it yet", and your system is the only thing that can tell them apart.
One warning from experience: calibrate a check-digit rule against your own historical data before you ship it as a hard reject. We tightened a USPS rule once that looked obviously correct and would have quietly orphaned a slice of real numbers that a partner was formatting slightly differently. Run it in log-only mode first and count what it would have rejected.
Carrier is a mutable field. Model it that way
A surprising number of schemas store carrier as if it were decided once, at creation. It is not:
- Parcels get re-labelled mid-journey. Purolator even has a public status for it - New Tracking Number Assigned - at which point the original number stops moving and a different one carries the rest of the trip.
- Consolidators issue their own job number first and a real carrier number later.
- Rebrands happen. Toll Global Express became Team Global Express in 2022 and the old portal is gone, but the numbers on the labels did not change.
Store (carrier_code, number) and resolve the tracking URL at render time. Never persist a tracking URL - it is derived data with a shorter lifetime than the row it lives on.
"Reference" is not a key, even when the carrier accepts it
Some carriers deliberately accept the shipper's own code. Purolator's tracking box asks for "a PIN or Reference", where a Reference is up to 15 characters chosen by whoever shipped it.
That is useful for humans and dangerous for code. A reference is not unique across shippers: two merchants can both use INV-1042. If you let a reference into the same column as a carrier number, you have introduced a lookup that can return somebody else's shipment. Keep a separate shipper_reference column and never make it the join key.
The same applies to naming. Several carriers do not use the phrase "tracking number" at all: Purolator says PIN, US LTL freight says PRO, air cargo says AWB (a 3-digit airline prefix plus 8 digits), Australian freight says consignment note. If you render a generic "Tracking number" label, a chunk of your users will be looking for something they cannot find on the carrier's site either.
Two different timeouts, two different alerts
Teams usually have one staleness alert: no movement for N days. You want two, because the remedies are unrelated.
| Condition | What it means | Who fixes it |
|---|---|---|
| Zero scans ever, N days after ingest | the number may not exist in any carrier system | you and the merchant - data problem |
| Had scans, none for N days | the parcel is between scan points | nobody, usually - it is in the air, in customs, or mid-handover |
The second is normal and should be scored against that lane's own distribution, not a global constant. Cross-border parcels average roughly 18-22 scans end to end against 4-5 for a domestic one, and a lane with a median of 13 days can have an 80th percentile near 29. A flat "two weeks means lost" rule will fire on more than one parcel in five that is behaving perfectly.
The first is the one that actually needs a human, and it is the one almost nobody alerts on.
The one-line version
Validate shape at ingest, verify check digits where the carrier publishes one, keep carrier mutable, keep shipper references out of your key, and alert separately on "never scanned" versus "not scanned lately".
If you want a reference implementation to compare against, 24hTrack does the detection step for 3,200+ carriers from the number alone, with a REST API and an MCP server if you want an AI assistant to do the lookup. Free to try without an account.
Top comments (1)
The three outcomes are the useful part, and I'd keep ambiguous as its own stored state instead of resolving it to the first matching carrier at ingest. Saved as "FedEx Express or a regional courier, unconfirmed", the first carrier response that actually finds the number can settle it, whereas a guessed carrier looks exactly like a recognised one in the data. The same split helps on the entry screen too: an unrecognised number can be shown back to whoever pasted it while they still have the label in front of them.