You can get a package tracking API for UPS, FedEx, DHL and many postal services without registering for three carrier developer programs: send batches of tracking numbers to five pay-per-result tracking Actors on Apify through one client library, and get one JSON record per package with its status, delivery estimate and latest scan. On Apify's FREE plan it costs USD 20 per 1,000 FedEx or DHL records, USD 30 per 1,000 UPS records and USD 39 per 1,000 records for other carriers, and numbers that return nothing are not charged.

This post builds a small script that reads a CSV of mixed tracking numbers, routes each one to the right tracker, and writes a single tracking-status.csv.

Disclosure: I built and maintain the five Actors used here, and they are paid tools on Apify Store. I've kept to batch sizes, field names and prices you can check on each Store page.

Which tracker handles which carrier?

Carrier Actor Numbers per run Scan history FREE price per 1,000 records
FedEx (tracking and door-tag numbers) FedEx Tracking Scraper 30 Yes USD 20
UPS (tracking and InfoNotice numbers) UPS Tracking Scraper 25 Latest update only USD 30
DHL (Express, eCommerce, supply chain) DHL Tracking Scraper 10 Yes USD 20
Postal services and other carriers on 17TRACK 17TRACK Package Tracking Scraper 10 Yes USD 39
Mixed UPS and FedEx International Connect, one status vocabulary Multi-Carrier Package Tracking 25 Yes USD 40

Each Actor reads the carrier's public tracking page (or 17TRACK's public tracker), so none of them needs a carrier login, OAuth app or API key.

When should you use the official carrier tracking APIs instead?

Use the official UPS, FedEx or DHL tracking API when you already have developer access, need labels, rates, address changes or proof-of-delivery documents, or want push notifications tied to your shipping account. That is what those APIs are for.

The approach here fits a narrower job: you already have tracking numbers, often from several shippers, marketplaces or 3PLs, and you only need their current status in a structured format. You skip three registrations, three auth flows and three SDKs. The trade-offs are real: you get what the public tracking pages show, lookups take seconds each rather than milliseconds, and nothing is pushed to you, so you poll on a schedule.

What you need

  • Node.js 18 or newer (Python 3.11 or newer for the Python example)
  • A free Apify account and your API token (Apify Console → Settings → API & Integrations)
  • npm install apify-client
export APIFY_TOKEN=your_token_here 

Step 1: Route each tracking number to the right Actor

Most order systems already store the carrier, so the input file can carry it. When a row has no carrier, two formats are safe to detect: 1Z plus 16 characters is UPS, and 12 or 15 digits is usually FedEx. Everything else goes to 17TRACK, which detects the carrier itself.

