# Watches and webhooks

URL: https://docs.lastscanned.com/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

```bash
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

```bash
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](https://app.lastscanned.com/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`:

```json
{
  "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

```json
{
  "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.

```js tab="Node"
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"));
}
```

```python tab="Python"
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return abs(time.time() - int(parts["t"])) < tolerance and hmac.compare_digest(parts["v1"], expected)
```

## 17TRACK format

Set `"format": "17track"` and the same events go out as 17TRACK's v2 webhook, so a receiver written for 17TRACK works unchanged:

```bash
curl -X PUT https://api.lastscanned.com/v1/webhook \
  -H "Authorization: Bearer $LASTSCANNED_KEY" \
  -H "Content-Type: application/json" \
  -d '{"format": "17track"}'
```

```json
{
  "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](/migrate-from-17track).
