WritingCommerce
One tracking status model for DHL, FedEx, UPS and USPS shipments
How to normalise four carriers' tracking events into one shipment lifecycle, when to poll and when to use webhooks, and how to keep statuses from going backwards.
A cross-border order spends most of its life in transit. The customer paid in a few seconds, the marketplace shipped within a day or two, and then there are one to three weeks in which the only thing the business can show the customer is a tracking status. If that status is wrong, stale or contradictory, the support inbox fills up with the same question in slightly different words.
At Woow Global I worked on procurement from Amazon, Alibaba, AliExpress and 1688, with parcels moving through DHL, FedEx, UPS and USPS. Each carrier has its own tracking API, its own event codes and its own idea of what a status means. The approach below is how I would build the tracking layer today: a single shipment lifecycle that every carrier is mapped into, so that the rest of the system (notifications, the order page, the support tooling) only ever has to understand one vocabulary.
Why raw carrier statuses do not belong in your database
The tempting shortcut is to store whatever the carrier returns and render it. It works for one carrier and breaks with the second. A USPS "Arrived at USPS Regional Facility" and a FedEx "At local FedEx facility" describe the same thing but share no text, and neither is machine-readable without a mapping. Carriers also add and retire event codes without telling you, which means an unmapped code will appear in production sooner or later.
There are three practical problems with storing raw statuses. First, downstream features have to know about every carrier. A notification rule like "email the customer when the parcel is out for delivery" becomes four rules that drift apart. Second, reporting is impossible: you cannot compute how long parcels spend in customs if "customs" is spelled ten different ways. Third, when a carrier changes its API you touch every consumer rather than one adapter.
The alternative is to treat carrier events as input, normalise them at the boundary and store both: the raw event for audit and debugging, and the normalised status for everything else.
A lifecycle small enough to reason about
The normalised model should be a short list of states with clear transitions. Too many states and the mapping becomes guesswork; too few and the customer loses useful information. Eight or nine covers cross-border parcels well.
stateDiagram-v2 [*] --> LabelCreated LabelCreated --> InTransit InTransit --> CustomsHold CustomsHold --> InTransit InTransit --> OutForDelivery OutForDelivery --> Delivered OutForDelivery --> DeliveryAttempted DeliveryAttempted --> OutForDelivery InTransit --> Exception CustomsHold --> Exception Exception --> InTransit Exception --> Returned Delivered --> [*] Returned --> [*]
The normalised shipment lifecycle every carrier is mapped into.
A few of these deserve comment. CustomsHold is separate from Exception because customers react to them differently: a customs hold is expected on a cross-border order and usually needs nothing from the buyer, while an exception (address problem, damaged parcel, refused delivery) often does. DeliveryAttempted is separate from Exception for the same reason; the customer can act on it. Returned is terminal for the shipment but not for the order, which will need a refund or a re-ship handled elsewhere.
Every state also carries a small set of attributes: the carrier's own code and description, a location string, a timestamp in UTC with the original offset preserved, and an optional estimated delivery date. Keep the estimate as its own field rather than folding it into a status; carriers revise it constantly and it should never trigger a state transition on its own.
Mapping tables, not if-else chains
Each carrier adapter owns a mapping from its event codes to the normalised state. I would keep this as data (a table in code or in configuration), not as branching logic, because the table is easy to review, easy to test exhaustively and easy to hand to someone who knows the carrier better than the code.
flowchart TD A[DHL events] --> N[Normaliser]:::accent B[FedEx events] --> N C[UPS events] --> N D[USPS events] --> N N --> S[Shipment state store] S --> O[Order page] S --> M[Customer notifications] S --> R[Reporting]
Where carrier events are normalised before anything else sees them.
The mapping should be in two steps: carrier code to normalised state, then the carrier's free-text description used only for display. Never map on description text alone. Descriptions change wording between API versions and even between regions, while codes are more stable.
// Simplified: one carrier adapter's mapping and the shared normaliser.
type ShipmentState =
| 'LABEL_CREATED' | 'IN_TRANSIT' | 'CUSTOMS_HOLD' | 'OUT_FOR_DELIVERY'
| 'DELIVERY_ATTEMPTED' | 'DELIVERED' | 'EXCEPTION' | 'RETURNED' | 'UNKNOWN';
interface CarrierEvent {
carrier: 'dhl' | 'fedex' | 'ups' | 'usps';
code: string;
description: string;
occurredAt: string; // ISO 8601 with offset
location?: string;
}
const fedexMap: Record<string, ShipmentState> = {
OC: 'LABEL_CREATED',
IT: 'IN_TRANSIT',
CD: 'CUSTOMS_HOLD',
OD: 'OUT_FOR_DELIVERY',
DE: 'DELIVERY_ATTEMPTED',
DL: 'DELIVERED',
RS: 'RETURNED',
};
function normalise(ev: CarrierEvent, map: Record<string, ShipmentState>) {
const state = map[ev.code] ?? 'UNKNOWN';
if (state === 'UNKNOWN') {
metrics.increment('tracking.unmapped_code', { carrier: ev.carrier });
}
return { state, raw: ev };
}The codes above are illustrative rather than a complete FedEx table. The important part is UNKNOWN: an unmapped code must not crash the pipeline, must not silently become IN_TRANSIT, and must be counted so that someone adds it to the table. In practice I would alert when the unmapped rate for a carrier rises above a small baseline, because that almost always means the carrier changed something.
Polling versus webhooks
Some carriers push tracking updates to a URL you register; others only offer a query endpoint; a few offer both with different coverage. A robust system supports both and treats them as two sources feeding the same normaliser.
| Concern | Polling | Webhooks |
|---|---|---|
| Latency | Minutes to hours, set by your schedule | Seconds after the carrier records the scan |
| Cost | API quota grows with active shipments | Near zero per shipment |
| Reliability | You control retries | Missed or duplicated deliveries are the carrier's problem, then yours |
| Missing scans | You see gaps on the next poll | A dropped webhook leaves a gap until you reconcile |
| Setup | Credentials only | Public endpoint, signature verification, registration per account |
My default is webhooks where available with polling as a safety net: poll every shipment that has had no event for longer than a threshold that depends on its state (a parcel in IN_TRANSIT across an ocean can go quiet for days; one in OUT_FOR_DELIVERY should not go quiet for more than a few hours). This catches dropped webhooks without polling everything on a fixed schedule.
sequenceDiagram participant Carrier participant Webhook as Webhook endpoint participant Poller participant Norm as Normaliser participant Store as Shipment store Carrier->>Webhook: Tracking event Webhook->>Norm: Raw event Poller->>Carrier: Get events for quiet shipments Carrier-->>Poller: Event history Poller->>Norm: Raw events Norm->>Store: Apply if new and not backwards Store-->>Norm: Current state
Webhook and scheduled poll feeding the same idempotent update path.
Whichever route an event arrives by, the update path must be idempotent. Webhooks get retried, polls return history you have already seen, and the same scan can arrive by both routes. The natural deduplication key is carrier, tracking number, event code and event timestamp. Store that key with a unique constraint and let the database reject the duplicate rather than checking in application code first.
Statuses that go backwards
The failure mode that generates the most confusing customer experiences is a status that regresses. It happens for ordinary reasons: a late-arriving scan from an earlier facility, a poll that returns history out of order, a webhook delayed in a queue. If the current state is OUT_FOR_DELIVERY and an IN_TRANSIT scan from two days ago arrives, the customer must not see the parcel move backwards.
The rule I use is that events are always recorded but the displayed state only moves forward, with explicit exceptions. Assign each state a rank. An incoming event with a lower rank than the current state is stored in the history but does not change the current state. The exceptions are the states that legitimately cycle: DeliveryAttempted back to OutForDelivery, CustomsHold or Exception back to InTransit. Those transitions are allowed by an explicit list, not by rank.
Ordering by the carrier's event timestamp rather than arrival time helps, but it is not sufficient on its own, because carriers occasionally emit events with clocks in different time zones or with missing offsets. Treat the carrier timestamp as the primary sort key and arrival time as the tiebreaker, and keep the rank check as the final guard.
Putting the pieces in order
If I were building this layer from scratch, this is the sequence I would follow. Each step is useful on its own, so the work can ship incrementally.
- Define the lifecycle first. Agree the normalised states and allowed transitions with support and operations before writing an adapter. They know which distinctions customers care about.
- Write one adapter with a mapping table. Start with the carrier that carries the most volume. Store raw events alongside normalised state from day one so nothing is lost.
- Make updates idempotent. Add the unique constraint on the event key and the forward-only rank check before adding a second source of events.
- Add webhooks, keep polling for quiet shipments. Verify webhook signatures, then let the poller cover only shipments that have gone silent for longer than their state allows.
- Alert on unmapped codes. Count UNKNOWN per carrier and review the list weekly. This is how the mapping table stays current without anyone reading carrier changelogs.
What this buys the rest of the system
Once every carrier speaks the same vocabulary, the features that customers actually notice become simple. Notifications are one rule per normalised state. The order page renders one timeline component. Reporting can answer how long parcels spend in customs by destination country, or which carrier has the highest exception rate on a route, without a single carrier-specific query. And when a new carrier is added, the work is one adapter and one mapping table, with everything downstream untouched.
The cost is a real one: you own the mapping, and mappings rot. The UNKNOWN counter and the raw event history are what make that ownership manageable. Without them a normalised model quietly becomes wrong; with them it stays a small, boring table that someone corrects a few times a year, which is exactly what tracking infrastructure should be.