LEARN · DEBUGGING GUIDE

Expo Push Notifications Not Received: Diagnosis and Fixes

You sent a push notification via Expo but the device never shows it. This guide walks through the real reasons from token registration to server-side failures, with commands to verify each layer.

IntermediateMobile7 min read

What this usually means

The root cause is almost never a bug in Expo's push service itself. More often, it's a failure in one of three layers: (1) token registration – the device token is never obtained, stored incorrectly, or becomes invalid; (2) credential setup – the Expo project ID or access token is wrong, or the push certificate/key is missing; (3) payload format – the notification JSON doesn't match what Expo expects, especially the `to` field or `data` payload. Background delivery failures also stem from missing notification channel configurations on Android or missing capabilities on iOS. The trick is to isolate which layer fails by checking each with explicit commands.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `expo push:android:upload --api-key <your-key>` or `expo push:ios:upload` to verify credentials are uploaded correctly.
  • 2Check the Expo push token on the device: `Expo.notifications.getExpoPushTokenAsync()` and log it. Verify it starts with `ExponentPushToken[` and is not null.
  • 3Send a test notification via curl: `curl -H "Content-Type: application/json" -H "Accept: application/json" -H "Authorization: Bearer YOUR_ACCESS_TOKEN" -X POST https://exp.host/--/api/v2/push/send -d '{"to":"ExponentPushToken[xxx]","title":"test","body":"hello"}'` and inspect the response for errors.
  • 4Check the Expo push ticket response: if `status` is `error`, look at `message` and `details`. Common: "DeviceNotRegistered", "InvalidCredentials", "MessageTooBig".
  • 5On Android, verify notification channel is created (Android 8+): `NotificationChannel channel = new NotificationChannel("default", "Default", NotificationManager.IMPORTANCE_HIGH);` and set it in the push payload as `channelId`.
  • 6On iOS, check that the app has requested notification permissions: `Notifications.requestPermissionsAsync()` should resolve with `granted: true`.
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchExpo push token (log it from device): `Expo.notifications.getExpoPushTokenAsync()`
  • searchExpo push ticket response (server-side logs): check `status` and `details` fields
  • searchExpo credentials: `expo credentials:manager` – verify push key/certificate uploaded
  • searchApp.json / app.config.js: ensure `expo.notifications` config includes `icon`, `color`, `iosDisplayInForeground`, `androidMode`
  • searchAndroid `AndroidManifest.xml`: check for `<meta-data android:name="expo.modules.notifications.default_notification_channel_id" android:value="default"/>`
  • searchServer-side push sending code: inspect the request payload format against Expo's API v2 specification
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningPush token not stored or retrieved correctly – often due to async timing or missing `useEffect` cleanup
  • warningExpo project ID mismatch between development and production builds (EAS Build vs local)
  • warningiOS push certificate expired or not uploaded correctly – check `expo push:ios:upload --status`
  • warningAndroid notification channel not created before sending notification with channelId
  • warningPayload too large (Expo limit ~4KB) – truncate data or use `data` field for custom JSON
  • warningBackground notification handling missing – need to configure `remote-notification` in iOS capabilities
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildRe-register push token on app start and store it server-side with device ID for mapping
  • buildRe-upload push credentials: `expo push:android:upload --api-key YOUR_KEY` or `expo push:ios:upload`
  • buildEnsure notification channel exists before push: create channel in `useEffect` on app mount for Android
  • buildValidate payload size: keep total JSON under 4KB, avoid base64 images in `data` field
  • buildCheck Expo access token is valid (not expired) and has correct permissions (push notifications)
  • buildFor iOS background delivery: add `"remote-notification"` to `UIBackgroundModes` in Info.plist
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedSend test notification via curl and confirm receipt on device within seconds
  • verifiedCheck Expo push ticket response: `data.status` should be `ok` for each recipient
  • verifiedMonitor `/logs` endpoint (Expo push logs) to see delivery receipts: `curl -H "Authorization: Bearer TOKEN" https://exp.host/--/api/v2/push/getReceipts -d '{"ids":["ticket-id"]}'`
  • verifiedVerify token registration: log token on device and compare with stored token on server
  • verifiedTest both foreground and background states: lock screen and wait 10 seconds
  • verifiedOn Android, use `adb shell dumpsys notification` to see if notification was posted
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningAssuming Expo push always works – always check ticket status and receipt
  • warningUsing the same push token across different environments (development vs production)
  • warningNot creating a notification channel on Android 8+ and then sending a notification without `channelId`
  • warningSending notifications to expired tokens – clean up tokens that return `DeviceNotRegistered`
  • warningIgnoring iOS push certificate expiry – check with `expo push:ios:upload --status`
  • warningHardcoding the push token in code instead of fetching it dynamically
