Spot a tracking number in an email

Find the parcel reference in a shipping confirmation and tell which carrier it belongs to.

No AI needed

FreeInstantYour data never leaves

The verdict

N0, because the UPU S10 standard fixes both the shape and the arithmetic: the eleventh character follows from the eight digits before it, by a sum weighted 8, 6, 4, 2, 3, 5, 9, 7 and a remainder modulo eleven. The snippet reproduces the worked example the standard itself gives, and the test measures what that check is worth: over the seventy-two single-digit substitutions and the seven adjacent transpositions of the example number, none gets through. The rest of this entry is discipline about words. A family with no key is a shape, not an identification, and the report says so on every line, with one reason per family: “ten digits” carries no key at all, where a UPS number carries one — the eighteenth position, computed here too, although UPS does not publish the algorithm; families with no key are not searched unless the caller asks for them; and the service indicators the standard reserves — JA to JZ, KA to KZ, SA to SZ, TA to TZ, WA to WZ — are not numbers, whatever they look like.

The standard’s shape, and its check digit where there is one

No AI needed

Rule and classic algorithmN0FreeInstant

Python

snippets/extract-tracking-numbers-from-an-email/n0.pyTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
"""
The parcel references an email carries, and how far each one can be trusted.

Rung N0. A tracking number is not free text: it is an identifier whose shape a
standard fixes, and the international one — UPU S10, used by every postal
operator and therefore by Colissimo and Chronopost — puts a check digit in it.
Two letters, eight digits, a check digit, two letters for the country: the
eleventh character is computed from the eight before it, and a number with one
digit wrong fails. That is the difference between finding a candidate and
identifying a reference.

Everything else is shape. « 1Z » followed by sixteen characters is a UPS
number, and the prefix is distinctive enough to be worth returning; ten bare
digits is the shape of a DHL Express air waybill, and it is also the shape of
an order number, a customer number and a phone number without its leading zero.
Those families are declared and named, but they are not searched unless the
caller asks for them, because a rung that returns every ten-digit number of an
email as a parcel reference is worse than one that returns nothing.

What comes back therefore says three things: what was found, which family it
belongs to, and whether anything was actually verified. A candidate is never
handed back as an identification.
"""

from __future__ import annotations

import re

# The service indicators the standard reserves: a valid S10 identifier never
# starts with one of them.
RESERVED = ("J", "K", "S", "T", "W")

# The weighting factors of the S10 check digit, in the order of the serial
# number's eight digits.
WEIGHTS = (8, 6, 4, 2, 3, 5, 9, 7)

# The eighteenth character of a `1Z` number is a check digit over the fifteen
# that precede it: letters become digits, the odd places count once and the
# even ones twice, and the key is what brings the total to the next ten.
#
# UPS does not publish this. It is written here because it is the algorithm the
# carrier's own canonical example satisfies — `1Z999AA10123456784`, whose
# fifteen characters add up to 96, hence a key of 4 — and because a number that
# fails it is still returned, with `checked: false` and the reason. Nothing is
# dropped on the strength of an unpublished rule; only the word « verified » is
# withheld.
UPS_BODY = 15


def ups_check_digit(body: str) -> int:
    """The check digit of the fifteen characters after « 1Z »."""
    total = 0
    for place, character in enumerate(body, start=1):
        value = int(character) if character.isdigit() else (ord(character) - 63) % 10
        total += value if place % 2 else value * 2
    return (10 - total % 10) % 10


# Each family: what it looks like, who uses it, and what the number itself
# proves when there is something in it to check.
FAMILIES = {
    "upu-s10": {
        "pattern": re.compile(r"(?<![0-9A-Za-z])([A-Z]{2})([0-9]{8})([0-9])([A-Z]{2})(?![0-9A-Za-z])"),
        "carriers": ["La Poste", "Colissimo", "Chronopost", "postal operators"],
        "verifiable": True,
    },
    "ups": {
        "pattern": re.compile(r"(?<![0-9A-Za-z])1Z[0-9A-Z]{16}(?![0-9A-Za-z])"),
        "carriers": ["UPS"],
        "verifiable": True,
    },
    "ten-digits": {
        "pattern": re.compile(r"(?<![0-9A-Za-z])[0-9]{10}(?![0-9A-Za-z])"),
        "carriers": ["DHL Express", "and anything else written on ten digits"],
        "verifiable": False,
    },
}

