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 — 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
lastSeenAtand 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
deviceIdanalytics 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:
- A token earns a strike each time a receipt run reports
DeviceNotRegistered. - A token is only ever removed after 2+ separate receipt runs — never on the first report.
- Removal is off by default on the server (
DELETE_DEAD_TOKENS=1enables it) — with it off, strikes stay visible in the failure history and nothing is deleted. - 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.
What "delivered" means
- Expo path:
deliveredcomes 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).
Where to look when a device "didn't get it"
- Registrations:
/api/universal/health(universal) or the dashboard's Audience view (both paths) — is the device's token there, live, and not struck? - Receipts/failures: the dashboard's failure-reasons card, or
get_notification_failures— is it one device (DeviceNotRegistered) or every device (InvalidCredentials)? - A single-device proof:
test-sendon the universal path; a test push from the dashboard on the Expo path. - The device itself: notification permission, battery/background settings, and whether the app ever deregistered the device on logout.