Expo push notification errors, and what to do about each
Every Expo push ticket, receipt and request error: DeviceNotRegistered, MessageTooBig, MismatchSenderId, InvalidCredentials, TOO_MANY_REQUESTS, with fixes.
Expo’s push service answers in two stages. The ticket comes back at once and says whether Expo accepted the message. The receipt, fetched later with the ticket’s id, says what Apple or Google did with it. Errors can appear in either, and a whole request can also fail. Here is every code Expo documents, and what to do about each.
Request errors (the whole call failed)
These come back in the response’s top-level errors array.
| Code | Cause | Fix |
|---|---|---|
PUSH_TOO_MANY_NOTIFICATIONS |
More than 100 messages in one request | Send in batches of 100 or fewer |
TOO_MANY_REQUESTS |
Over 600 notifications a second for the project | Throttle; the Node SDK does this |
PUSH_TOO_MANY_EXPERIENCE_IDS |
Tokens from more than one Expo project in one request | Group tokens by project; details lists which belong where |
PUSH_TOO_MANY_RECEIPTS |
More than 1,000 ids in one receipts request | Ask for 1,000 or fewer at a time |
UNAUTHORIZED |
Push security is on and no valid access token was sent | Send Authorization: Bearer <token>, or accessToken in expo-server-sdk 3.6.0 and later |
Ticket errors
A ticket with "status": "error" failed before leaving Expo.
| details.error | Meaning | Fix |
|---|---|---|
DeviceNotRegistered |
The device can no longer get pushes | Delete the token |
Receipt errors
Fetch receipts with the ticket ids:
const res = await fetch("https://exp.host/--/api/v2/push/getReceipts", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids: ticketIds }),
});
const { data } = await res.json();
for (const [id, r] of Object.entries(data)) {
if (r.status === "error") console.log(id, r.details?.error, r.message);
}
| details.error | Meaning | Fix |
|---|---|---|
DeviceNotRegistered |
Apple or Google says the device is unregistered: app uninstalled, notifications revoked | Delete the token until the app registers again |
MessageTooBig |
The payload is over 4,096 bytes | Send less: an id, then fetch the rest in the app |
MessageRateExceeded |
Too many messages to one device in a short time | Back off exponentially and send more slowly to that device |
MismatchSenderId |
The FCM credentials and the google-services.json sender id are from different Firebase projects |
Use one Firebase project for both |
InvalidCredentials |
Push credentials for your standalone app are invalid or revoked | Android: upload the FCM V1 service account key again. iOS: eas credentials; rebuild if the message mentions InvalidProviderToken |
Receipts are kept 24 hours. Expo suggests checking them about 15 minutes after sending; a receipt still missing then points to a problem on Expo’s side.
A sender that handles all of it
With the official Node SDK, chunking, throttling and receipts look like this:
import { Expo } from "expo-server-sdk";
const expo = new Expo({ accessToken: process.env.EXPO_ACCESS_TOKEN });
const messages = tokens.filter(Expo.isExpoPushToken).map((to) => ({ to, title, body, data }));
const tickets = [];
for (const chunk of expo.chunkPushNotifications(messages)) tickets.push(...(await expo.sendPushNotificationsAsync(chunk)));
const dead = [];
tickets.forEach((t, i) => { if (t.status === "error" && t.details?.error === "DeviceNotRegistered") dead.push(messages[i].to); });
// 15 minutes later, from a job:
const ids = tickets.filter((t) => t.status === "ok").map((t) => t.id);
for (const chunk of expo.chunkPushNotificationReceiptIds(ids)) {
const receipts = await expo.getPushNotificationReceiptsAsync(chunk);
// handle DeviceNotRegistered, MessageTooBig, InvalidCredentials as in the table
}
Keep the token with each ticket id when you store them, so a receipt error can point back to the token to delete.
When there is no error at all
Ticket ok, receipt ok, nothing on the phone: Apple or Google accepted it and the device did not show it. On Android that is usually the notification permission on Android 13 and later, a missing channel, or a phone maker’s battery saver. See Expo push notifications not working, the Android 13 notification permission and push on Xiaomi, Oppo and Vivo.
Sending without Expo’s service
Expo’s service is free and works well for Expo apps. If you want one sender for Expo, bare React Native, Flutter, native apps and the web, SignOps Push takes your FCM and APNs credentials directly, answers a push to one user with how many of their devices it reached and how many failed, with no receipts to fetch, and removes dead tokens itself. Setup: push in React Native, Expo and Flutter.
Sources: Expo: Send notifications with the Expo Push Service, expo-server-sdk-node.