How to Detect the Carrier From a Tracking Number, Canada Post and Purolator Included
Someone pastes a tracking number with no carrier attached. Which site do you send it to? The formats overlap more than the guides admit, most of the guides forget Canada exists, and the check digits that settle the hard cases are rarely written down in one place. This is the version I wish I had found while building CargoPax.
CargoPax tracks parcels from forwarded shipping emails, and it also lets you paste a bare tracking number. The first version of that field required the carrier to be picked by hand, which is the kind of question a person should not have to answer when the number already says most of it. The rules below are what replaced it. They are in production, they have tests, and they are honest about the cases a regex cannot decide.
The formats
Six carriers, thirteen formats. The regexes assume you have already stripped spaces and dashes and upper-cased the input, because people paste numbers the way the label printed them.
| Carrier | Format | Regex | Check digit |
|---|---|---|---|
| UPS | 1Z + 16 alphanumerics (18 total) | ^1Z[0-9A-Z]{16}$ | mod 10, letters mapped |
| UPS | InfoNotice: T + 10 digits | ^T\d{10}$ | mod 10 |
| UPS Mail Innovations | 18 or 22 digits (USPS-format) | ^\d{18}$ ^\d{22}$ | USPS mod 10 |
| FedEx Express | 12 digits | ^\d{12}$ | weights 1,3,7; mod 11 then 10 |
| FedEx Ground | 15 digits | ^\d{15}$ | mod 10, weights 3,1 |
| FedEx Ground 96 / SmartPost | 20 or 22 digits | ^\d{20}$ ^\d{22}$ | mod 10, weights 3,1 |
| USPS domestic | 20 to 26 digits, usually 92, 93, 94, or 95 first | ^\d{20,26}$ | mod 10, weights 3,1 |
| USPS international | S10: 2 letters + 9 digits + US | ^[A-Z]{2}\d{9}[A-Z]{2}$ | S10 mod 11 |
| DHL Express | 10 or 11 digits | ^\d{10,11}$ | not published |
| DHL eCommerce | J… or GM… prefixes | ^J[A-Z0-9]{9,19}$ ^GM\d{10,20}$ | not published |
| Canada Post domestic | 16 digits | ^\d{16}$ | not published |
| Canada Post international | S10: 2 letters + 9 digits + CA | ^[A-Z]{2}\d{9}[A-Z]{2}$ | S10 mod 11 |
| Purolator | PIN: 12 digits, or 3 to 4 letters + 8 to 9 digits | ^\d{12}$ ^[A-Z]{3,4}\d{8,9}$ | not published |
Two Canadian details the US-centric guides get wrong. Canada Post domestic parcels use a 16-digit number, which is unique among these carriers and therefore the easiest to identify. And both Canada Post and USPS use the universal S10 format for international items, where the last two letters are the origin postal operator: …CA was issued by Canada Post, …US by USPS, and …GB, …CN, or …DE by a foreign post whose parcel will be handed to the destination operator to finish the journey.
The detection code
The shape that works is a map from carrier to a list of patterns, a function that returns every carrier a number could belong to, and a caller that decides what to do when the answer is not exactly one. This is CargoPax’s, minus the type annotations:
const TRACKING_NUMBER_PATTERNS = {
ups: [/^1Z[0-9A-Z]{16}$/, /^T\d{10}$/, /^\d{18}$/, /^\d{22}$/],
fedex: [/^\d{12}$/, /^\d{15}$/, /^\d{20}$/, /^\d{22}$/],
usps: [/^\d{20,26}$/, /^[A-Z]{2}\d{9}[A-Z]{2}$/],
dhl: [/^\d{10,11}$/, /^J[A-Z0-9]{9,19}$/, /^GM\d{10,20}$/],
canada_post: [/^\d{16}$/, /^[A-Z]{2}\d{9}[A-Z]{2}$/],
purolator: [/^\d{12}$/, /^[A-Z]{3,4}\d{8,9}$/],
};
export function normalizeTrackingNumberInput(value) {
return value.replace(/[\s-]/g, "").trim().toUpperCase();
}
/* Every carrier this number could belong to. Formats overlap, so this
returns candidates and lets the caller decide. */
export function carriersForTrackingNumber(value) {
const candidate = normalizeTrackingNumberInput(value);
if (!candidate) return [];
return Object.keys(TRACKING_NUMBER_PATTERNS)
.filter((carrier) => TRACKING_NUMBER_PATTERNS[carrier].some((pattern) => pattern.test(candidate)));
}The tests say what that gives you in practice:
carriersForTrackingNumber("1Z999AA10123456784") // ["ups"]
carriersForTrackingNumber("7023 2101 2345 6789") // ["canada_post"] 16 digits
carriersForTrackingNumber("1234567890") // ["dhl"] 10 digits
carriersForTrackingNumber("123456789012") // ["fedex", "purolator"]
carriersForTrackingNumber("9400111899223397938644") // ["fedex", "ups", "usps"]
carriersForTrackingNumber("hello") // []The overlaps, and how to settle them
Three collisions account for nearly every ambiguous number you will see.
Twelve digits: FedEx Express or Purolator. FedEx Express numbers carry a check digit and Purolator PINs do not have a published one, so run the FedEx check. A number that fails it is almost certainly Purolator. A number that passes is probably FedEx, with the caveat that a random Purolator PIN passes one time in ten. Rank FedEx first and let the user correct it.
Twenty to twenty-six digits: USPS, FedEx Ground, or UPS Mail Innovations. The first two digits do most of the work. USPS domestic numbers start with 92, 93, 94, or 95; FedEx Ground’s 22-digit format starts with 96; the older 20-digit USPS confirmation numbers start with the two-digit service type. UPS Mail Innovations is the odd one: UPS carries the parcel to a USPS facility and USPS delivers it, so the number is USPS-format and either site will track it. Treat a USPS-shaped number as USPS unless the link it came from says ups.com.
Thirteen characters ending in two letters: USPS or Canada Post. Read the last two letters. That is the origin operator, and it is definitive. My first implementation returned both carriers for LZ123456789US and asked the user; the country code was sitting right there.
function s10Origin(candidate) {
const m = /^[A-Z]{2}\d{9}([A-Z]{2})$/.exec(candidate);
if (!m) return null;
return { CA: "canada_post", US: "usps" }[m[1]] ?? "foreign_post";
}Check digits: the part the guides leave out
Most carriers append one digit computed from the rest of the number. It exists to catch a mistyped label, and it is exactly what you need to catch a mistyped paste or to rank two candidate carriers. The algorithms below are verified against numbers the carriers themselves publish as valid. All of them take the normalized string and return the digit the number should end with.
UPS. Take the fifteen characters after 1Z. Letters become digits by their ASCII code minus 63, mod 10, so A is 2, B is 3, and I wraps to 0. Digits in odd positions count once, even positions twice. The check digit is whatever brings the sum to a multiple of ten.
function upsCheckDigit(n) {
const body = n.slice(2, 17);
let sum = 0;
for (let i = 0; i < body.length; i++) {
const c = body[i];
const v = /\d/.test(c) ? Number(c) : (c.charCodeAt(0) - 63) % 10;
sum += i % 2 === 0 ? v : v * 2;
}
return String((10 - (sum % 10)) % 10);
}
upsCheckDigit("1Z5R89390357567127") // "7"USPS and FedEx Ground. The same mod-10 scheme with weights three and one, counted from the right. Start at the digit just before the check digit, multiply it by three, the next by one, and alternate.
function mod10From31(n) {
const body = n.slice(0, -1);
let sum = 0;
for (let i = 0; i < body.length; i++) {
const d = Number(body[body.length - 1 - i]);
sum += i % 2 === 0 ? d * 3 : d;
}
return String((10 - (sum % 10)) % 10);
}
mod10From31("9400111206206406260787") // "7" USPS IMpb
mod10From31("041441760228964") // "4" FedEx GroundFedEx Express. Different scheme. Over the first eleven digits, counted from the right, the weights cycle 1, 3, 7. Take the sum mod 11, then mod 10.
function fedexExpressCheckDigit(n) {
const body = n.slice(0, 11);
const w = [1, 3, 7];
let sum = 0;
for (let i = 0; i < body.length; i++) sum += Number(body[body.length - 1 - i]) * w[i % 3];
return String((sum % 11) % 10);
}
fedexExpressCheckDigit("986578788855") // "5"S10 international (USPS and Canada Post). The eight serial digits are weighted 8, 6, 4, 2, 3, 5, 9, 7. Subtract the sum mod 11 from 11; a result of 10 becomes 0 and 11 becomes 5.
function s10CheckDigit(n) {
const serial = n.slice(2, 10);
const w = [8, 6, 4, 2, 3, 5, 9, 7];
let sum = 0;
for (let i = 0; i < 8; i++) sum += Number(serial[i]) * w[i];
const r = 11 - (sum % 11);
return String(r === 10 ? 0 : r === 11 ? 5 : r);
}
s10CheckDigit("RB123456785GB") // "5"A warning about rejecting on the check digit. The USPS sample number that appears in countless tutorials, 9400 1118 9922 3397 9386 44, fails its own check; the digit should be 2. DHL Express, Canada Post domestic, and Purolator publish no algorithm at all. So use check digits to rank candidates and to show a "double-check this number" hint, and never as a hard wall between a person and the parcel they are trying to find.
Numbers inside links
The harder version of this problem is the one CargoPax actually has: the number is buried in a tracking link inside a forwarded email, next to campaign ids, session tokens, and locale codes that are also long strings of letters and digits. A rule like "eight or more alphanumerics" matches all of them. The fix is to know the carrier from the hostname first, then take the longest token in the URL that fits that carrier’s format. Only when nothing fits does it fall back to a heuristic: the token that looks least like an English word.
function trackingNumberFromUrl(url, carrier = carrierForHostname(url)) {
const tokens = (url.match(/\b[a-zA-Z0-9]+\b/g) ?? []).filter((t) => t.length >= 8);
const fitting = tokens.filter((t) => isTrackingNumberForCarrier(carrier, t));
if (fitting.length) return fitting.reduce((a, b) => (b.length > a.length ? b : a));
return tokens.reduce((a, b) => (englishLikeness(a) < englishLikeness(b) ? a : b)) ?? null;
}The English-likeness score is small and slightly silly: vowel ratio, mixed case, and a penalty per digit. It is there so a pasted URL with a format nobody has documented still produces a number to show, and it is never trusted over a real format match.
When the code cannot decide, ask
The last piece is not code. When carriersForTrackingNumber returns one carrier, the dropdown fills itself in and the person never touches it. When it returns two or three, the dropdown stays open with those options and a line underneath: "That number could be FedEx or Purolator, pick the carrier." When it returns none, the field says so and offers the full list. The detection does not have to be perfect. It has to be right often enough that the question disappears most of the time, and honest enough that the person is never told something false.
Frequently asked questions
- Can you tell the carrier from a tracking number alone?
- Often, not always. A number starting with 1Z is UPS, a 16-digit number is Canada Post, a 10-digit number is DHL Express, and a 13-character number ending in two letters is international post from the country those letters name. Twelve digits could be FedEx Express or Purolator, and 20 to 26 digits could be USPS, FedEx Ground, or UPS Mail Innovations. For those, a check digit usually settles it and a dropdown settles the rest.
- What does a Canada Post tracking number look like?
- Domestic parcels use a 16-digit number, often printed in four groups of four. International items use the 13-character S10 format: two letters, nine digits, and CA at the end. The two letters at the start indicate the service, and the ninth digit is a check digit.
- What does a Purolator tracking number look like?
- Purolator calls it a PIN. It is usually 12 digits, and some services print three or four letters followed by eight or nine digits. Purolator does not publish a check-digit algorithm, which is why a 12-digit number that fails the FedEx Express check is most likely Purolator.
- How do tracking number check digits work?
- Each carrier appends one digit computed from the others. UPS and USPS use a mod-10 sum with alternating weights; FedEx Express uses weights 1, 3, and 7 with a mod 11 then mod 10; international post uses eight fixed weights and mod 11. The algorithms are in this article with working JavaScript. Use them to rank candidates and catch typos, not to reject input outright, because sample numbers in documentation often fail them.
- Why does my regex match campaign ids in tracking links?
- Because a rule like 'eight or more alphanumerics' matches the session tokens and campaign parameters that sit in the same URL. Match the carrier's real format instead, take the longest token that fits it, and only fall back to a heuristic when nothing does.
Related
- An eight-second ad, rendered from code: the product these rules live in, and how its promo was made.
- jkeen/tracking_number_data: the open dataset of carrier formats and check-digit rules this article’s algorithms were verified against.
- Event sourcing on SQLite in production: the architecture underneath CargoPax.
Comments 0
No comments yet. Start the conversation.