API documentation
Base URL https://www.tracksonar.com · OpenAPI 3.0
· Postman collection
· llms.txt for AI agents · MCP at https://www.tracksonar.com/mcp
1 · Get a key
curl -X POST https://www.tracksonar.com/v1/signup -d '{"email":"you@example.com"}'
# → {"api_key":"trk_live_...","plan":"free"}
2 · Track a parcel
curl -X POST https://www.tracksonar.com/v1/trackings \
-H "Authorization: Bearer $KEY" \
-d '{"tracking_number":"1Z12345E0205271688"}'
# 201 → {"id":"...","carrier":"ups","status":"pending",...}
The carrier is worked out from the number; pass "carrier":"usps" to pin it.
Sending the same number again returns 200 and is never billed twice.
A few carriers only accept a registration with an extra detail — the last four
digits of the recipient's phone for SF Express (CN), ZTO or JD Logistics, a postal
code for some European ones. GET /v1/carriers lists them per carrier as
required_extras (phone, postal,
ship_date, country); pass
"extra":{"phone":"13800001234"} with the create, or it is a
422 missing_extra naming what is missing.
To see what we would detect for a number — without creating a tracking or
spending quota — call GET /v1/detect:
curl -H "Authorization: Bearer $KEY" "https://www.tracksonar.com/v1/detect?number=SF1234567890123"
# → {"number":"SF1234567890123","ambiguous":false,"candidates":[
# {"carrier":"sf-express","name":"SF Express","confidence":90,
# "providers":{"17track":"100012","kuaidi100":"shunfeng","51tracking":"sf-express","track123":"sfb2c"}}]}
Ranked, highest confidence first. Add &provider=51tracking to also get
provider_carrier. Local pattern match only: instant, no quota.
ambiguous=true means only an aggregator fallback matched.
3 · Read status
curl -H "Authorization: Bearer $KEY" https://www.tracksonar.com/v1/trackings/{id}
curl -H "Authorization: Bearer $KEY" "https://www.tracksonar.com/v1/trackings?external_order_id=ORD-1"
# filters: tracking_number, external_order_id, status, carrier, created_from, created_to
# → {"data":[...],"total":1,"has_more":false}
curl -X PATCH -H "Authorization: Bearer $KEY" https://www.tracksonar.com/v1/trackings/{id} \
-d '{"notify_email":"buyer@example.com","metadata":{"sku":"A1"}}' # free, no quota
Statuses run pending → info_received → in_transit → out_for_delivery → delivered,
plus available_for_pickup, delivery_failure, exception,
returned (back with the sender; a return still under way is exception with
substatus exception.returning) and expired. substatus is a closed
status.detail vocabulary shared by every carrier — see the API reference.
4 · Webhooks
Verify your email first — click the sign-in link we mailed you. Creating an
endpoint before that is a 403 email_unverified.
curl -X POST https://www.tracksonar.com/v1/webhooks \
-H "Authorization: Bearer $KEY" \
-d '{"url":"https://example.com/hooks/track"}'
# → {"secret":"whsec_..."} (shown once)
Every update POSTs JSON with the header
X-Track-Signature: t=<unix>,v1=<hex> where
v1 = HMAC-SHA256(secret, t + "." + body). Reject it if |now − t| > 5 min.
Event types are tracking.updated, tracking.delivered, tracking.returned,
tracking.exception and one per status. Delivery is at-least-once and
unordered, so deduplicate on the payload id.
To check your handler before a real update arrives,
POST /v1/webhooks/{id}/test queues a tracking.test event —
same envelope and signature, sample shipment. GET /v1/webhooks/deliveries
lists every delivery with the payload we sent and the first 1 KB of your reply
(?status=dead for the ones we gave up on), and
POST /v1/webhooks/deliveries/{id}/redeliver sends one again.
Delivery, retries, and why you cannot lose an event
The delivery row is written in the same transaction as the shipment update that caused it. There is no window in which we accept a scan and forget to tell you: if the update is in your tracking history, the webhook for it is queued.
A delivery is any 2xx. Anything else is retried 7 times
over about 3 days — after 1 min, 5 min, 30 min, 2 h, 12 h, 24 h, 24 h —
then marked dead, which you can still replay by hand. We time out
after 10 s and follow no redirects.
If your endpoint fails 20 times in a row we disable it and stop
calling. We keep queueing: events that happen while it is disabled are
parked, not dropped, and they do not consume retry attempts while parked.
POST /v1/webhooks/{id}/enable re-arms the endpoint and releases the
entire backlog at once, oldest first. An outage on your side costs you latency,
not history.
Retention — one window for replay and for storage
The same number decides two things: how long a failed or parked delivery can still be retried or replayed, and how long we keep its payload before deleting it. It is 1 to 168 hours (7 days, the maximum), account-wide, and defaults to the maximum.
curl -X POST https://www.tracksonar.com/v1/webhooks/retention \
-H "Authorization: Bearer $KEY" -d '{"hours":24}'
# current value: GET /v1/me → {"webhook_retention_hours":24, ...}
Shorten it if you would rather we did not hold your shipment data; lengthen it
to give yourself more room to recover from a long outage. A delivery that is
still undelivered when its window runs out is marked dead with the
reason, then removed — past the window there is nothing left to replay.
Debug mode — while you are writing the handler
curl -X POST https://www.tracksonar.com/v1/webhooks/$ID/debug \
-H "Authorization: Bearer $KEY" -d '{"enabled":true}'
Two things change for that endpoint. We record the exact headers we
sent with every delivery, signature included, and the dashboard turns
them into a curl command you can paste to replay our request against
your own server — the one input you cannot reconstruct yourself is the timestamp
we signed with, which is why a signature that will not verify is otherwise
painful. And its failures stop counting toward auto-disable, so
an afternoon of 500s while you iterate cannot disable your own endpoint.
Debug mode does not expire on its own. Turn it off when the integration works: the stored headers contain a valid signature, and there is no reason to keep those around once you no longer need them.
Verify a signature in Python or Node
# Python
import hmac, hashlib, time
def verify(secret, header, body):
t, v1 = [p.split("=",1)[1] for p in header.split(",")]
if abs(time.time() - int(t)) > 300: return False
want = hmac.new(secret.encode(), f"{t}.".encode()+body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, v1)
// Node
const crypto = require("crypto");
function verify(secret, header, body) {
const [t, v1] = header.split(",").map(p => p.split("=")[1]);
if (Math.abs(Date.now()/1000 - +t) > 300) return false;
const want = crypto.createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
const a = Buffer.from(want), b = Buffer.from(v1 || "");
return a.length === b.length && crypto.timingSafeEqual(a, b); // timingSafeEqual throws on length mismatch
}
5 · Limits and billing
You are billed for trackings created per month. Reads, lists and webhooks
are free. The free plan is 100 a month, and that is the only plan open right
now — paid tiers are not yet available for self-service signup.
Over quota returns 402 quota_exceeded (do not retry until next month
or an upgrade); too fast returns 429 rate_limited with a
Retry-After header.
6 · MCP, for AI assistants
{"mcpServers": {"track": {"type": "http", "url": "https://www.tracksonar.com/mcp",
"headers": {"Authorization": "Bearer trk_live_..."}}}}
Tools: track_number, detect_carrier, get_tracking,
list_trackings, list_carriers.
7 · SDKs
Plain HTTP is the SDK: one key header and JSON. If you want typed clients, generate them from the spec (openapi-generator, needs node + Java) — nothing is published to npm or PyPI:
make sdk-ts # → sdk/ts (typescript-fetch)
make sdk-py # → sdk/py (python, package track_sdk)
Or import the Postman collection
and set its baseUrl and bearerToken variables.
// TypeScript, no dependencies
const BASE = "https://www.tracksonar.com", KEY = process.env.TRACK_KEY!;
const api = (path: string, init: RequestInit = {}) =>
fetch(BASE + path, { ...init, headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" } })
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error.code) }));
const t = await api("/v1/trackings", { method: "POST",
body: JSON.stringify({ tracking_number: "1Z12345E0205271688", notify_email: "buyer@example.com" }) });
const full = await api(`/v1/trackings/${t.id}`); // with events
const page = await api("/v1/trackings?limit=50&offset=0"); // { data: [...] }, newest first
// webhook receiver: verify(secret, req.headers["x-track-signature"], rawBody) — section 4
# Python, requests
import os, requests
BASE, s = "https://www.tracksonar.com", requests.Session()
s.headers["Authorization"] = f"Bearer {os.environ['TRACK_KEY']}"
t = s.post(f"{BASE}/v1/trackings", json={"tracking_number": "1Z12345E0205271688",
"notify_email": "buyer@example.com"}).json()
full = s.get(f"{BASE}/v1/trackings/{t['id']}").json() # with events
page = s.get(f"{BASE}/v1/trackings", params={"limit": 50, "offset": 0}).json()["data"]
# webhook receiver: verify(secret, request.headers["X-Track-Signature"], request.get_data()) — section 4
Errors
{"error":{"message":"...","code":"bad_request|unauthorized|not_found|method_not_allowed|invalid_number|missing_extra|quota_exceeded|rate_limited|email_unverified|endpoint_disabled|already_exists|already_subscribed|signups_closed|billing_unavailable|internal"}}
Unknown JSON fields are a 400 naming the field; 404 and 405 under
/v1 use the same envelope. Quota exhaustion is 402, not
429 — a 429 is burst throttling and is safe to retry.