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:
- Acks are SDK-side. Without the SDK’s
delivered/openedcallback a row stays atsentforever — an empty ladder, not an error. Wire the ack before treatingsentas the ceiling, and listen for thepush.delivered/push.openedwebhooks rather than polling. - No invalid-target preflight.
POST /push/sendfilters 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-devicefailedentry means a token problem — read the two failure planes separately.
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:
- 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 withPOST /push/device-tokens. Registration is{ token : { user } }upsert — re-registering rebinds ownership. - Re-register from the client, not the server. Hand-synthesizing a token value fails permanently; only the OS can mint one.
- Drop the device from your target list while it is unregistered. A
broadcast (
user_ids: ["*"]) skips the deleted row automatically; an explicitdevice_token_ids/user_idssend re-targets it until the client re-registers. Deleting dead tokens yourself is safe — the platform rows for them are gone. - Long-tail cleanup runs daily, not per send. A background sweep
removes tokens whose
last_seen_atexceeds 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.
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:
- Send with explicit
device_token_idsfromGET /push/device-tokens— bypass the user-index; if both users map to onedtk_…id, the token was double-registered. - Compare
user_idon the row with the user you think owns it — a mismatch proves the later registration stole ownership. - Have each client register its own stable
app_install_id(ordevice_id). The upsert retires same-install tokens on reinstall, so a fresh install keeps a single row per (user, platform, install). - If two identities genuinely need the same device (shared family device),
re-register with the correct
user_idper identity switch — ownership follows the latest write, never both.
VAPID key rotation and Web Push endpoint failures
RotatingDEVOTEL_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:
- Rotate only on private-key compromise — never as routine hygiene.
- After rotating, have every web client call
pushManager.subscribewith the new public key and re-register; the old rows either retire on the next send failure or on the daily cleanup probe. - If you see every
webplatform target failing with a Web Push error immediately after a deploy, suspect a mistyped or mismatched public/private keypair (generate-vapid-keysprints both together) or a missingDEVOTEL_VAPID_SUBJECT(themailto:/httpscontact header the push service requires).
What not to do
- Do not retry a permanent-expiry verdict at backoff —
410/Unregistereddoes 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
- Push channel overview — envelope shape, capability fields, per-device response contract.
- Push notification categories guide
— register curated
notification_channelbehavior. - Token onboarding — SDK surfaces for re-registration.
- Webhook events —
push.delivered/push.openedpayloads. - Frequently Asked Questions — long tail push Q&A.