axiom-push-notifications-diag
Push Notification Diagnostics
Systematic troubleshooting for push notification failures: missing notifications, token registration errors, environment mismatches, silent push throttling, and service extension problems.
Overview
Core Principle: When push notifications don't work, the problem is usually:
- Token/registration failures (never registered, wrong format, expired) — 30%
- Entitlement/provisioning mismatch (capability missing, wrong environment) — 25%
- Payload structure errors (missing keys, wrong types, invalid JSON) — 15%
- Focus/interruption suppression (iOS 15+ filtering, provisional auth) — 15%
- Service extension failures (timeout, crash, missing mutable-content) — 10%
- Delivery timing/throttling (silent push budget, APNs coalescing) — 5%
Always verify entitlements and token registration BEFORE debugging payload or delivery logic.
Red Flags
Symptoms that indicate push-specific issues:
| Symptom | Likely Cause |
|---|---|
| No notifications at all | Missing Push Notification capability or provisioning profile |
| Works in dev, not production | Sending to sandbox APNs with production token (or vice versa) |
| Token registration fails on Simulator | Expected — Simulator cannot register for remote notifications |
| Notifications appear without sound | Missing .sound in authorization options or payload |
| Rich notification shows plain text | Missing mutable-content: 1 in payload |
| Image not showing in notification | Service extension failed silently — check serviceExtensionTimeWillExpire |
| Silent push not waking app | System throttling (~2-3/hour), or app was force-quit by user |
| Notifications stopped after iOS update | Focus mode enabled by default in iOS 15+; check interruption level |
| Badge shows wrong number | Multiple notifications sent without explicit badge count reset |
| Actions not appearing | Category identifier mismatch between payload and registered categories |
| Notification appears twice | Both local and remote notification scheduled for same event |
| FCM works on Android, not iOS | Missing APNs auth key upload in Firebase Console |
Anti-Rationalization
| Rationalization | Why It Fails | Time Cost |
|---|---|---|
| "It worked yesterday, so entitlements are fine" | Provisioning profiles get regenerated during signing changes. Always re-verify. | 30-60 min debugging code when the profile lost push capability |
| "The server says their payload is fine" | 55% of push failures are client-side (entitlements + tokens). Verify independently with curl. | 1-2 hours of finger-pointing before someone checks |
| "I'll skip token verification, the error is clearly in the payload" | Wrong-environment tokens are the #1 cause of "works in dev, not production." | 30+ min debugging valid payloads sent to invalid tokens |
| "Focus mode doesn't matter, we use default interruption level" | Default (active) is filtered by Focus. Only time-sensitive and critical break through. |
Hours adding code workarounds for a payload-level fix |
| "Silent push is reliable, we use it for sync" | System throttles to ~2-3/hour and ignores force-quit apps. It's a hint, not a guarantee. | Architecture rework when silent push can't sustain real-time sync |
| "Service extension is set up, so rich notifications should work" | Extension needs correct bundle ID suffix, mutable-content in payload, AND completing within 30s. | 30+ min when any one of the three prerequisites is missing |
| "FCM handles everything, I don't need to understand APNs" | FCM wraps APNs. Token type confusion, missing p8 key upload, and swizzling conflicts are all APNs-level problems. | Hours debugging FCM when the issue is APNs configuration |
| "I'll test on Simulator first" | Simulator cannot register for remote notifications. No APNs token = no real push testing. | Wasted test cycle discovering Simulator limitations |
| "Let me rewrite the notification handler" | 80% of push failures are configuration (entitlements, tokens, environment), not code. | Hours rewriting working code while the config stays broken |
| "This worked on iOS 17, the bug must be in our code" | Each iOS version changes Focus defaults, interruption filtering, and provisional behavior. | Debugging code when the fix is a payload or Settings change |
Mandatory First Steps
Before investigating code, run these diagnostics:
Step 1: Verify Push Notification Entitlements
security cms -D -i path/to/embedded.mobileprovision | grep -A1 "aps-environment"
Expected output:
- ✅
<string>development</string>or<string>production</string>→ Entitlement present - ❌ No aps-environment key → Push Notifications capability not enabled in Xcode
How to find the provisioning profile:
# For installed app on device
find ~/Library/Developer/Xcode/DerivedData -name "embedded.mobileprovision" -newer . 2>/dev/null | head -3
Step 2: Check Token Registration
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
print("✅ APNs token: \(token)")
print("✅ Token length: \(token.count) chars")
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("❌ Registration failed: \(error.localizedDescription)")
}
Expected output:
- ✅ 64-character hex token → Registration successful
- ❌ "no valid aps-environment entitlement" → Capability misconfigured
- ❌ No callback fires at all →
registerForRemoteNotifications()never called
Critical: Both callbacks must be in AppDelegate, not SceneDelegate. SwiftUI apps need @UIApplicationDelegateAdaptor.
Step 3: Validate Payload with curl
curl -v \
--header "apns-topic: com.your.bundle.id" \
--header "apns-push-type: alert" \
--header "authorization: bearer $JWT_TOKEN" \
--data '{"aps":{"alert":{"title":"Test","body":"Hello"}}}' \
--http2 https://api.sandbox.push.apple.com/3/device/$DEVICE_TOKEN
Expected output:
- ✅ HTTP/2 200 → Payload accepted by APNs
- ❌ 400 BadDeviceToken → Token format wrong or expired
- ❌ 403 ExpiredProviderToken → JWT older than 1 hour
- ❌ 403 InvalidProviderToken → Wrong key ID, team ID, or key
- ❌ 410 Unregistered → App uninstalled or token invalidated
- ❌ 413 PayloadTooLarge → Exceeds 4096 bytes
Step 4: Check Authorization Status
let settings = await UNUserNotificationCenter.current().notificationSettings()
print("Authorization: \(settings.authorizationStatus.rawValue)")
print("Alert: \(settings.alertSetting.rawValue)")
print("Sound: \(settings.soundSetting.rawValue)")
print("Badge: \(settings.badgeSetting.rawValue)")
Expected output:
- ✅ authorizationStatus = 2 → Authorized
- ⚠️ authorizationStatus = 3 → Provisional (appears silently in Notification Center)
- ❌ authorizationStatus = 1 → Denied by user
- ❌ authorizationStatus = 0 → Not determined (never requested)
Decision Trees
Tree 1: Not Receiving Any Notifications
Not receiving any notifications?
│
├─ Check Step 1 (entitlements)
│ ├─ No aps-environment key?
│ │ └─ Enable Push Notifications in Signing & Capabilities → DONE
│ └─ aps-environment present → continue
│
├─ Check Step 2 (token registration)
│ ├─ didFailToRegister called?
│ │ ├─ "no valid aps-environment" → Regenerate provisioning profile
│ │ └─ Other error → Check network, device (not Simulator)
│ ├─ Neither callback fires?
│ │ └─ Verify registerForRemoteNotifications() called after app launch
│ └─ Token received → continue
│
├─ Check Step 3 (payload delivery)
│ ├─ HTTP 200 but no notification?
│ │ └─ Check Step 4 (authorization status)
│ ├─ 400 BadDeviceToken?
│ │ └─ Token expired or wrong environment → Re-register
│ └─ 403/410 error?
│ └─ Fix auth credentials or re-register device
│
└─ Check Step 4 (user authorization)
├─ Status: denied?
│ └─ User must enable in Settings → Show settings prompt
├─ Status: notDetermined?
│ └─ Call requestAuthorization() → Was never requested
└─ Status: authorized but still no notifications?
└─ Check Focus mode, Do Not Disturb, notification grouping
Tree 2: Works in Dev, Not Production
Works in development, fails in production?
│
├─ APNs endpoint correct?
│ ├─ Dev: api.sandbox.push.apple.com
│ └─ Prod: api.push.apple.com
│ └─ Using sandbox endpoint with production build? → Switch endpoint
│
├─ Token environment matches?
│ ├─ Dev and production tokens are DIFFERENT
│ │ └─ Server storing dev token, sending to prod APNs? → Re-register on prod build
│ └─ Server distinguishes token environments? → Add environment flag to token storage
│
├─ Auth method correct?
│ ├─ .p8 key (token-based)?
│ │ └─ Same key works for both environments ✅
│ └─ .p12 certificate?
│ ├─ Dev cert → Only works with sandbox
│ └─ Prod cert → Only works with production
│ └─ Wrong cert for environment? → Generate correct certificate
│
└─ Using FCM?
├─ APNs auth key (.p8) uploaded to Firebase Console?
│ └─ Missing? → Upload in Project Settings > Cloud Messaging
└─ Key uploaded but wrong Team ID?
└─ Verify Team ID matches Apple Developer account
Tree 3: Silent Notifications Not Waking App
Silent push not waking app?
│
├─ Payload correct?
│ ├─ Has "content-available": 1 in aps?
│ │ └─ Missing? → Add to aps dictionary
│ ├─ Has NO "alert", "badge", or "sound" in aps?
│ │ └─ Has alert? → Not a silent push; system treats as visible notification
│ └─ Payload valid → continue
│
├─ Headers correct?
│ ├─ apns-push-type: background?
│ │ └─ Missing or wrong? → Must be "background" for silent push
│ └─ apns-priority: 5?
│ └─ Using 10? → Silent push MUST use priority 5
│
├─ Background mode enabled?
│ ├─ "Remote notifications" checked in Background Modes capability?
│ │ └─ Missing? → Enable in Signing & Capabilities
│ └─ application(_:didReceiveRemoteNotification:fetchCompletionHandler:) implemented?
│ └─ Missing? → Implement the delegate method
│
├─ App state?
│ ├─ Force-quit by user (swiped up)?
│ │ └─ System will NOT wake force-quit apps for silent push
│ └─ Suspended or background?
│ └─ Should wake — continue debugging
│
└─ System throttling?
├─ Budget: ~2-3 silent pushes per hour
│ └─ Exceeding? → Reduce frequency, batch updates
└─ Device in Low Power Mode?
└─ Further reduces background execution budget
Tree 4: Rich Notification Missing Media
Rich notification not showing image/video?
│
├─ Payload has mutable-content: 1?
│ └─ Missing? → Required for Notification Service Extension to fire
│
├─ Notification Service Extension target exists?
│ ├─ Missing? → File > New > Target > Notification Service Extension
│ └─ Exists → continue
│
├─ Extension bundle ID correct?
│ ├─ Must be: {app-bundle-id}.{extension-name}
│ │ Example: com.myapp.NotificationService
│ └─ Wrong prefix? → Fix bundle ID to match parent app
│
├─ Download completing in time?
│ ├─ Extension has ~30 seconds to modify notification
│ │ └─ Large file? → Use thumbnail URL, not full resolution
│ └─ serviceExtensionTimeWillExpire called?
│ └─ Must deliver bestAttemptContent with fallback text
│
├─ Attachment created correctly?
│ ├─ File written to disk before creating UNNotificationAttachment?
│ │ └─ Must write to tmp directory, then create attachment from file URL
│ └─ File type supported?
│ ├─ Images: JPEG, GIF, PNG (max 10MB)
│ ├─ Audio: AIFF, WAV, MP3, M4A (max 5MB)
│ └─ Video: MPEG, MPEG-2, MP4, AVI (max 50MB)
│
└─ App groups configured?
└─ Extension and app share data via App Groups?
└─ Missing? → Add same App Group to both targets
Tree 5: Live Activity Not Updating via Push
Live Activity not updating from push?
│
├─ APNs topic correct?
│ ├─ Must be: {bundleID}.push-type.liveactivity
│ │ └─ Using plain bundle ID? → Append .push-type.liveactivity
│ └─ Topic correct → continue
│
├─ Push type header correct?
│ ├─ apns-push-type: liveactivity?
│ │ └─ Using "alert"? → Must be "liveactivity"
│ └─ Correct → continue
│
├─ Content-state matches ActivityAttributes.ContentState?
│ ├─ JSON keys match Swift property names exactly?
│ │ └─ Mismatch? → Decoding fails silently
│ └─ Using custom CodingKeys or JSONEncoder strategies?
│ └─ Custom strategies NOT supported — use default key encoding
│
├─ Push token being sent to server?
│ ├─ Observing pushTokenUpdates on the Activity?
│ │ └─ Missing? → Must iterate Activity.pushTokenUpdates async sequence
│ └─ Token changes when Activity restarts?
│ └─ Must handle token rotation — send updated token to server
│
└─ Rate limiting?
├─ Frequent updates: ~10-12 per hour per Activity
│ └─ Exceeding? → Batch updates, reduce frequency
└─ Alert updates (sound/vibration): ~3-4 per hour
└─ Exceeding? → Reserve alerts for critical state changes
Tree 6: Notifications Stopped After iOS Update
Notifications stopped working after iOS update?
│
├─ Focus mode auto-enabled? (iOS 15+)
│ ├─ Check Settings > Focus
│ │ └─ Focus active? → App may not be in allowed list
│ └─ No Focus active → continue
│
├─ Interruption level filtering?
│ ├─ Default level is .active (may be filtered by Focus)
│ │ └─ Need to break through Focus? → Use .timeSensitive or .critical
│ ├─ .timeSensitive requires capability
│ │ └─ Missing? → Add Time Sensitive Notifications capability
│ └─ .critical requires Apple entitlement
│ └─ Only for health/safety/security apps — apply via Apple Developer
│
├─ Provisional authorization behavior changed?
│ ├─ iOS 15+ provisional notifications appear in Notification Summary
│ │ └─ User may not see them → Request full authorization
│ └─ Was relying on provisional? → Prompt for explicit permission
│
└─ Communication notifications require INSendMessageIntent?
├─ iOS 15+ communication notifications need SiriKit intent
│ └─ Missing? → Donate INSendMessageIntent before showing notification
└─ Intent donated but still filtered?
└─ Check that sender is in user's contacts
Push Notification Console Workflow
Apple's Push Notification Console provides server-free testing:
Navigate to Console
- Open https://icloud.developer.apple.com/dashboard
- Select "Push Notifications" from sidebar
- Choose your app's bundle ID
Send Test Notification
- Enter device token (from Step 2 diagnostic)
- Select environment (Sandbox/Production)
- Compose payload or use template
- Send and observe delivery status
Check Delivery Logs
- Copy
apns-idfrom the response header of your push request - Use Push Notification Console to look up delivery status by
apns-id - Status shows: accepted, delivered, dropped (with reason)
Common Console Findings
| Status | Meaning | Action |
|---|---|---|
| Delivered | APNs delivered to device | Problem is on-device (auth, Focus, extension) |
| Dropped: Unregistered | Token invalid | Re-register device |
| Dropped: DeviceTokenNotForTopic | Bundle ID mismatch | Fix apns-topic header |
| Stored | Device offline, will deliver later | Wait or check device connectivity |
Simulator Testing with simctl
Simulators cannot register for remote notifications, but you can test notification handling:
cat > test-push.apns << 'EOF'
{
"Simulator Target Bundle": "com.your.bundle.id",
"aps": {
"alert": {
"title": "Test",
"body": "Hello from simctl"
},
"sound": "default",
"mutable-content": 1
}
}
EOF
xcrun simctl push booted com.your.bundle.id test-push.apns
Simulator Limitations
- ✅ Notification appearance and content
- ✅ Notification Service Extension processing
- ✅ Notification Content Extension (custom UI)
- ✅ Action handling and categories
- ❌ APNs token registration (always fails)
- ❌ Silent push waking app accurately
- ❌ Live Activity push updates
Drag-and-Drop Alternative
Drag a .apns file directly onto the Simulator window to deliver it. Requires "Simulator Target Bundle" key in the payload.
Common FCM Diagnostics
Swizzling Conflict
Symptom: Token callback not firing with Firebase
Cause: Method swizzling disabled but manual forwarding not implemented
Diagnostic:
// Check Info.plist
// FirebaseAppDelegateProxyEnabled = NO means YOU must forward tokens
Fix (if swizzling disabled):
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
Token Mismatch
Symptom: Server has FCM token, but APNs delivery fails
Cause: FCM token and APNs token are different. FCM wraps APNs token.
Diagnostic:
// FCM token (send this to YOUR server)
Messaging.messaging().token { token, error in
print("FCM token: \(token ?? "nil")")
}
// APNs token (FCM handles this internally)
// Do NOT send raw APNs token to your server when using FCM
Missing APNs Key in Firebase Console
Symptom: FCM works on Android, notifications not arriving on iOS
Fix:
- Firebase Console → Project Settings → Cloud Messaging
- Upload APNs Authentication Key (.p8)
- Enter Key ID and Team ID
- Verify bundle ID matches your app
Quick Reference Table
| Symptom | Check | Fix |
|---|---|---|
| No notifications at all | Step 1: entitlements | Enable Push Notification capability, regenerate profile |
| Token registration fails | Step 2: callbacks | Implement both delegate methods in AppDelegate |
| 400 BadDeviceToken | Token format | Re-register; check hex encoding (no spaces, no angle brackets) |
| 403 InvalidProviderToken | JWT/certificate | Regenerate JWT; verify key ID, team ID, bundle ID |
| 410 Unregistered | Device state | App uninstalled or token rotated — remove from server |
| Works dev not prod | Step 3: curl both | Switch APNs endpoint; tokens differ per environment |
| Silent push ignored | Payload + headers | content-available: 1, push-type: background, priority: 5 |
| Rich media missing | Extension | Add mutable-content: 1, check extension bundle ID and timeout |
| Live Activity stale | Topic format | Use {bundleID}.push-type.liveactivity topic |
| Focus mode filtering | Interruption level | Use .timeSensitive for important notifications |
| FCM iOS failure | Firebase Console | Upload .p8 key with correct Key ID and Team ID |
| Actions not showing | Category ID | Match category identifier in payload to registered categories |
Pressure Scenarios
Scenario 1: "Server team says the problem is on the iOS side"
Context: Push notifications stopped working. The backend team says their payload is fine and the problem must be in the app.
Pressure: Skip client-side diagnostics and assume the server is right. Start rewriting notification handling code.
Reality: 55% of push failures are entitlement/token issues (Steps 1-2), not code bugs. The server may be sending to the wrong environment or using an expired token.
Correct action: Run all 4 mandatory diagnostic steps before touching code. Share the curl test (Step 3) results with the server team — this objectively proves which side has the issue.
Push-back template: "Let me verify the client-side chain first — I can share the curl results in 5 minutes so we both know exactly where the failure is."
Scenario 2: "Notifications stopped after iOS update, ship a fix today"
Context: Users report notifications stopped working after updating to a new iOS version. Management wants a hotfix today.
Pressure: Start debugging notification code immediately. Assume Apple broke something.
Reality: New iOS versions often enable Focus mode by default or change interruption level filtering. 15% of push failures are Focus/interruption suppression — no code change needed on your side.
Correct action: Check Tree 6 ("Notifications stopped after iOS update"). Verify Focus mode settings on test devices before changing any code. If Focus is filtering, the fix is setting the correct interruption-level in the payload, not rewriting notification handling.
Push-back template: "iOS updates often change Focus mode defaults. Let me check interruption levels first — if that's the cause, the fix is a one-line payload change, not a code rewrite."
Scenario 3: "Silent push worked last week, nothing changed"
Context: Background content sync via silent push stopped working. "We didn't change anything."
Pressure: Deep-dive into background execution code. Assume a regression.
Reality: Silent push has a system-enforced throttle budget (~2-3/hour). If usage increased, or if users force-quit the app, silent push stops working regardless of code quality. Also, the provisioning profile may have been regenerated without the push entitlement.
Correct action: Follow Tree 3 ("Silent notifications not waking app"). Check throttle budget, force-quit state, and entitlements before debugging code.
Push-back template: "Silent push has a system throttle budget. Let me verify we haven't exceeded it and that the app hasn't been force-quit on test devices — those are the two most common causes."
Checklist
Before escalating push notification issues:
- Push Notification capability enabled in Xcode (Step 1)
- Provisioning profile contains aps-environment (Step 1)
- Token registration callback fires with 64-char hex token (Step 2)
- curl to APNs returns HTTP/2 200 (Step 3)
- User authorized notifications, status = 2 (Step 4)
- APNs environment matches build type (sandbox/production)
- Focus mode not filtering notifications on test device
- Tested on physical device (not Simulator for token registration)
- For FCM: APNs auth key uploaded to Firebase Console
- For silent push: background mode enabled, priority 5, no alert keys
Resources
WWDC: 2021-10091, 2023-10025, 2023-10185
Docs: /usernotifications, /usernotifications/testing-notifications-using-the-push-notification-console
Skills: axiom-push-notifications, axiom-push-notifications-ref, axiom-extensions-widgets