Skip to main content

Troubleshooting: push token expiry — APNs 410, FCM InvalidToken, and UNREGISTERED

Push failures look nothing like SMS failures. On SMS the ladder is carrier receipts (queued → submitted → delivered/undelivered); on push the platform of record — Apple, Google, Huawei, or the browser’s push service — either accepts a token or flatly retires it. A send to a retired token still returns HTTP 201: the failure arrives per-device inside the notifications[] list, as status: "failed" with a provider error string. This page maps that ladder, the token-expiry lifecycle, capability-flag preflight, user_ids registration drift, and VAPID rotation — the tenant-owned controls per class.

How the push status ladder differs from SMS

Every push row you can read carries one of four states: Two consequences differ from SMS:
  1. Acks are SDK-side. Without the SDK’s delivered/opened callback a row stays at sent forever — an empty ladder, not an error. Wire the ack before treating sent as the ceiling, and listen for the push.delivered / push.opened webhooks rather than polling.
  2. No invalid-target preflight. POST /push/send filters to registered, enabled tokens that pass suppression checks; everything else goes to the provider and is graded per-device. A whole-request error (422) means a config problem; a per-device failed entry means a token problem — read the two failure planes separately.
Provider-code vocabulary per device — the string appears verbatim in the error field: A token that fails with a transient error is kept; the four permanent classes above are the expiry path — the next section.

Token expiry lifecycle: APNs 410-Unregistered and FCM UNREGISTERED

When a per-device verdict matches a permanent class — Unregistered, BadDeviceToken, invalid_argument, Invalid registration, not_registered, 410, 404, 403, subscription is gone, invalid token — the platform deletes the device-token row inline, so the next send never repeats the same failure. The send response reports the failure once; after that the device silently drops out of your target scope. Operator procedure:
  1. Re-issue the token on the device. The mobile/web SDK requests a fresh token from the OS (APNs refresh, FCM token rotation, a new PushSubscription) and re-registers with POST /push/device-tokens. Registration is { token : { user } } upsert — re-registering rebinds ownership.
  2. Re-register from the client, not the server. Hand-synthesizing a token value fails permanently; only the OS can mint one.
  3. Drop the device from your target list while it is unregistered. A broadcast (user_ids: ["*"]) skips the deleted row automatically; an explicit device_token_ids / user_ids send re-targets it until the client re-registers. Deleting dead tokens yourself is safe — the platform rows for them are gone.
  4. Long-tail cleanup runs daily, not per send. A background sweep removes tokens whose last_seen_at exceeds the FCM inactivity floor (270 days) and probes stale Web Push subscriptions (>14 days quiet) — endpoints answering 404/410/403 are retired. So uninstalled devices with no send traffic still converge out of your audience.
If a token is failing on every send with a permanent code but the user claims the app is installed, the registration is wrong, not the app — see the isolation checks at the end.

Capability flags preflight: sound, mutable-content, interruption_level, badge

Capability flags are all-or-nothing per send; the platform cannot read back the device’s notification settings, so a mismatch surfaces as a user-visible silent/cropped notification, not as an error. Preflight them before you blame the provider: Rule of thumb: a sent/delivered row plus a missing flag effect is a device-side capability mismatch — preflight the flag, not the provider.

Token registration drift: user_ids collisions

POST /push/device-tokens is an idempotent upsert keyed on the raw token value. If two app instances hand you the same token (a backup-restore clone, a cloned emulator, a backend that re-registers on behalf of a different user_id), the last registration rewrites the owning user_id of that token. Symptoms: user A registers, user B registers, sends to A’s user_ids land on B’s device, or both claim the token and one receives nothing. Isolation:
  1. Send with explicit device_token_ids from GET /push/device-tokens — bypass the user-index; if both users map to one dtk_… id, the token was double-registered.
  2. Compare user_id on the row with the user you think owns it — a mismatch proves the later registration stole ownership.
  3. Have each client register its own stable app_install_id (or device_id). The upsert retires same-install tokens on reinstall, so a fresh install keeps a single row per (user, platform, install).
  4. If two identities genuinely need the same device (shared family device), re-register with the correct user_id per identity switch — ownership follows the latest write, never both.

VAPID key rotation and Web Push endpoint failures

Rotating DEVOTEL_VAPID_PUBLIC_KEY / DEVOTEL_VAPID_PRIVATE_KEY invalidates every existing browser subscription — the push service rejects the old JWT signer with a per-device failed verdict, and the platform retires those tokens like any other permanent failure. POST /push/send still returns 201; only the per-device entries degrade. Because it is the one all-devices-at-once failure vector, treat rotation as an emergency flow:
  1. Rotate only on private-key compromise — never as routine hygiene.
  2. After rotating, have every web client call pushManager.subscribe with the new public key and re-register; the old rows either retire on the next send failure or on the daily cleanup probe.
  3. If you see every web platform target failing with a Web Push error immediately after a deploy, suspect a mistyped or mismatched public/private keypair (generate-vapid-keys prints both together) or a missing DEVOTEL_VAPID_SUBJECT (the mailto:/https contact header the push service requires).

What not to do

  • Do not retry a permanent-expiry verdict at backoff — 410 / Unregistered does not heal by repetition; it burns budget against a row the platform already deleted.
  • Do not ship the server a token the client minted once — refreshable tokens must always re-register via the SDK.
  • Do not rotate VAPID to “clear” weird failures — the side-effect is a full fleet re-registration; debug the per-device error first.

See also