DEV Community

Cover image for Package Tracking API for UPS, FedEx & DHL: No Carrier Keys
Muhammad Afzal
Muhammad Afzal

Posted on Edited on

Package Tracking API for UPS, FedEx & DHL: No Carrier Keys

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. Then it shows how to connect the same trackers to an AI agent in Claude, ChatGPT, Codex, Gemini, Grok or Cursor through Apify's MCP server, with automation recipes you can copy.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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
}
Enter fullscreen mode Exit fullscreen mode

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 }),
  },
};
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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`);
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
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"])
Enter fullscreen mode Exit fullscreen mode

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.

Turn it into an AI agent: Claude, ChatGPT, Codex, Gemini, Grok and Cursor

The script above is one way to drive the trackers. The other is to hand them to an AI agent. All five Actors are exposed as tools through Apify's MCP server. Any assistant that speaks MCP can then read each tool's input schema, output fields and price per record, pick the right tracker for each number, run it, and reason over the JSON that comes back. You describe the job in plain language instead of writing the routing and normalization yourself.

This is the server URL with only the five trackers enabled:

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
Enter fullscreen mode Exit fullscreen mode

It signs you in to Apify with OAuth on first connect. Clients that can't do OAuth send Authorization: Bearer <APIFY_TOKEN> instead. Save the URL once, since the snippets below use it:

export APIFY_MCP_URL="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"
Enter fullscreen mode Exit fullscreen mode

Claude (claude.ai, Claude Desktop, Claude Code)

In claude.ai or Claude Desktop, open Settings → Connectors, choose Add custom connector, paste the URL and sign in to Apify. In Claude Code:

claude mcp add --transport http apify "$APIFY_MCP_URL"
# then run /mcp inside Claude Code to sign in
Enter fullscreen mode Exit fullscreen mode

ChatGPT

Turn on Developer Mode in ChatGPT's settings, create a connector with the URL as the MCP server and OAuth as the authentication, then enable it from the tools menu in a chat. Developer Mode is available on paid plans, and workspace admins may need to allow custom connectors.

OpenAI Codex

codex mcp add apify --url "$APIFY_MCP_URL"
codex mcp login apify
Enter fullscreen mode Exit fullscreen mode

Or put it in ~/.codex/config.toml and use a token instead of OAuth:

[mcp_servers.apify]
url = "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"
bearer_token_env_var = "APIFY_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Gemini CLI

gemini mcp add --transport http apify "$APIFY_MCP_URL"
# then run /mcp auth apify inside Gemini CLI to sign in
Enter fullscreen mode Exit fullscreen mode

On a headless machine, skip OAuth and pass --header "Authorization: Bearer <APIFY_TOKEN>" instead.

Grok (xAI API)

Grok connects to remote MCP servers from the API, so the agent runs in your own code:

import os
from xai_sdk import Client
from xai_sdk.chat import user
from xai_sdk.tools import mcp

client = Client(api_key=os.environ["XAI_API_KEY"])
chat = client.chat.create(
    model="grok-4.7",  # use the current Grok model
    tools=[mcp(
        server_url=os.environ["APIFY_MCP_URL"],
        server_label="apify",
        extra_headers={"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"},
    )],
)
chat.append(user("Track 1Z999AA10123456784 and 794644790138. Which ones are delayed?"))
print(chat.sample().content)
Enter fullscreen mode Exit fullscreen mode

Cursor and other MCP clients

apify mcp install cursor writes the Cursor config for you. VS Code, Windsurf and any other client that accepts a remote MCP server work with the same URL. For a client without remote MCP support, run the server locally with npx -y @apify/actors-mcp-server and APIFY_TOKEN set.

Agent automation recipes

Once the tools are connected, these are prompts you can give the agent as they are.

Morning exception report. "Read open-orders.csv. Track every number with the right tracker. List the packages that are delayed, have a failed delivery attempt, or have had no new scan for three days, each with its last scan location. Draft a short message for each customer." To run it unattended, call Claude Code from cron:

# every weekday at 07:00
0 7 * * 1-5 cd ~/ops && claude -p "$(cat morning-report-prompt.txt)" --allowedTools "mcp__apify,Read,Write"
Enter fullscreen mode Exit fullscreen mode

codex exec and gemini -p run the same prompt non-interactively.

Support-ticket answers. Give a support agent the tools and let it answer "where is my order?" itself. It tracks the number on the ticket, reads statusCategory or status and the latest event, and writes the reply with the real delivery estimate.

Stop re-checking finished packages. UPS and Multi-Carrier records carry terminal: true once a package is delivered or returned. Tell the agent to drop those numbers from tomorrow's list, so each run only checks what is still moving.

Cross-border explanations. 17TRACK records list every carrier a parcel passed through, with origin and destination countries. The agent can explain a handoff such as "left China Post and is now with USPS in Chicago" in plain words.

Every call returns structured records and honest outcomes. A number with no data comes back as EMPTY, a refused lookup as BLOCKED, and nothing is invented, so the agent never has to tell a guess from a fact.

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.

Which AI assistants can use these trackers?

Any MCP client: Claude (claude.ai, Claude Desktop, Claude Code), ChatGPT with Developer Mode, OpenAI Codex, Gemini CLI, Grok through the xAI API, Cursor, VS Code and others. They all connect to the same Apify MCP server URL; the setup for each is in the agent section above.

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.

Top comments (0)