Watches and webhooks
A watch follows a number for you. POST /v1/watches looks it up straight away and returns the current result, the same as GET /v1/track, so there is no waiting for a first crawl. After that we re-check it on a schedule and POST to your webhook whenever the status, the newest scan or the estimated delivery changes.
You don't need a watch to track a number. GET /v1/track stays the simplest way to answer "where is it" on demand. Watches are for when you'd rather be told.
1. Set your webhook
curl -X PUT https://api.lastscanned.com/v1/webhook \
-H "Authorization: Bearer $LASTSCANNED_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/tracking"}'The response has your signing secret (whsec_…). It stays the same when you change the URL; send "rotateSecret": true for a new one. POST /v1/webhook/test sends a webhook.test event and tells you what your endpoint answered. The dashboard's Webhooks page does all of this too, and lists every delivery.
2. Watch numbers
curl -X POST https://api.lastscanned.com/v1/watches \
-H "Authorization: Bearer $LASTSCANNED_KEY" \
-H "Content-Type: application/json" \
-d '{"trackingNumber": "541270563758", "metadata": {"order": "A1"}}'You can also paste numbers on the dashboard's Watches page, which lists every watch with its latest scan. Send "trackingNumbers": [...] for up to 100 at once. carrier is optional, as with /v1/track. metadata is any JSON object under 2 KB, and comes back in every event for that watch.
The response is the watch, with its first result:
{
"id": "wat_109060ade9f9a48732117f7a",
"trackingNumber": "541270563758",
"carrier": "fedex",
"status": "delivered",
"active": false,
"metadata": { "order": "A1" },
"endReason": "delivered",
"result": { "carrier": "fedex", "status": "delivered", "...": "same shape as /v1/track" }
}Watching a number counts as one tracked number, like a lookup. The re-checks after that are free. An account can have 5,000 active watches.
| Route | |
|---|---|
GET /v1/watches | Active watches, newest first. ?active=false for ended ones; ?limit= (max 200), ?cursor= from nextCursor. |
GET /v1/watches/:id | One watch with its last result or error. |
DELETE /v1/watches/:id | Stop watching. |
How often we check
| Status | Next check |
|---|---|
out_for_delivery | 15 minutes |
exception | 30 minutes |
in_transit | 1 hour |
label_created | 2 hours |
A watch ends by itself when the package is delivered or returned, after 14 days with no carrier record, or after 90 days.
Events
{
"id": "evt_63e401d5f73a4c31ef8fa8d9",
"type": "tracking.updated",
"createdAt": "2026-10-27T18:20:36Z",
"data": {
"watch": { "id": "wat_109060ade9f9a48732117f7a", "trackingNumber": "541270563758", "carrier": "fedex", "metadata": { "order": "A1" } },
"previousStatus": "in_transit",
"result": { "carrier": "fedex", "status": "delivered", "...": "same shape as /v1/track" }
}
}tracking.updated: something changed. The first successful check always sends one.watch.ended: the watch stopped without a final status;data.reasonisnot_foundorexpired. Delivered and returned packages end with their lasttracking.updated.
Delivery and retries
Any 2xx within 10 seconds counts as delivered. Anything else is retried after 1, 5, 30, 120, 360 and 720 minutes, then marked failed. You can send any delivery again from the dashboard or with POST /v1/webhook/deliveries/:id/retry, and GET /v1/webhook/deliveries lists the last 30 days.
A retry can arrive after a newer event, and in rare cases one event arrives twice. Deduplicate on the event id (also in the Lastscanned-Event-Id header) and order by createdAt.
Signatures
Every request carries Lastscanned-Signature: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256 of "<t>.<raw body>", keyed with your secret. Check it against the raw body, before parsing, and reject old t values.
import crypto from "node:crypto";
function verify(rawBody, header, secret, toleranceSec = 300) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < toleranceSec;
return fresh && crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));
}17TRACK format
Set "format": "17track" and the same events go out as 17TRACK's v2 webhook, so a receiver written for 17TRACK works unchanged:
curl -X PUT https://api.lastscanned.com/v1/webhook \
-H "Authorization: Bearer $LASTSCANNED_KEY" \
-H "Content-Type: application/json" \
-d '{"format": "17track"}'{
"event": "TRACKING_UPDATED",
"data": {
"number": "541270563758",
"carrier": 100003,
"param": null,
"tag": "shop-1",
"track_info": {
"latest_status": { "status": "Delivered", "sub_status": "Delivered_Other", "sub_status_descr": "Delivered" },
"latest_event": { "time_iso": "2026-10-26T10:09:00-04:00", "time_utc": "2026-10-26T14:09:00Z", "description": "Delivered", "location": "OCALA, FL", "stage": "Delivered", "...": "" },
"time_metrics": { "...": "" },
"milestone": [ "..." ],
"tracking": { "providers": [ { "provider": { "key": 100003, "name": "FedEx" }, "events": [ "..." ] } ] }
}
}
}tracking.updatedbecomesTRACKING_UPDATED, andwatch.endedbecomesTRACKING_STOPPED.carrierandprovider.keyare 17TRACK's numeric carrier codes. 347 of our carriers have one; the rest send0.tagis your watch'smetadata.tag, when that is a string.- Statuses map as
label_created→InfoReceived,in_transit→InTransit,out_for_delivery→OutForDelivery,delivered→Delivered,exception→Exception,returned→Exception/Exception_Returned. - Requests carry 17TRACK's
signheader, hex SHA-256 of"<raw body>/<secret>", as well asLastscanned-Signature. Point your existing check at your lastscanned secret instead of your 17TRACK key.
Coming from 17TRACK's register call? POST /v1/watches takes its place, and returns the tracking result in the same response instead of a queued registration. See Migrating from 17TRACK.