Push notifications have a particularly unpleasant failure mode: the part of your system that reports success is not the part that decides whether the message was delivered.
Your smoke test says configured. Your send function says attempted. APNs quietly refuses and nobody tells you.
I have hit two versions of this, and both cost more time than they should have because neither surfaces an error anywhere you would normally look.
One: the bundle ID is case-sensitive
APNs uses the bundle identifier as the message topic, and it matches byte-exactly. Device tokens are registered under whatever bundle the installed app declares.
Mismatch by a single capital letter and you get:
HTTP 400 | {"reason":"DeviceTokenNotForTopic"}
This catches people because Android package names are conventionally lowercase, and it is natural to assume the two platforms share one identifier. They do not. iOS bundle IDs and Android package names are separate namespaces and can legitimately differ.
One of my apps is genuinely asymmetric, and for a good reason:
| Where | Value |
|---|---|
| iOS bundle | com.GroupFoodApp.GroupFood |
| Android package | com.groupfoodapp.groupfood |
APNS_BUNDLE_ID | com.GroupFoodApp.GroupFood |
The camelCase iOS bundle exists because it matches an existing App Store listing. Changing it would have shipped users a brand-new app rather than an in-place update, which is a far worse outcome than an unusual-looking identifier.
I got this wrong during setup in exactly the way you would expect: I saw the Xcode project using lowercase and "corrected" the server to match. The Xcode project was the thing that was wrong — the legacy store listing was camelCase, and everything on the iOS side had to converge to it instead.
The check, before you go anywhere near stale tokens or your .p8:
# What the server sends
grep '^APNS_BUNDLE_ID=' /path/to/api/.env
# What the app actually declares — the source of truth
grep PRODUCT_BUNDLE_IDENTIFIER mobile/ios/YourApp.xcodeproj/project.pbxproj
Those two strings must be identical, including case. If they are not, that is your bug, and it is a one-line fix.
Two: sandbox versus production, defaulting wrong
The second failure is worse, because it works perfectly in development and breaks the moment real users exist.
APNs has two endpoints. api.sandbox.push.apple.com serves local Xcode debug installs. api.push.apple.com serves everything else — and critically, that includes TestFlight. People expect TestFlight to be "not production yet" and it is not, for APNs purposes: a TestFlight build registers production tokens.
Send a production token to sandbox and you get:
HTTP 400 | {"reason":"BadDeviceToken"}
My own bug was the other direction of the same confusion: the service defaulted to production, sandbox was opt-in via an environment variable, and that is the right default — but a server left configured for sandbox silently drops every push to every real phone.
I found this because reminders stopped arriving over a weekend. Not because anything reported an error.
Why both of these are silent
This is the part worth internalizing, because it is the reason both cost days rather than minutes.
A rejected push is not a thrown exception. APNs accepts your HTTPS request, processes it, and answers 400 with a JSON reason. The HTTP call succeeded. The connection was fine. The certificate was fine.
So code shaped like this:
$ok = $this->sendApns($token, $payload); // logs failures to debug.log
return ['status' => 'Push: attempted']; // ...but reports success anyway
reports success for a push that was refused, because the only thing it checked was whether the call threw. The real status code went to a log nobody reads during a test.
If your push test button says "sent" and your phone says nothing, the test button is measuring the wrong thing. Surface the APNs status and reason to whatever you are looking at. Everything after that is easy.
The order to check things in
When pushes are not arriving:
- Read the actual APNs response. Status and reason. Most of the time this ends the investigation on its own.
DeviceTokenNotForTopic→ compareAPNS_BUNDLE_IDtoPRODUCT_BUNDLE_IDENTIFIERcharacter by character.BadDeviceToken→ check which endpoint you are using against where the token came from. TestFlight is production.- Only then start looking at token freshness, the
.p8, key IDs and team IDs — which is where most people start, and where neither of these bugs lives.
Both failures present identically from the outside: your code says it worked, the phone stays quiet. The reason string is the only thing that distinguishes them, and it is sitting right there in a response you are probably discarding.