On this pageShowHide
The API has two endpoints. One looks up tracking numbers, the other lists the carriers. This page covers both, and what surrounds them: the key, the nine statuses, the errors and the limits.
1Quick start
Every request goes to https://api.shipmenttrackingapi.com over HTTPS, and every answer is JSON.
First, get a key: sign in to the console with Google and create one. It is free. The key is shown once, so copy it then. Next, send a carrier and an array of tracking numbers.
curl https://api.shipmenttrackingapi.com/v1/track \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"carrier":"ups","trackingNumbers":["YOUR_TRACKING_NUMBER"]}'const res = await fetch("https://api.shipmenttrackingapi.com/v1/track", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ carrier: "ups", trackingNumbers: ["YOUR_TRACKING_NUMBER"] }),
});
const body = await res.json();
if (!body.success) throw new Error(body.error.code);
for (const r of body.data.results) {
console.log(r.trackingNumber, r.status ?? r.error.code);
}import requests
res = requests.post(
"https://api.shipmenttrackingapi.com/v1/track",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"carrier": "ups", "trackingNumbers": ["YOUR_TRACKING_NUMBER"]},
)
body = res.json()
if not body["success"]:
raise SystemExit(body["error"]["code"])
for r in body["data"]["results"]:
print(r["trackingNumber"], r.get("status") or r["error"]["code"])<?php
$ch = curl_init("https://api.shipmenttrackingapi.com/v1/track");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer YOUR_API_KEY", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["carrier" => "ups", "trackingNumbers" => ["YOUR_TRACKING_NUMBER"]]),
]);
$body = json_decode(curl_exec($ch), true);
if (!$body["success"]) exit($body["error"]["code"]);
foreach ($body["data"]["results"] as $r) {
echo $r["trackingNumber"], " ", $r["status"] ?? $r["error"]["code"], "\n";
}body := strings.NewReader(`{"carrier":"ups","trackingNumbers":["YOUR_TRACKING_NUMBER"]}`)
req, _ := http.NewRequest("POST", "https://api.shipmenttrackingapi.com/v1/track", body)
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
out, _ := io.ReadAll(res.Body)
fmt.Println(string(out)) YOUR_API_KEY and YOUR_TRACKING_NUMBER.The answer has one result for each number. Read its status: it is always one of nine words. To send a request without writing code, open the Playground.
A lookup is not instant, so give your HTTP client a timeout of 60 seconds. Retries and timeouts has the details.
2Authentication
POST /v1/track needs your key in the Authorization header. GET /v1/carriers needs no key.
Authorization: Bearer YOUR_API_KEY
A key starts with sta_. Each account has one key. We store only a hash of it, so the console cannot show it to you again. If you lose it, or it leaks, replace it in the console. A deleted key can keep working for up to a minute.
The limits belong to the account, not to the key: a new key carries on with the same count.
Keep the key on your server. A key placed in a web page or a mobile app can be read by anyone who uses it.
3The request
POST /v1/track looks up one or more tracking numbers for one carrier. Send the body as JSON, with Content-Type: application/json.
{
"carrier": "usps",
"trackingNumbers": [
"YOUR_TRACKING_NUMBER_1",
"YOUR_TRACKING_NUMBER_2"
]
} | Field | Type | Rule |
|---|---|---|
| carrier | string | One of usps, ups, fedex, dhl, in lowercase. |
| trackingNumbers | array of strings | An array even for one number. USPS takes 35 at once; UPS, FedEx and DHL take 1. |
A request has four rules: one carrier per request, no more numbers than that carrier takes, no number twice, and every number 6 to 40 letters and digits. Carriers print their numbers with spaces, so strip them first. Break a rule and the whole request is rejected with a 400. The message names the rule and points to the number at fault.
Letters can be in either case. Each number comes back in uppercase, with leading and trailing spaces removed.
4The response
results has one entry for each number, in the order you sent them. An entry is either tracking data or an error for that one number. The HTTP status is 200 either way.
An example, not a real shipment. Four earlier scans are left out.
| Field | What it holds |
|---|---|
| results[] | One entry for each number, in the order you sent them. |
| trackingNumber | The number you sent, in uppercase. |
| status | One of the nine. The package’s latest state. |
| events[] | Every scan, oldest first. |
| events[].timestamp | ISO 8601. USPS times are in UTC; the other carriers keep the local offset. |
| events[].timeUnknown | true when the carrier gave a date without a time. The time in timestamp is then a placeholder. |
| events[].status | One of the nine, for that scan. |
| events[].description | The carrier’s own words. |
| events[].location | Where it was scanned, when the carrier says. |
| estimatedDelivery | When the carrier gives one. |
| lastUpdated | When this data was read from the carrier. |
| error | In place of a status, when that one number has no answer. |
timeUnknown, location, estimatedDelivery and lastUpdated are left out when there is nothing to put in them.
Your code only has to read status. description is the carrier’s wording, written for people: show it, but do not build logic on it.
5When one number fails
One number without an answer does not fail the others. Its entry carries an error in place of status and events.
Of these errors, only TRACKING_NOT_FOUND makes the request count, the same as tracking data does.
| Code | Counts | What it means |
|---|---|---|
| TRACKING_NOT_FOUND | Yes | The carrier has no record of that number. |
| INVALID_TRACKING_NUMBER | No | Not a valid number for that carrier. |
| CARRIER_ERROR | No | The carrier did not answer for that number. Send it again. |
INVALID_TRACKING_NUMBER can also reject the whole request. If a number is not shaped like that carrier’s numbers, the API answers 400 and nothing is counted. When the request had several numbers, the message lists up to five of the ones to fix.
6The nine statuses
A status is always one of these nine words, for the package as a whole and for each scan.
| Status | Meaning |
|---|---|
| pending | A label exists, but the carrier does not have the package yet. |
| in_transit | Moving through the carrier’s network. Also a missed delivery that can still be picked up or redelivered. |
| out_for_delivery | On the vehicle for the final delivery. |
| delivered | Handed over at the destination. |
| failed | The delivery could not be completed. |
| returned | Going back to the sender, or already back. |
| cancelled | The shipment was cancelled before delivery. |
| lost | The carrier reports the package missing. |
| unknown | The carrier said something the table does not cover yet. |
The words come from a table of what each carrier says, shown on the home page. The translation is automatic, and a status can be late, missing or wrong. Each scan also carries the carrier’s own words, in description.
7Carriers
GET /v1/carriers lists the carriers and how many tracking numbers each one takes in a request. It needs no key and is not counted.
| carrier | Name | Numbers per request |
|---|---|---|
| usps | USPS | 1 to 35 |
| ups | UPS | 1 |
| fedex | FedEx | 1 |
| dhl | DHL | 1 |
curl https://api.shipmenttrackingapi.com/v1/carriers
Read maxTrackingNumbers from this response instead of writing the numbers into your code.
A request names one carrier, and the API does not work out the carrier from a number. For packages from two carriers, send two requests.
8Errors
When the whole request fails, the HTTP status says so, and the body has the same shape every time: a code your program can switch on and a message a person can read.
The answer to a UPS request that carried 2 numbers.
| Code | HTTP | When |
|---|---|---|
| INVALID_CARRIER | 400 | The carrier is not one of usps, ups, fedex, dhl. |
| INVALID_TRACKING_NUMBER | 400 | A number is malformed, repeated, or not valid for that carrier. trackingNumbers is always an array. |
| TOO_MANY_TRACKING_NUMBERS | 400 | More numbers than the carrier takes in one request: 35 for USPS, 1 for the others. |
| AUTH_MISSING_API_KEY | 401 | There is no Authorization header. |
| AUTH_INVALID_API_KEY | 401 | The key does not exist. |
| KEY_INACTIVE | 403 | The key was turned off. |
| NOT_FOUND | 404 | There is no such endpoint. Check the path and the method. |
| RATE_LIMIT_EXCEEDED | 429 | More than 30 requests in a minute. Retry-After says when to come back. |
| MONTHLY_QUOTA_EXCEEDED | 429 | The account has used its 1,000 lookups for the month. |
| IP_QUOTA_EXCEEDED | 429 | The IP address has used its 1,000 lookups for the month, across every key. |
| INTERNAL_ERROR | 500 | Something unexpected on our side. Try again. |
| SERVICE_UNAVAILABLE | 503 | Something on our side. Try again. |
One exception: a body that is not valid JSON is refused before it reaches the API, with a plain 400 and no JSON.
An error for a single number is not in this table. It comes inside results with HTTP 200, as When one number fails describes.
Switch on code. The wording of a message can change.
9Limits
| Lookups per account | 1,000 a month | One request is one lookup. Resets on the 1st, UTC. |
| USPS packages, at most | 35,000 a month | A USPS request carries up to 35 numbers and still counts as one lookup. |
| Requests per account | 30 a minute | A 429 tells you when to retry. |
| Lookups per IP address | 1,000 a month | Counted across every key and the Playground. An address over the limit is blocked until the 1st, UTC. |
| Numbers per request | 1 to 35 | USPS takes 35 at once; UPS, FedEx and DHL take 1. |
A request counts as one lookup when at least one number in it comes back with tracking data or with TRACKING_NOT_FOUND. A request where every entry is INVALID_TRACKING_NUMBER or CARRIER_ERROR is not counted. Neither is a request that was rejected with a 400, 429 or 503.
The answers to POST /v1/track carry headers that say where you stand.
| Header | What it says |
|---|---|
| X-RateLimit-Limit-Minute | Requests your account can send in one minute. |
| X-RateLimit-Remaining-Minute | Requests left in the current minute. |
| X-RateLimit-Limit-Month | Lookups your account has for the month. |
| X-RateLimit-Remaining-Month | Lookups left this month. |
| Retry-After | Only with RATE_LIMIT_EXCEEDED: the seconds until the next minute starts. |
The minute is the clock minute: the count starts again at second 0. Every request with a valid key uses one of the 30, whatever the answer. The month is the calendar month, in UTC.
The IP limit adds up every lookup from one address, whichever key made it. An IPv6 address is counted by its /64 network. If you share an outbound address with other people, as on a serverless platform or behind a shared NAT, send your requests through a static IP address of your own.
10Retries and timeouts
A lookup is not instant. In our tests a USPS request took 24 to 28 seconds, whether it carried 2 numbers or 35. Some take longer.
A request that runs too long comes back 503, is not counted, and can be sent again. Set your HTTP client’s timeout to 60 seconds: a client that gives up sooner can miss a result that was still counted.
| Code | HTTP | When to send it again |
|---|---|---|
| RATE_LIMIT_EXCEEDED | 429 | After the seconds in Retry-After. |
| MONTHLY_QUOTA_EXCEEDED | 429 | On the 1st of next month, UTC. |
| IP_QUOTA_EXCEEDED | 429 | On the 1st of next month, UTC. |
| SERVICE_UNAVAILABLE | 503 | After a few seconds. It was not counted. |
| INTERNAL_ERROR | 500 | After a few seconds. |
| CARRIER_ERROR | 200 | Send that number again. The other numbers in the request have their answers. |
Do not resend a 400, 401, 403 or 404 as it is. Fix the request first.
Send requests one after another. Requests sent at the same moment are more likely to come back 503. For a list longer than one request holds, this note has a loop that splits it and retries.
There are no webhooks. To follow a package, ask again later, and stop once it is delivered.
11For tools and agents
The same reference is published as two files that a program can read.
| File | What it is |
|---|---|
| /openapi.json | An OpenAPI 3.1 description of both endpoints, for API clients and code generators. |
| /llms.txt | A plain-text summary for language models and coding agents. |