> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

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

> Decode per-device push outcomes — token-registered, sent, delivered, APNs 410-Unregistered, FCM UNREGISTERED — retire expired tokens, preflight capability flags, isolate user_ids registration drift, and survive a VAPID rotation.

# 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:

| State                | Meaning                                                                                                                                                      | Where to watch it          |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------- |
| **token-registered** | The device holds a live token: `POST /push/device-tokens` returned `201`. Nothing has been sent yet — a valid token proves registration, not deliverability. | `GET /push/device-tokens`  |
| **sent**             | The provider accepted the envelope. This is the response value on an immediate send; it is not delivery acknowledgment.                                      | send response `status`     |
| **delivered**        | Your mobile/web SDK posted a delivery ack (`event: delivered`) and the platform stamped `delivered_at`. Taps stamp `opened_at`.                              | `GET /push/notifications`  |
| **failed**           | The provider rejected this device — the `error` string names the provider-code reason (see the table below).                                                 | send response and log rows |

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:

| Provider verdict                                                               | Where it comes from | Verdict                                                                |
| ------------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------------- |
| `Unregistered` (HTTP status `410` on APNs reason codes, `UNREGISTERED` on FCM) | APNs / FCM          | Permanent — the token is retired.                                      |
| `BadDeviceToken`                                                               | APNs                | Permanent — token mint against the wrong host (sandbox vs production). |
| `Invalid registration` / `invalid token`                                       | FCM / Web Push      | Permanent — malformed or swapped token value.                          |
| `subscription is gone` / `404` / `403`                                         | Web Push (RFC 8030) | Permanent — the subscription endpoint is retired.                      |
| Provider 5xx / network timeout                                                 | any                 | Transient — retry; the token may be fine.                              |

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:

| Flag                 | What a mismatch looks like                                                                                                                               | Detection                                                                                                                                                                                               |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sound`              | Notification arrives visibly but silent — the device played no tone because its own Do-Not-Disturb / focus mode or the app's channel defaults win.       | Compare send `status: delivered/ok` with the device's Notification-settings panel and with the Android `channel_id` a client-side dump reports — FCM treats channel config as device-side once created. |
| `mutable_content`    | The image/rich payload is dropped; client sees title+body only because the Notification Service Extension is not embedded or is missing the entitlement. | Ship the extension; verify its bundle id is registered and `mutable_content: true` survives to APNs (a plain-text fallback is the tell).                                                                |
| `interruption_level` | `passive` rows bucket silently in Notification Summary; `critical` without the Apple entitlement renders as `active`.                                    | Compare your manifest entitlement and the level you sent; downgrade expectations for un-entitled clients.                                                                                               |
| `badge`              | Lock-screen badge sticks at an old value — usually the app never cleared it locally.                                                                     | Track the badge progress in your app; a `delivered` row with an unchanged badge is local-app state, not the send.                                                                                       |

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

* [Push channel overview](/channels/push) — envelope shape, capability
  fields, per-device response contract.
* [Push notification categories guide](/guides/push-notification-categories)
  — register curated `notification_channel` behavior.
* [Token onboarding](/channels/push#token-onboarding) — SDK surfaces for
  re-registration.
* [Webhook events](/reference/webhook-events) — `push.delivered` /
  `push.opened` payloads.
* [Frequently Asked Questions](/reference/faq) — long tail push Q\&A.
