Trust is earned, not given

A different perspective

2025-08-21 · Projects

Tracking packages by API: FedEx, UPS, DHL, USPS — and the AliExpress/Temu carriers from China

Order tracking is the integration nobody plans for until support tickets pile up. This is the practical map as of mid-2025: the four big carriers' official APIs with working C# (secrets read from environment variables, never hard-coded), the China cross-border carriers that actually move AliExpress and Temu parcels, the last-mile handoff gotcha that confuses everyone, and the aggregator shortcut when you need fifty carriers instead of five. A cheat-sheet table at the end collects every signup URL and public tracking-URL variant.

FedEx: OAuth2, then a single POST

Sign up at developer.fedex.com — test keys are free and issued immediately, production keys after a short review. The API is OAuth2 client-credentials: exchange client id + secret for a bearer token (valid about an hour), then call tracking:

using System.Net.Http.Headers;
using System.Net.Http.Json;

static readonly HttpClient http = new();

// Token endpoint. Secrets come from the environment - never from source code.
using var tokenReq = new HttpRequestMessage(HttpMethod.Post,
    "https://apis.fedex.com/oauth/token")
{
    Content = new FormUrlEncodedContent(new Dictionary<string, string> {
        ["grant_type"] = "client_credentials",
        ["client_id"] = Environment.GetEnvironmentVariable("FEDEX_API_KEY")!,
        ["client_secret"] = Environment.GetEnvironmentVariable("FEDEX_SECRET_KEY")!,
    })
};
using var tok = await http.SendAsync(tokenReq);
var tokenJson = await tok.Content.ReadFromJsonAsync<JsonElement>();
var accessToken = tokenJson.GetProperty("access_token").GetString();

// Tracking: one POST, multiple numbers supported in the payload.
using var trackReq = new HttpRequestMessage(HttpMethod.Post,
    "https://apis.fedex.com/track/v1/tracking-numbers")
{
    Content = JsonContent.Create(new {
        trackingInfo = new object[] {
            new { trackingNumberInfo = new { trackingNumber = "774901234567" } }
        }
    })
};
trackReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var resp = await http.SendAsync(trackReq);
var feed = await resp.Content.ReadAsStringAsync();
// resp "completeTrackResults[0].trackResults[0].latestStatusDetail.description"
// carries the human status ("Delivered", "In transit", ...).

Public tracking URL (no auth, shareable): https://www.fedex.com/fedextrack/?trknbr=774901234567. FedEx numbers are typically 12 digits.

UPS: the new OAuth developer portal

The classic XML Track API is retired territory — sign up at developer.ups.com (free, instant sandbox) and use the REST API: the same client-credentials OAuth dance, then a GET per tracking number. UPS numbers are the familiar 1Z... (18 characters):

// 1. Token: Basic auth header holds base64(clientId:clientSecret).
using var tok = new HttpRequestMessage(HttpMethod.Post,
    "https://onlinetools.ups.com/security/v1/oauth/token")
{
    Content = new FormUrlEncodedContent(
        new Dictionary<string, string> { ["grant_type"] = "client_credentials" })
};
var user = Environment.GetEnvironmentVariable("UPS_CLIENT_ID")!;
var pass = Environment.GetEnvironmentVariable("UPS_CLIENT_SECRET")!;
tok.Headers.Authorization = new AuthenticationHeaderValue("Basic",
    Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes($"{user}:{pass}")));
// ...send, read "access_token"...

// 2. Track: GET /api/track/v1/details/{inquiryNumber}
using var tr = new HttpRequestMessage(HttpMethod.Get,
    "https://onlinetools.ups.com/api/track/v1/details/1Z999AA10123456784" +
    "?locale=en_US&returnSignature=false");
tr.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
tr.Headers.Add("transId", Guid.NewGuid().ToString("N"));   // your correlation id
tr.Headers.Add("transactionSrc", "maplecart-portal");      // who is calling
// Response: trackResponse.shipment[0].package[0].activity[0] = latest scan
// (city, code, description, timestamp) - walk activity[] for full history.

Public URL: https://www.ups.com/track?tracknum=1Z999AA10123456784.

DHL: the friendliest of the four

developer.dhl.com issues an API key (not OAuth) for the Express Tracking API — one header, one GET. This is the fastest big-carrier integration you will ever do. DHL Express waybills are 10 digits:

using var req = new HttpRequestMessage(HttpMethod.Get,
    "https://api-eu.dhl.com/track/shipments" +
    "?trackingNumber=1234567890&language=en");
req.Headers.Add("DHL-API-Key",
    Environment.GetEnvironmentVariable("DHL_API_KEY")!);
// Response "status.status" ("delivered", "in-transit") plus events[] history.
// Regional hosts: api-eu.dhl.com (EU), api-us.dhl.com (US) - pick by account.

Public URL: https://www.dhl.com/en/express/tracking.html?AWB=1234567890 (DHL Parcel Germany has a separate portal and API — different product, check developer.dhl.com's parcel section).

USPS: in mid-migration