# The families searched when the caller does not say. The one left out is the
# one that matches an order number as readily as a parcel.
DEFAULT_FAMILIES = ("upu-s10", "ups")


def find_tracking_numbers(text, families=DEFAULT_FAMILIES) -> dict:
    """
    Every parcel reference candidate in `text`, with what could be verified.

    `families` is declared by the caller. Adding « ten-digits » finds DHL
    Express numbers, and every other ten-digit number in the same message.
    """
    if not isinstance(text, str):
        return {"found": [], "reason": f"expected text, not {type(text).__name__}"}
    unknown = [name for name in families if name not in FAMILIES]
    if unknown:
        return {"found": [], "reason": f"unknown families: {', '.join(sorted(unknown))}"}

    found = []
    for name in families:
        family = FAMILIES[name]
        for match in family["pattern"].finditer(text):
            checked, why = _verify(name, match)
            if why == "reserved":
                continue  # not an S10 identifier at all, whatever it looks like
            found.append({"text": match.group(), "family": name,
                          "carriers": list(family["carriers"]), "checked": checked,
                          "why": why, "start": match.start(), "end": match.end()})
    found.sort(key=lambda item: (item["start"], item["family"]))
    return {"found": found, "reason": None}


def _verify(name: str, match):
    """
    What the number itself proves.

    One reason per family, because they are not the same statement. « Ten
    digits » carries no key at all: nothing in the number can contradict it.
    A UPS number carries one, and this snippet computes it.
    """
    if name == "ten-digits":
        return False, "shape only: ten digits carry no key to check"
    if name == "ups":
        body, digit = match.group()[2:2 + UPS_BODY], match.group()[-1]
        if not digit.isdigit() or ups_check_digit(body) != int(digit):
            return False, "the UPS check digit does not match the rest of the number"
        return True, None
    service, serial, digit, _country = match.groups()
    if service[0] in RESERVED:
        return False, "reserved"
    if check_digit(serial) != int(digit):
        return False, "the check digit does not match the serial number"
    return True, None


def check_digit(serial: str) -> int:
    """
    The S10 check digit of an eight-digit serial number.

    Weighted modulus 11, as the standard describes it: the weighted sum is
    subtracted from eleven, 10 becomes 0 and 11 becomes 5.
    """
    total = sum(int(figure) * weight for figure, weight in zip(serial, WEIGHTS))
    remainder = 11 - total % 11
    return {10: 0, 11: 5}.get(remainder, remainder)

JavaScript

snippets/extract-tracking-numbers-from-an-email/n0.jsTested as shown — This snippet runs with its real dependencies, and its test runs on every build of the site.
/**
 * The parcel references an email carries, and how far each one can be trusted.
 *
 * Rung N0. A tracking number is not free text: it is an identifier whose shape
 * a standard fixes, and the international one — UPU S10, used by every postal
 * operator and therefore by Colissimo and Chronopost — puts a check digit in
 * it. Two letters, eight digits, a check digit, two letters for the country:
 * the eleventh character is computed from the eight before it, and a number
 * with one digit wrong fails. That is the difference between finding a
 * candidate and identifying a reference.
 *
 * Everything else is shape. « 1Z » followed by sixteen characters is a UPS
 * number, and the prefix is distinctive enough to be worth returning; ten bare
 * digits is the shape of a DHL Express air waybill, and it is also the shape
 * of an order number, a customer number and a phone number without its leading
 * zero. Those families are declared and named, but they are not searched
 * unless the caller asks for them, because a rung that returns every
 * ten-digit number of an email as a parcel reference is worse than one that
 * returns nothing.
 *
 * What comes back therefore says three things: what was found, which family it
 * belongs to, and whether anything was actually verified. A candidate is never
 * handed back as an identification.
 */