( 07 )War story

Production Expo Notifications Silent After EAS Build

Mobile LeadReact Native 0.72, Expo SDK 49, Node.js backend, EAS Build

Timeline

  1. 09:15User reports not receiving push notifications after latest app update via TestFlight
  2. 09:30Checked server logs: Expo push tickets return status 'ok' for all sends
  3. 09:45Tested on own device: notification arrives in debug build but not in TestFlight build
  4. 10:00Compared push tokens: debug build returns token starting with 'ExponentPushToken[xxx]', TestFlight build returns different token
  5. 10:15Checked Expo credentials: noticed iOS push key was uploaded for development, not production
  6. 10:30Ran `expo push:ios:upload` with production APNs key
  7. 10:45Sent test notification via curl to TestFlight token – received on device
  8. 11:00Deployed new backend mapping for new tokens and confirmed all users receive notifications

We had recently migrated to EAS Build for our iOS app. After pushing a new build to TestFlight, users started reporting that push notifications were not showing up. Our server logs showed Expo returned a 200 with ticket status 'ok' for every send, so I initially assumed it was a client-side issue. I tested on my own iPhone – the debug build installed via Expo Go worked fine, but the TestFlight build did not. That narrowed it to the build configuration.

I compared the push tokens from both builds. The debug token was `ExponentPushToken[ABC123]`, the TestFlight token was `ExponentPushToken[DEF456]`. Different tokens were expected, but the TestFlight token never resulted in a notification. I then checked our Expo credentials using `expo credentials:manager`. That's when I saw it: the iOS push key was uploaded for 'development', not 'production'. Our old build system used the same key for both, but EAS Build separates them.

I ran `expo push:ios:upload` with the production APNs key. Then I sent a test notification directly to the TestFlight token using curl and it arrived. I updated our backend to map the new tokens correctly and pushed a server update. The lesson: always verify push credentials after switching build systems, and always test with a production build before shipping. Expo's API may say 'ok', but delivery depends on the certificate matching the build environment.

Root cause

iOS push certificate uploaded for development environment while TestFlight build used production APNs.

The fix

Uploaded production APNs key via `expo push:ios:upload` and updated server-side token mapping.

The lesson

Always verify push credentials per environment (development vs production) after build system changes. Expo push tickets can be misleadingly successful.

( 08 )Expo Push Token Lifecycle and Common Pitfalls

The Expo push token is obtained by calling `Expo.notifications.getExpoPushTokenAsync()`. This token is tied to the specific device and Expo project ID. If the token is null or undefined, the most common cause is that the notification permissions have not been granted. Always call `Notifications.requestPermissionsAsync()` first and check the result.

Another common pitfall is storing the token only once at app launch. If the token changes (e.g., after app reinstall or OS update), the server will send to a stale token. Implement a token refresh listener: `Notifications.addPushTokenListener` and update your backend whenever the token changes. Also, ensure you handle async storage correctly – I've seen tokens lost because of race conditions in `useEffect`.

( 09 )Payload Format and Size Limits