The ancient Web Tools API (a USERID plus XML GET) is being replaced by the modern OAuth-based APIs at developers.usps.com; 2025 is the transition year with both alive. New integrations should target the new platform — but the Web Tools track call is still the one you will find everywhere in the wild:

// Legacy Web Tools (still operational during migration): a GET that returns
// TrackResponse XML. USERID + password are per-account, free from USPS.
var url = "https://secure.shippingapis.com/ShippingAPI.dll?API=TrackV2&XML=" +
    Uri.EscapeDataString(
        "<TrackRequest USERID=\"ENV-USPS-USERID\">" +
        "<TrackID ID=\"9400111899223812345678\"></TrackID></TrackRequest>");
// New platform equivalent: OAuth token then GET on api.usps.com - same shape
// as FedEx/UPS above. 22-digit "9400..." IMpb barcodes are the norm.

Public URL: https://tools.usps.com/go/TrackConfirmAction?tLabels=9400....

The China carriers: who actually moves AliExpress and Temu parcels

The big four are irrelevant for a $6 phone case from Shenzhen. Cross-border parcels ride China-origin consolidators, and 2020s e-commerce exposed the whole world to their naming quirks:

None of these have public no-contract APIs as open as FedEx's; the official door is their merchant portal (Yun Express, 4PX and Yanwen issue API tokens to shipping customers with an account), and the public door is a web lookup — most usefully Cainiao's JSON endpoint, which knows about all the consolidators it carries:

// Cainiao global lookup - public JSON, no key, fine for low-volume human
// support tooling; do NOT build a production poller on an undocumented
// endpoint (it changes without notice, and there is no SLA):
GET https://global.cainiao.com/global/detail.json?mailNos=LP00123456789012&lang=en
// Web UI variant: https://global.cainiao.com/detail.htm?mailNoList=LP...
// Yun Express:    https://www.yuntrack.com/Track/Detail?id=YT2512345678
// Yanwen:         https://track.yanwen.com.cn/?trackNumber=YE2512345678
// 4PX:            https://track.4px.com/  (search box; number variants vary)

The last-mile handoff gotcha

Every China-carrier integration meets the same confusion: the tracking history ends at "arrived at destination country / handed to local carrier" and goes silent for days. The parcel is not lost — it changed hands and usually changed tracking numbers (the consolidator's number becomes a USPS 9400... or a local-carrier code for the final leg). Cainiao's lookup follows the handoff internally; standalone carrier APIs do not. If you poll only the origin carrier, your users will see a dead status exactly when the parcel is closest to them.

The pragmatic answer: an aggregator

When the requirement is "any number from any store in one UI", do not integrate twelve APIs — integrate one aggregator that auto-detects the carrier and follows last-mile handoffs. 17TRACK (api.17track.net, free tier, key from the developer console) is the one with the deepest China-carrier coverage, which is why it is the default answer for AliExpress/Temu flows; AfterShip and TrackingMore are the commercial alternatives with richer webhooks:

using var req = new HttpRequestMessage(HttpMethod.Post,
    "https://api.17track.net/track/v2.2/register");
req.Headers.Add("17token",
    Environment.GetEnvironmentVariable("TRACK17_API_KEY")!);
req.Content = JsonContent.Create(new object[] {
    new { number = "YT2512345678", carrier = 0 }   // 0 = auto-detect
});
// register once, then /get returns merged events across carriers - including
// the China -> USPS handoff, which is exactly what raw carrier APIs miss.

Public URL: https://t.17track.net/en#nums=YT2512345678.

Cheat sheet: everything in one table

CarrierSignupAuthPublic tracking URL
FedExdeveloper.fedex.comOAuth2 client-credentials fedex.com/fedextrack/?trknbr={n}
UPSdeveloper.ups.comOAuth2 client-credentials ups.com/track?tracknum={n}
DHL Expressdeveloper.dhl.comAPI key header dhl.com/en/express/tracking.html?AWB={n}
USPSdevelopers.usps.comOAuth2 (new) / UserID (legacy) tools.usps.com/go/TrackConfirmAction?tLabels={n}
Cainiao (AliExpress)merchant portal / undocumented JSON none for public lookup global.cainiao.com/detail.htm?mailNoList={n}
Yun Express (Temu/AE)YunTrack merchant accountaccount token yuntrack.com/Track/Detail?id={n}
YanwenYanwen merchant accountaccount token track.yanwen.com.cn/?trackNumber={n}
17TRACK (aggregator)17track.net developer consoleAPI key t.17track.net/en#nums={n}

Design notes for your tracker service

Three lessons from running one against these APIs: cache aggressively — packages do not move at 3am, so a 15–60 minute poll cadence per active tracking number is plenty and keeps you far under every quota; normalize to one event model (carrier status codes are a zoo — map them once to in-transit / out-for-delivery / delivered / exception and store the raw payload for forensics); and key everything off the tracking number, keep secrets in configuration — every snippet above reads the environment, and nothing here exposes or hard-codes a real credential. Number-format sniffing (12 digits → FedEx, 1Z → UPS, 10 digits → DHL, 9400 → USPS, YT/LP → China consolidator → 17TRACK) will carry you further than you expect before you need the paid aggregator tier.