// The service indicators the standard reserves: a valid S10 identifier never
// starts with one of them.
const RESERVED = new Set(['J', 'K', 'S', 'T', 'W']);

// The weighting factors of the S10 check digit, in the order of the serial
// number's eight digits.
export const WEIGHTS = [8, 6, 4, 2, 3, 5, 9, 7];

// The eighteenth character of a `1Z` number is a check digit over the fifteen
// that precede it: letters become digits, the odd places count once and the
// even ones twice, and the key is what brings the total to the next ten.
//
// UPS does not publish this. It is written here because it is the algorithm
// the carrier's own canonical example satisfies — `1Z999AA10123456784`, whose
// fifteen characters add up to 96, hence a key of 4 — and because a number
// that fails it is still returned, with `checked: false` and the reason.
// Nothing is dropped on the strength of an unpublished rule; only the word
// « verified » is withheld.
export const UPS_BODY = 15;

/** The check digit of the fifteen characters after « 1Z ». */
export function upsCheckDigit(body) {
  let total = 0;
  [...body].forEach((character, index) => {
    const place = index + 1;
    const value = /[0-9]/.test(character)
      ? Number(character)
      : (character.charCodeAt(0) - 63) % 10;
    total += place % 2 === 1 ? value : value * 2;
  });
  return (10 - (total % 10)) % 10;
}

// Each family: what it looks like, who uses it, and what the number itself
// proves when there is something in it to check.
export const FAMILIES = {
  'upu-s10': {
    pattern: /(?<![0-9A-Za-z])([A-Z]{2})([0-9]{8})([0-9])([A-Z]{2})(?![0-9A-Za-z])/g,
    carriers: ['La Poste', 'Colissimo', 'Chronopost', 'postal operators'],
    verifiable: true,
  },
  ups: {
    pattern: /(?<![0-9A-Za-z])1Z[0-9A-Z]{16}(?![0-9A-Za-z])/g,
    carriers: ['UPS'],
    verifiable: true,
  },
  'ten-digits': {
    pattern: /(?<![0-9A-Za-z])[0-9]{10}(?![0-9A-Za-z])/g,
    carriers: ['DHL Express', 'and anything else written on ten digits'],
    verifiable: false,
  },
};

// The families searched when the caller does not say. The one left out is the
// one that matches an order number as readily as a parcel.
export const DEFAULT_FAMILIES = ['upu-s10', 'ups'];

/**
 * Every parcel reference candidate in `text`, with what could be verified.
 *
 * `families` is declared by the caller. Adding « ten-digits » finds DHL
 * Express numbers, and every other ten-digit number in the same message.
 */
export function findTrackingNumbers(text, families = DEFAULT_FAMILIES) {
  if (typeof text !== 'string') {
    return { found: [], reason: `expected text, not ${typeof text}` };
  }
  const unknown = [...families].filter((name) => !(name in FAMILIES));
  if (unknown.length) {
    return { found: [], reason: `unknown families: ${unknown.sort().join(', ')}` };
  }

  const found = [];
  for (const name of families) {
    const family = FAMILIES[name];
    for (const match of text.matchAll(family.pattern)) {
      const [checked, why] = verify(name, match);
      if (why === 'reserved') continue; // not an S10 identifier at all
      found.push({
        text: match[0],
        family: name,
        carriers: [...family.carriers],
        checked,
        why,
        start: match.index,
        end: match.index + match[0].length,
      });
    }
  }
  found.sort((a, b) => a.start - b.start
    || (a.family < b.family ? -1 : a.family > b.family ? 1 : 0));
  return { found, reason: null };
}

/**
 * What the number itself proves.
 *
 * One reason per family, because they are not the same statement. « Ten
 * digits » carries no key at all: nothing in the number can contradict it. A
 * UPS number carries one, and this snippet computes it.
 */
