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.