A push notification has two lives. In the first, your server asks Firebase Cloud Messaging (FCM) or Apple's APNs to deliver it, and gets an answer. In the second, a phone somewhere decides whether to show it. Most debugging goes wrong because people read the first answer as if it described the second.
This is a guide to both: what the errors from FCM (HTTP v1) and APNs mean, which ones to retry, which ones to treat as "delete this token", and the failures that come back with no error at all.
"Accepted" is not "delivered"
When APNs answers 200, it accepted your request. When FCM answers with a message name, it accepted your request. A web push service answering 201 Created is the same: RFC 8030 says the message "was accepted", nothing more.
Apple's documentation does not give the sending server a signal that the notification was displayed. FCM is more generous but still separates the two: in its BigQuery export, MESSAGE_ACCEPTED means the FCM server received a valid request, while MESSAGE_DELIVERED means it reached the FCM SDK on the device, and that field is not exported by default. FCM's delivery reports also depend on Google Analytics and can lag by up to 24 hours.
For your pipeline: store "accepted by provider" and "delivered/opened" as different facts. Accepted comes from the provider's answer. Delivered and opened have to come from your app reporting back.
Sort every error into one of four buckets
Before the table, a rule that saves hours. Every provider error is one of these:
- The token is dead. Delete it and never send to it again.
- Your request is wrong. Fix it; retrying the same request repeats the failure.
- Your credentials or configuration are wrong. Fix the setup; it affects many devices at once.
- The provider is struggling or you are going too fast. Retry later, with exponential backoff and jitter.
Mixing the buckets is how teams end up deleting healthy tokens during an outage, or retrying a dead token forever.
FCM (HTTP v1)
| Error | HTTP | What it means | What to do |
|---|---|---|---|
UNREGISTERED |
404 | The token is no longer valid: the app was uninstalled, unregistered, or the token expired | Delete the token |
INVALID_ARGUMENT |
400 | A parameter is invalid: a malformed token, wrong package name, message too big, reserved data key, invalid TTL | Read the BadRequest detail. Fix the message. Delete the token only after confirming the payload is valid |
SENDER_ID_MISMATCH |
403 | The sender you authenticated as is not the sender the token was registered for | Check that your service account belongs to the same Firebase project as the app. Do not delete tokens first |
QUOTA_EXCEEDED |
429 | Rate exceeded for the project, one device, or one topic | Reduce the rate. FCM recommends exponential backoff with an initial delay of at least 1 minute for message and topic rates |
UNAVAILABLE |
503 | FCM is overloaded | Retry the same request, honor Retry-After if present, back off exponentially, add jitter |
INTERNAL |
500 | Unknown FCM error | Retry with backoff; check the FCM status dashboard; if it persists, contact Firebase support |
THIRD_PARTY_AUTH_ERROR |
401 | The APNs certificate or web push auth key is invalid or missing, for a message to an iOS device or a web registration | Fix the APNs or web push credentials in the Firebase console |
Three details from the official docs that are easy to miss:
-
INVALID_ARGUMENTis the ambiguous one. FCM's own token guide says it can mean an invalid registration or a bad payload, and tells you to treat it as a dead token only when you have confirmed the payload is fine. Log the field detail before you delete anything. - The size limit is 4096 bytes for most messages and 2048 bytes for messages to topics, keys and values included. TTL must be an integer between 0 and 2,419,200 seconds (4 weeks).
- On Android, FCM expires a registration after 270 days of inactivity. On iOS it relies on APNs, which has no such expiry, so Firebase recommends you prune stale tokens yourself.
APNs
| Status | reason |
What it means | What to do |
|---|---|---|---|
| 400 | BadDeviceToken |
The token is invalid, or does not match the environment | First check sandbox vs production. Only then treat it as dead |
| 400 | DeviceTokenNotForTopic |
The token does not belong to the topic (bundle ID) you sent | Fix the apns-topic. Apple lists it among the errors not to retry as-is |
| 400 | TopicDisallowed |
Pushing to this topic is not allowed | Check that the topic is allowed for your app and key |
| 403 | ExpiredProviderToken |
Your JWT is stale | Generate a new one and retry |
| 403 | InvalidProviderToken |
The JWT is invalid or its signature can't be verified | Check the key, Key ID and Team ID |
| 410 | Unregistered |
The token is no longer active for the topic; the response includes a timestamp
|
Stop sending to it |
| 413 | PayloadTooLarge |
The payload is over the limit | Shrink it. 4 KB (4096 bytes), 5 KB for VoIP |
| 429 | TooManyRequests |
Too many requests to the same device token | Retry with a delay |
| 429 | TooManyProviderTokenUpdates |
You refresh your JWT too often | Refresh at most once every 20 minutes |
| 500 / 503 |
InternalServerError, ServiceUnavailable, Shutdown
|
Apple-side trouble | Retry with backoff |
The rules in Apple's own words are worth keeping near your code: don't retry BadDeviceToken, DeviceTokenNotForTopic, Forbidden, ExpiredToken, Unregistered or PayloadTooLarge; you can retry TooManyRequests with a delay; 5xx responses can be retried, and Apple mentions waiting 15 minutes and using back-off.
Two more things from that page. Errors in the 4xx range slow you down: APNs can disconnect a provider connection that produces too many of them, and sooner for BadDeviceToken. Sending to dead tokens is not only wasteful, it throttles your good traffic. And BadDeviceToken has a classic cause: a token from an Xcode (development) build belongs to the sandbox environment, while TestFlight and App Store builds belong to production. Send to the wrong host and every token of the other kind fails.
For your pipeline: map each code to a bucket in one place (a lookup table, not scattered ifs), and log the raw code next to your own classification. When a new reason shows up, the default should be "don't delete, don't retry blindly, alert".
Failures that come back without an error
This is the part the tables can't help with. The provider said yes, and the user saw nothing.
Notifications are off for the app. On Android 13 and later, notifications are off by default for a new install, and if the user taps Don't allow, all notification channels are blocked. The token stays valid and the send keeps being accepted. FCM even warns that high-priority messages that produce no visible notification can be deprioritized over a 7-day window. Check areNotificationsEnabled() in the app and report it to your server along with the token. Do Not Disturb and Focus modes work the same way from your server's point of view: the system decides what to show, and nothing tells you.
Doze and priority. FCM's normal-priority messages may be held back while the device is in Doze; high priority is the option meant for urgent, user-visible content. APNs has the same idea: apns-priority 10 means now, 5 means "when power considerations allow", and 5 or 1 can be grouped into bursts or throttled. Pick the priority per message type, not one global default.
Expiry while the phone is offline. With no TTL, FCM can keep a message for up to four weeks; with a TTL of 0 it discards what it can't deliver immediately. APNs with apns-expiration: 0 tries once and doesn't store the notification. If your "flash sale ends in 1 hour" push sat in a queue for a day, it should have expired, so set TTL on purpose.
Android vendors that kill apps. Some manufacturers aggressively stop background apps to save battery. The community site dontkillmyapp.com rates Xiaomi, Samsung, OnePlus and Huawei at its worst level. You can't fix this from the server, but you can detect the pattern: if your delivery stats look fine overall and bad for one device brand, this is the first suspect. Link users to the vendor-specific steps from your support page.
Expo Go. If you test with Expo Go, remote push is not available there on Android from SDK 53; Expo says a development build is required, while local notifications still work in Expo Go. "It works in my build and not in Expo Go" is expected behavior.
Web push. Three things trip people up. On iOS the permission request must come from direct user interaction, and asking from a button is the safe default everywhere. The push needs a registered service worker. And on iOS and iPadOS 16.4 and later, web push works for web apps added to the Home Screen, not for a plain Safari tab. When a subscription has expired, RFC 8030 says the push service answers 404: delete it.
A debugging order that works
- Did the provider accept the request? Read the status and
reason/error code, not just "success". - Is the token alive? Check how old it is and when the app last registered it.
- Right environment? Sandbox vs production for APNs, right Firebase project for FCM.
- Is the payload under the limit, with a sensible TTL and priority?
- Is the app allowed to show notifications on that device (permission, channel, focus modes)?
- Is the device a known battery-killer, or a build that can't receive remote push (Expo Go)?
- If you control the app, did it report receipt? If not, you are guessing.
About the project
I work on Espresso Notify, a push notification service for iOS, Android and web with a dashboard and a REST API, and the cases above shaped how it reports a failure. Each failed device gets a stable code and what to do about it instead of a bare provider string, tokens the provider says are dead are removed so the next send skips them, and the docs state plainly that "sent" means the provider accepted it. It is free up to 10,000 devices.
- How delivery results are reported: https://docs.espressonotify.com/docs/sending/verify-delivery
- Setting up the iOS key, including the sandbox/production choice: https://docs.espressonotify.com/docs/credentials/apns
A question for the comments: when FCM returns INVALID_ARGUMENT, how do you decide between "bad payload" and "dead token" in your pipeline? Do you parse the BadRequest detail, or do you wait for a second failure?
Top comments (0)