function verify(name, match) {
  if (name === 'ten-digits') return [false, 'shape only: ten digits carry no key to check'];
  if (name === 'ups') {
    const numero = match[0];
    const dernier = numero.at(-1);
    if (!/[0-9]/.test(dernier) || upsCheckDigit(numero.slice(2, 2 + UPS_BODY)) !== Number(dernier)) {
      return [false, 'the UPS check digit does not match the rest of the number'];
    }
    return [true, null];
  }
  const [, service, serial, digit] = match;
  if (RESERVED.has(service[0])) return [false, 'reserved'];
  if (checkDigit(serial) !== Number(digit)) {
    return [false, 'the check digit does not match the serial number'];
  }
  return [true, null];
}

/**
 * The S10 check digit of an eight-digit serial number.
 *
 * Weighted modulus 11, as the standard describes it: the weighted sum is
 * subtracted from eleven, 10 becomes 0 and 11 becomes 5.
 */
export function checkDigit(serial) {
  const total = [...serial].reduce((sum, figure, index) => sum + Number(figure) * WEIGHTS[index], 0);
  const remainder = 11 - (total % 11);
  return { 10: 0, 11: 5 }[remainder] ?? remainder;
}

Try it

interactive

The code above runs in your browser. Type in the box: the result is redone in front of you.

Nothing goes out on the network: no carrier is queried. Only the UPU S10 standard carries a check digit; the other families are recognised shapes and nothing more.

yours to edit

Recomputed on every keystroke.

Or start from an example

Free typing and the other examples need JavaScript. The example shown below was computed when the site was built.

What the code makes of it

2 matches

What the code picked up

Hello, Your order 1234567890 of 10 October 2026 has shipped. Tracking number: RB123456785GB. UPS tracking: 1Z9999W99999999999. Regards, Customer service

What it returns

2 candidate(s), 1 of them verified by their check digit

In detail

FoundFamilyCheckedWhy
RB123456785GBupu-s10yescheck digit is right
1Z9999W99999999999upsno

What this means for you

  • Your data stays put
  • Always the same result
  • Unit-testable, case by case
  • No provider in the loop
  • Negligible footprint

What this adds to your regulatory scope

  • No specific scope added: the message is read inside your own process and no carrier is queried
  • A tracking number ties a delivery address to a person: this is the level that keeps that link at home

Breaking point

A family with no key catches everything with its shape: asking for “ten digits” also returns the order number and the compact phone number of the same message. Witness in the same test: the postal number on the same line comes back verified, and the same number with one digit wrong comes back refused.

When to move up a level

There is no level above this one that means anything here. When you need to know where the parcel is, rather than which number it is, what follows is not a model: it is the carrier’s tracking interface, called with the number this level verified.

The four options

  1. RecommendedN0The standard’s shape, and its check digit where there is oneFree · Instant
  2. N1Not worth it here
  3. N2Not worth it here
  4. N3Not worth it here

The other options

N1Not worth it here

Not worth it here

Lightweight classic modelN1

There is nothing to learn: the shape is published, and the check digit is a multiplication by eight weights followed by a remainder. A classifier trained on annotated emails would recover the shapes it has seen and be wrong about the ones it has not, without ever being able to say what the check digit says: that this particular number was not damaged in the copying.

N2Not worth it here

Not worth it here

Small self-hosted specialised modelN2

The same argument, with budget on top. The work is measured in microseconds per message, over mailboxes receiving thousands a day; a resident model would cost orders of magnitude more for an answer less checkable than eight multiplications.

N3Not worth it here

Not worth it here

General-purpose LLM APIN3

One model call per message to read an identifier whose standard is public is the over-engineering this site exists to name. And the data egress here covers correspondence that ties a name, a delivery address and an order — the kind of message you do not hand to a third party to have it read thirteen characters a two-line test verifies.

Further reading

Where the figures come from

Metadata