Troubleshooting
Server
| Symptom | Likely cause | Fix |
|---|---|---|
| API exits immediately at boot | Redis unreachable | The API refuses to start without a queue. Check REDIS_URL; docker compose ps redis. |
/health returns 503, API still up | MongoDB unreachable | Intentional — it retries in the background. Check MONGO_URL, the mongo container. |
docker compose up fails with set ENCRYPTION_KEY in .env | Secrets missing | bun run generate:keys >> .env and remove the empty placeholders. |
/v1/admin/* answers 404 | ADMIN_API_TOKEN unset or the dashboard's copy differs | Set the same value on both; restart the API. |
Dashboard: ADMIN_PASSWORD_HASH is not set or login always fails | Hash pasted raw ($argon2id…) and a .env parser expanded the $ signs | Paste the base64 line exactly as generate:admin-password printed it. |
| Dashboard login works, then every page redirects to login | Cookie dropped over plain HTTP | INSECURE_COOKIES=true for localhost; HTTPS in production. |
Campaign stuck in sending | Worker not running, or a platform with no credentials | docker compose logs worker; check the report's failure table. |
| Scheduled campaign never fired | Redis restarted without persistence | Compose sets appendonly yes; if you run your own Redis, enable AOF or RDB. |
| Queue grows, Redis memory climbs | Worker down / starved, or allkeys-lru | Start workers; policy must be noeviction. |
Every push on one platform fails with InvalidProviderToken / PERMISSION_DENIED | Wrong or revoked credential | Re-upload; press Test connection. |
SENDER_ID_MISMATCH on many Android devices | Tokens from a different Firebase project than the service account | Use the service account of the project the app's google-services.json belongs to. |
Rate limited (429) on subscribe during an import | Per-IP cap of 10/min | Raise SUBSCRIBE_RATE_LIMIT temporarily or run the import from the API host. |
iOS
| Symptom | Likely cause | Fix |
|---|---|---|
| Permission granted, device never appears in the dashboard | didRegisterForRemoteNotificationsWithDeviceToken not forwarded | Add the AppDelegate callback (Swift, RN). |
BadDeviceToken for every send | Sandbox/production mismatch | Debug builds → Production APNs off; App Store/TestFlight → on. Or two apps, one per environment. |
DeviceTokenNotForTopic | Bundle ID on the credentials page differs from the app's | Match them exactly. |
TopicDisallowed / 403 on test | App ID lacks the Push Notifications capability, or key made without APNs | Enable the capability in the portal; recreate the key with APNs ticked. |
| Delivered stays at 0 %, opens still count | No Notification Service Extension | Add the NSE target with the template and OpenNotificationApiUrl. |
| Image never shows | Same — NSE missing, or the image URL is not HTTPS | |
| Buttons do not appear | NSE missing (it registers the category) | |
| Notification shows nothing while the app is open | willPresent not forwarded | Forward it and complete with [.banner, .sound, .badge]. |
| Works on device, not on simulator | Simulator has no real APNs token | Test on a device. |
Android
| Symptom | Likely cause | Fix |
|---|---|---|
| No token / device never registers | google-services.json missing or wrong package name; plugin not applied | Compare applicationId with the Firebase app; apply com.google.gms.google-services. |
| Grey square instead of an icon | Coloured or non-transparent small icon | Monochrome white-on-transparent ic_notification.png. |
| No notification on Android 13+ | POST_NOTIFICATIONS not granted | Call requestPermission(activity); declare the permission. |
| Arrives with app open, not in background | RN: the native module is not linked (old build) | Rebuild the Android app so autolinking picks up @opennotification/react-native/android. |
| Delivered when app is open, silent when force-stopped | OS behaviour for data messages on force-stopped apps / OEM battery saver | Expected; priority: high is already set. Ask users to exempt the app from battery optimisation on Xiaomi/Oppo/vivo. |
Two FirebaseMessagingServices | Another SDK also registers one | Forward onMessageReceived to OpenNotificationMessagingService.handle. |
UNREGISTERED immediately after install | App reinstalled; old token invalidated | Normal — the new token subscribes on next launch. |
Web
| Symptom | Likely cause | Fix |
|---|---|---|
capability().reason === "IOS_NEEDS_INSTALL" | Safari tab on iOS | Show the install guide; push only works from the Home Screen app. |
INSECURE_CONTEXT | Site over HTTP | HTTPS (localhost is exempt). |
permission_dismissed every time | subscribe() not called from a click | Call it in the click handler, synchronously after the gesture. |
service_worker_failed | /sw.js 404 or served from the wrong scope | Serve it at the root with Content-Type: application/javascript. |
api_error 401/403 on subscribe | Wrong key or CORS | Use the pk_ key; add the origin to CORS_ORIGINS. |
| Subscribed, nothing arrives | VAPID public key in the SDK differs from the app's | Copy the key from the credentials page. |
413 failures | Payload over 4 KB | Trim body/data; the wizard's byte meter shows it. |
Subscriptions die after a while (410) | Browser rotated the endpoint and the old worker did not handle pushsubscriptionchange | Use the shipped worker or registerPushHandlers. |
| Safari: no image, no buttons | Unsupported on Safari | Expected. |
"It says sent but nobody got it"
sent means the push service accepted it — the last thing the server can see. From there: the device may be offline (TTL still counting), the app force-stopped (Android), notifications disabled in system settings, or, on iOS, the app was deleted (the next send returns Unregistered and cleans up). The delivered count is the truth; compare it per platform in the report.
Getting more detail
docker compose logs -f worker | grep -E "failed|invalidated"
Logs carry subscriptionId and the provider code, never the token. Look the subscription up in the dashboard for the device facts.