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/watchesActive watches, newest first. ?active=false for ended ones; ?limit= (max 200), ?cursor= from nextCursor.
GET /v1/watches/:idOne watch with its last result or error.
DELETE /v1/watches/:idStop watching.

How often we check

StatusNext check
out_for_delivery15 minutes
exception30 minutes
in_transit1 hour
label_created2 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.reason is not_found or expired. Delivered and returned packages end with their last tracking.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.updated becomes TRACKING_UPDATED, and watch.ended becomes TRACKING_STOPPED.
  • carrier and provider.key are 17TRACK's numeric carrier codes. 347 of our carriers have one; the rest send 0.
  • tag is your watch's metadata.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 sign header, hex SHA-256 of "<raw body>/<secret>", as well as Lastscanned-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.

On this page