carrier,trackingNumber fedex,794644790138 ups,1Z999AA10123456784 dhl,00340434292135100186 ,LX123456789CN 
function detectCarrier(number) { if (/^1Z[0-9A-Z]{16}$/.test(number)) return 'ups'; if (/^(\d{12}|\d{15})$/.test(number)) return 'fedex'; return 'other'; // 17TRACK detects the carrier } 

DHL numbers come in too many shapes to detect safely, so label them in the file. An unlabelled DHL number still works through 17TRACK, it just costs more. When 17TRACK can't tell a number's carrier, the run reports it without charging, and you can rerun it with the 17TRACK carrier code (FedEx is 100003).

Step 2: Describe the four trackers once

Each Actor has its own batch size and input fields. Keeping them in one table makes the rest of the script carrier-agnostic:

const ACTORS = { fedex: { id: 'muhammadafzal/fedex-package-tracking-scraper', batch: 30, price: 0.02, timeout: 900, input: (nums) => ({ trackingNumbers: nums, includeDetailedScans: true, maxResults: nums.length }), }, ups: { id: 'muhammadafzal/ups-package-tracking-scraper', batch: 25, price: 0.03, timeout: 600, input: (nums) => ({ trackingNumbers: nums }), }, dhl: { id: 'muhammadafzal/dhl-package-tracking-scraper', batch: 10, price: 0.02, timeout: 300, input: (nums) => ({ trackingNumbers: nums, demoMode: false, accessMode: 'auto' }), }, other: { id: 'muhammadafzal/17track-package-tracking-scraper', batch: 10, price: 0.039, timeout: 300, input: (nums) => ({ trackingNumbers: nums }), }, }; 

Three settings matter:

  • timeout is the run timeout in seconds. Lookups run one at a time, and a full FedEx batch of 30 takes several minutes, so FedEx and UPS get more than the 300-second default.
  • price is the FREE-tier price per record, used below to set a spend cap per run.
  • demoMode: false for DHL. The DHL Actor's default input runs DHL's sample number 7777777770, which returns a demo record labelled isDemoData: true.

Step 3: Normalize four output shapes into one

The Actors return the carrier's own vocabulary, so field names differ. A small mapper gives you one row shape:

function normalize(carrier, r) { switch (carrier) { case 'fedex': return { carrier: 'FedEx', trackingNumber: r.tracking_number, status: r.status, eta: r.estimated_delivery_date ?? r.actual_delivery_date, lastLocation: r.last_scan?.location }; case 'ups': return { carrier: 'UPS', trackingNumber: r.trackingNumber, status: r.statusText, eta: r.estimatedDelivery, lastLocation: r.lastEvent?.location }; case 'dhl': return { carrier: 'DHL', trackingNumber: r.trackingNumber, status: r.status, eta: r.estimatedDelivery, lastLocation: r.currentLocation }; default: return { carrier: (r.carriers ?? []).join(' > ') || 'unknown', trackingNumber: r.trackingNumber, status: r.status, eta: r.estimatedDelivery, lastLocation: r.latestEvent?.location }; } } 

If you need more than the summary, every record carries more: FedEx adds status_code, service, ship_date, package_count and the full scan_events; UPS adds a status category, terminal, delivered, delayed, deliveredTo and receivedBy; DHL adds statusCode, origin and destination fields and events; 17TRACK adds statusCategory, originCountry, destinationCountry, daysInTransit and events.

Step 4: Run the batches and write one CSV

Carriers run in parallel, and batches within a carrier run one after another, so at most four runs are active at once. Each run gets a spend cap of its batch size times the record price plus a cent.

// track-all.mjs → node track-all.mjs shipments.csv import { readFile, writeFile } from 'node:fs/promises'; import { ApifyClient } from 'apify-client'; const client = new ApifyClient({ token: process.env.APIFY_TOKEN }); // ACTORS, detectCarrier() and normalize() from the steps above const groups = { fedex: new Set(), ups: new Set(), dhl: new Set(), other: new Set() }; const lines = (await readFile(process.argv[2] ?? 'shipments.csv', 'utf8')).split(/\r?\n/); for (const line of lines) { const [first, second] = line.split(',').map((s) => s.trim()); const number = (second ?? first ?? '').replace(/[\s-]/g, '').toUpperCase(); if (!/\d/.test(number)) continue; // header or blank line const carrier = second !== undefined && first ? first.toLowerCase() : detectCarrier(number); (groups[carrier] ?? groups.other).add(number); } async function runBatch(key, nums) { const a = ACTORS[key]; const run = await client.actor(a.id).call(a.input(nums), { timeout: a.timeout, maxTotalChargeUsd: Number((nums.length * a.price + 0.01).toFixed(2)), }); const { items } = await client.dataset(run.defaultDatasetId).listItems(); if (items.length < nums.length) { console.warn(`${key}: ${items.length}/${nums.length} records, see run ${run.id}`); } return items.filter((r) => r.isDemoData !== true).map((r) => normalize(key, r)); } const perCarrier = await Promise.all(Object.entries(groups).map(async ([key, set]) => { const nums = [...set]; const rows = []; for (let i = 0; i < nums.length; i += ACTORS[key].batch) { rows.push(...await runBatch(key, nums.slice(i, i + ACTORS[key].batch))); } return rows; })); const rows = perCarrier.flat(); const cell = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`; const csv = ['carrier,trackingNumber,status,eta,lastLocation', ...rows.map((r) => [r.carrier, r.trackingNumber, r.status, r.eta, r.lastLocation].map(cell).join(','))]; await writeFile('tracking-status.csv', csv.join('\n')); console.log(`${rows.length} records written to tracking-status.csv`); 

A FedEx record before normalization looks like this (shortened, values illustrative):

{ "tracking_number": "794644790138", "carrier": "FedEx", "status": "Delivered", "status_code": "DL", "actual_delivery_date": "2026-10-20T13:12:00-05:00", "service": "FedEx Ground", "last_scan": { "timestamp": "2026-10-20T13:12:00-05:00", "status": "Delivered", "location": "DALLAS, TX", "event_code": "DL" }, "scan_events": ["... full scan history ..."], "tracking_url": "https://www.fedex.com/fedextrack/?trknbr=794644790138" } 

Step 5: Find out why a number returned nothing

A typo, a label that was never scanned, an expired record or a refused lookup produces no record, and none of the Actors invents a row for it or charges for it. Each run explains every missing number in its key-value store: the record is called SUMMARY for FedEx and DHL, and OUTPUT for UPS, 17TRACK and Multi-Carrier. Add this inside runBatch, after the run finishes:

const KV_KEY = { fedex: 'SUMMARY', dhl: 'SUMMARY', ups: 'OUTPUT', other: 'OUTPUT' }; const record = await client.keyValueStore(run.defaultKeyValueStoreId).getRecord(KV_KEY[key]); console.log(record?.value); 

The outcome is one of DATA, EMPTY (the carrier has no record), REJECTED (malformed number), BLOCKED (the carrier refused the lookup) or FAILED (timeout or page error). UPS and Multi-Carrier mask tracking numbers in their diagnostics, so those records are safe to paste into a ticket.

The same call from Python

pip install "apify-client>=3.3,<4" 
import os from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("muhammadafzal/ups-package-tracking-scraper").call( run_input={"trackingNumbers": ["1Z999AA10123456784"], "locale": "en_US"} ) for item in client.dataset(run.default_dataset_id).iterate_items(): print(item["trackingNumber"], item["status"], item["terminal"], item["lastEvent"]) 

In apify-client 3.x, call() returns a Run object, so read run.default_dataset_id rather than run["defaultDatasetId"].

When does Multi-Carrier Package Tracking make sense?

Use Multi-Carrier Package Tracking when you want UPS scan-by-scan history, or when one list mixes UPS and FedEx International Connect (KOT, PRO) numbers and you want one status vocabulary: pre_advice, accepted, in_transit, out_for_delivery, delayed, delivery_attempted, ready_for_pickup, delivered, returned, exception or unknown. It takes 25 numbers per run with carrier: "auto".

Two honest limits: standard 12- and 15-digit FedEx numbers are accepted there but not yet verified, so send those to FedEx Tracking Scraper; and USPS lookups are currently refused and end as BLOCKED, with no result charge.

Run it every morning

Save the input in Apify Console and attach a schedule (hourly, daily or any cron expression), or run the script from your own cron. UPS and Multi-Carrier records carry terminal: true once a package is delivered or returned, so each run you can drop those numbers and re-check only what is still moving. Apify can also call a webhook when each run finishes.

Ask an AI assistant to do it

All five Actors work from Claude, ChatGPT, Cursor or any MCP client through Apify's MCP server. Add this URL as a server:

https://mcp.apify.com/?tools=muhammadafzal/fedex-package-tracking-scraper,muhammadafzal/ups-package-tracking-scraper,muhammadafzal/dhl-package-tracking-scraper,muhammadafzal/17track-package-tracking-scraper,muhammadafzal/multi-carrier-package-tracking 

Then ask something like: "Track these 40 numbers, pick the right tracker for each carrier, and list the delayed ones with their last scan location." The assistant can read each input schema and price before it runs anything.

What does it cost?

Prices are per record delivered, plus a start fee per run. Paid Apify plans get the same or lower per-record prices.

Actor FREE, per 1,000 records GOLD and above, per 1,000 Start fee per run
FedEx Tracking Scraper USD 20 USD 17 USD 0.005
UPS Tracking Scraper USD 30 USD 22 USD 0.005
DHL Tracking Scraper USD 20 USD 17 USD 0.00005
17TRACK Package Tracking Scraper USD 39 USD 31.20 USD 0.005
Multi-Carrier Package Tracking USD 40 USD 32 USD 0.005

Multi-Carrier's price applies from 6 November 2026; before that date it is USD 20 per 1,000 on FREE. The start fee is charged per started GB of run memory, which is one start event at each Actor's default memory.

A worked example: 500 numbers split into 200 FedEx, 150 UPS, 100 DHL and 50 others, every one returning a record, on the FREE tier:

  • FedEx: 7 runs, 200 × 0.020 + 7 × 0.005 = USD 4.04
  • UPS: 6 runs, 150 × 0.030 + 6 × 0.005 = USD 4.53
  • DHL: 10 runs, 100 × 0.020 + 10 × 0.00005 = USD 2.00
  • 17TRACK: 5 runs, 50 × 0.039 + 5 × 0.005 = USD 1.98
  • Total: about USD 12.54, less for any number that returns nothing.

FAQ

Do I need a UPS, FedEx or DHL developer account?

No. You need an Apify account and its API token. If you already have a DHL developer key, the DHL Actor can use DHL's official API instead of the public page: put it in dhlApiKey.

Is there a free package tracking API?

These Actors charge per record, with no subscription. Apify's Free plan includes USD 5 of usage per month, and pay-per-event charges draw on it, so you can test a couple of hundred FedEx or DHL lookups before paying anything.

How many packages can I track at once?

30 FedEx, 25 UPS, 25 Multi-Carrier, 10 DHL or 10 17TRACK numbers per run. For longer lists, start more runs, as the script does.

Does this track USPS?

Not reliably right now. Multi-Carrier Package Tracking recognizes USPS formats, but the lookups are currently refused and end as BLOCKED without a result charge.

Is the data real-time?

Each record is what the carrier's public page showed at scrapedAt, scraped_at or retrievedAt. There is no push feed; schedule runs as often as you need.

Am I charged for numbers that are not found?

No. Not-found, invalid, blocked and failed numbers write no record and pay no record charge. The start fee still applies to the run.

Limits worth knowing

  • These Actors read public tracking pages. They don't create labels, quote rates, change deliveries or download proof-of-delivery signatures.
  • Carriers hide some fields by service, destination and shipment age, and old tracking records expire. New labels can take 24–48 hours to show events.
  • Lookups are sequential inside a run, so a full FedEx batch takes minutes, not seconds.
  • Track only shipments you send, receive or are authorized to monitor. Records can include delivery locations and recipient text, so treat the output as personal data.
  • The Actors are unofficial and not affiliated with UPS, FedEx, DHL or 17TRACK; the names identify the public sources.

Links

If your carrier mix includes a format this routing gets wrong, tell me in the comments and I'll add it.