Expo push API expects a JSON payload with at minimum `to` (the push token) and either `title` or `body`. The total payload must be under 4KB. Common mistakes include embedding large base64 images in the `data` field or sending deeply nested objects. Use the `data` field only for custom JSON that the app will handle, and keep it small.

Check the response ticket's `details` field for 'MessageTooBig' error. If you need to send rich media, consider using a URL in the `data` field and having the app fetch the media. Also note that Android allows a `channelId` in the payload – if omitted, the notification will use the default channel, which might have low importance.

( 10 )Background Delivery and Notification Channels

Notifications not showing when app is in background is a common complaint. On iOS, you must enable the 'remote-notification' background mode in Xcode or via app.json: `"ios": { "infoPlist": { "UIBackgroundModes": ["remote-notification"] } }`. Without this, the system may not deliver the notification payload to your app when in background.

On Android 8+, every notification must belong to a notification channel. If you send a notification without specifying a `channelId` and no default channel exists, the notification may be dropped or shown with low importance. Create a channel early in app startup: `NotificationChannel channel = new NotificationChannel("default", "Default", NotificationManager.IMPORTANCE_HIGH);`. Then include `"channelId": "default"` in your push payload.

( 11 )Server-Side Delivery Verification with Receipts

After sending a push notification via Expo's API, you get a ticket with an `id`. To verify delivery, call the receipts endpoint: `GET https://exp.host/--/api/v2/push/getReceipts` with the ticket IDs. The receipt will show `status: ok` if delivered, or `error` with details like `DeviceNotRegistered` or `InvalidCredentials`.

Poll receipts for a few seconds after sending (Expo recommends up to 30 seconds). If you see `DeviceNotRegistered`, remove that token from your database. If you see `InvalidCredentials`, your Expo access token or project ID is wrong. Always log receipts in production to catch delivery failures early.

( 12 )Environment-Specific Credentials in EAS Build

When using EAS Build, the app's credentials (iOS push key, Android FCM server key) are tied to the build profile (development, preview, production). A common mistake is uploading the push key only for development and then building a production version. Verify with `expo credentials:manager` and ensure the correct credentials are set for each profile.

Additionally, the Expo project ID in app.json must match the project on Expo.dev. If you cloned a project, the ID might be wrong. Check `app.json` or `app.config.js` for `expo.extra.eas.projectId`. Mismatched project IDs cause tokens to be rejected. Run `expo config` to see the resolved project ID.

Frequently asked questions

Why does Expo push return 200 but my device never gets the notification?

A 200 response means Expo accepted the request, but delivery depends on the push token, credentials, and device state. Check the ticket response: it may contain an error like 'DeviceNotRegistered' (token expired) or 'InvalidCredentials' (wrong API key). Use the receipts endpoint to get final delivery status. Also verify that your app has notification permissions and the correct notification channel on Android.

My push token is null on Android. What's wrong?

On Android, push token can be null if Google Play Services are missing or outdated. Ensure the device has Google Play Services installed. Also, if you're using Expo Go, push tokens are not supported for standalone apps. For standalone builds, make sure you have uploaded your FCM server key via `expo push:android:upload --api-key YOUR_KEY`. Finally, call `getExpoPushTokenAsync()` after permissions are granted.

How do I handle expired push tokens?

When you receive a 'DeviceNotRegistered' error from Expo's push API or receipt, remove that token from your database immediately. Implement a cleanup routine that periodically re-validates tokens by sending a silent test notification and removing tokens that return errors. Also, listen for token changes on the device using `addPushTokenListener` and update your server.

Can I send notifications to both iOS and Android with the same payload?

Yes, Expo's API accepts the same payload format for both platforms. However, Android requires a `channelId` for notifications to appear on Android 8+. iOS may require `sound` to be a string (not `default` on some devices). Use platform-specific keys in the payload if needed, but a simple `{ to, title, body, data }` works for both. Test on each platform separately.