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 pathUniversal push
TokenExponentPushToken[…]APNs device token / FCM registration token
Mass registrationPOST /api/device/tokens (the SDK calls it)POST /api/universal/device/register
Indie registrationPOST /api/indie/idsame endpoint, with subscriberId
Sends go throughExpo's push serviceApple / Google directly
Delivery evidenceExpo receipts, checked ~15 min after a sendAPNs/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.

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).

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 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.