# Tokens & Receipts

How device tokens register and rotate, what Expo delivery receipts prove (~15 min after a send), the strikes rule before a dead token is ever removed, and where failures surface.

A push is only as good as the token it targets. This page is the token lifecycle on both paths — the Expo SDK path and [universal push](/docs/universal-push) — plus what "delivered" can and cannot mean.

## Two token worlds

|                    | Expo SDK path                                    | Universal push                                       |
| ------------------ | ------------------------------------------------ | ---------------------------------------------------- |
| Token              | `ExponentPushToken[…]`                           | APNs device token / FCM registration token           |
| Mass registration  | `POST /api/device/tokens` (the SDK calls it)     | `POST /api/universal/device/register`                |
| Indie registration | `POST /api/indie/id`                             | same endpoint, with `subscriberId`                   |
| Sends go through   | Expo's push service                              | Apple / Google directly                              |
| Delivery evidence  | **Expo receipts**, checked \~15 min after a send | APNs/FCM accept-or-reject at send time — no receipts |

## Registration & rotation

- **Register on every launch.** Both endpoints are idempotent upserts: re-registering refreshes `lastSeenAt` and token values, and never duplicates a device or token.
- **Tokens rotate.** FCM/APNs (and the SDK's Expo tokens) hand you new values after reinstalls and restores. Re-register the new value with the same device identity — presenting a token again proves it is in current use and clears its dead flag.
- **One device, several tokens** (e.g. an Expo token *and* a native APNs token) is normal, and so is one subscriber with many devices. Native Notify never trims a subscriber's device list at write time — dead-token cleanup is the receipt pipeline's job, never a storage optimization.
- **Deregister** (universal path) is a hard, idempotent delete for logout/uninstall flows.
- **Optional metadata** rides registrations: app version, device timezone, and — with the SDK's opt-in `deviceId` analytics flag — a stable device key. They power support views and unique-user analytics.

## Receipts (Expo path)

Expo accepts a send immediately and reports the real outcome minutes later. Native Notify:

- queues every accepted ticket;
- **checks receipts every 5 minutes** — roughly **15 minutes after the send**;
- records per-notification **accepted / delivered / failed** counts, updates the app's subscriber count from *real* deliveries, and stores each failure's reason code;
- surfaces it all in the dashboard (notification stats, failure-reasons card, open rates) and to agents (`get_notification_delivery_stats`, `get_notification_failures`).

Common receipt reasons you will see: `DeviceNotRegistered` (that install is gone), `InvalidCredentials` / `MismatchSenderId` (the app's push credentials are wrong — every token fails), `MessageRateExceeded`, `MessageTooBig`.

## Dead tokens: strikes before cleanup

The rule is deliberately conservative:

1. A token earns a **strike** each time a receipt run reports `DeviceNotRegistered`.
2. A token is only ever removed after **2+ separate receipt runs** — never on the first report.
3. Removal is **off by default** on the server (`DELETE_DEAD_TOKENS=1` enables it) — with it off, strikes stay visible in the failure history and nothing is deleted.
4. If an entire app's receipts fail in one run (≥5 errors, mostly `DeviceNotRegistered`), deletion is suppressed for that run — that shape is usually a **credentials problem, not dead devices**, and deleting tokens would silently shrink the audience.

On the universal path the same idea applies without receipts: a rejected token is recorded with the transport's answer, repeatedly failing tokens accumulate strikes, and re-registering a device clears its token's dead flag (a token moving to a different device resets its strikes). Check counts any time with [`/api/universal/health`](/docs/universal-push/verification).

## What "delivered" means

- **Expo path:** `delivered` comes from Expo's receipt — the device-side push service accepted it. It is not "the user saw it".
- **Universal path:** responses say `delivered: null`. APNs/FCM acceptance is the strongest signal a native token can give, and Native Notify never fabricates a number on top of it.

In both worlds, whether a notification is *displayed* also depends on the OS and the user (permission, focus mode, notification settings) — which is why open-rate analytics are collected client-side (see [Analytics](/docs/analytics)).

## Where to look when a device "didn't get it"

1. **Registrations:** `/api/universal/health` (universal) or the dashboard's Audience view (both paths) — is the device's token there, live, and not struck?
2. **Receipts/failures:** the dashboard's failure-reasons card, or `get_notification_failures` — is it one device (`DeviceNotRegistered`) or every device (`InvalidCredentials`)?
3. **A single-device proof:** [`test-send`](/docs/universal-push/verification) on the universal path; a test push from the dashboard on the Expo path.
4. **The device itself:** notification permission, battery/background settings, and whether the app ever deregistered the device on logout.
