Send a test push to an iPhone with curl
Make an APNs JWT from your .p8 key with openssl and send an alert or a background push with curl over HTTP/2, with every header and the errors you may see.
Before you write server code, check the key, the token and the bundle id with one curl call. If this works, anything that fails later is in your code; if it fails, the reason APNs returns tells you which of the three is wrong. These commands follow Apple’s own guide to sending pushes from the command line.
What you need
| Value | Where to find it |
|---|---|
.p8 key file |
Apple Developer > Certificates, Identifiers & Profiles > Keys, a key with Apple Push Notifications service enabled. It downloads once |
| Key ID | 10 characters, on the key’s page and in the file name AuthKey_<KEYID>.p8 |
| Team ID | 10 characters, top right of the developer account, or Membership details |
| Bundle id | The app’s identifier in Xcode, such as com.example.app; used as apns-topic |
| Device token | Hex string from didRegisterForRemoteNotificationsWithDeviceToken in the app |
The iOS setup guide shows each of these screens.
1. Set the variables
TEAM_ID=ABCDE12345
AUTH_KEY_ID=XYZ9876543
TOKEN_KEY_FILE_NAME=./AuthKey_XYZ9876543.p8
TOPIC=com.example.app
DEVICE_TOKEN=4f2a...e91c
APNS_HOST_NAME=api.sandbox.push.apple.com # api.push.apple.com for TestFlight and App Store builds
2. Make the JWT
The token is a JWT signed with ES256: a header with your Key ID, claims with your Team ID and the time it was made.
JWT_ISSUE_TIME=$(date +%s)
JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}"
JWT_SIGNED_HEADER_CLAIMS=$(printf "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d =)
AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}"
The token is good for an hour. Reuse it for every test in that hour; Apple refuses a new one more often than every 20 minutes.
3. Send
curl -v --http2 \
--header "authorization: bearer ${AUTHENTICATION_TOKEN}" \
--header "apns-topic: ${TOPIC}" \
--header "apns-push-type: alert" \
--header "apns-priority: 10" \
--data '{"aps":{"alert":{"title":"Test","body":"Sent with curl"},"sound":"default"}}' \
"https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}"
A 200 with an empty body and an apns-id header means Apple accepted it. The banner shows if the app is in the background; in the foreground the app decides through userNotificationCenter(_:willPresent:).
A silent (background) push
curl -v --http2 \
--header "authorization: bearer ${AUTHENTICATION_TOKEN}" \
--header "apns-topic: ${TOPIC}" \
--header "apns-push-type: background" \
--header "apns-priority: 5" \
--data '{"aps":{"content-available":1},"order_id":"88213"}' \
"https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}"
Background pushes must use priority 5, and the app needs the Remote notifications background mode. iOS limits how many it wakes the app for, so a silent push is never a guaranteed delivery.
The headers
| Header | Value |
|---|---|
authorization |
bearer <JWT> |
apns-topic |
The bundle id; add .voip, .complication or .push-type.liveactivity for those types |
apns-push-type |
alert, background, voip, liveactivity, location, complication, fileprovider, mdm. Must match the payload |
apns-priority |
10 now, 5 when power allows, 1 never wake the device. Default 10 |
apns-expiration |
Unix seconds until which APNs keeps trying; 0 means one attempt, not stored |
apns-collapse-id |
Up to 64 bytes; a newer push with the same id replaces the older one |
The payload is at most 4,096 bytes (5,120 for VoIP). Port 2197 works instead of 443 if your firewall needs it.
When it fails
APNs answers with a JSON reason. The ones a curl test usually hits:
| Response | Meaning |
|---|---|
400 BadDeviceToken |
Sandbox token sent to production or the reverse, or the token has spaces or <> |
400 DeviceTokenNotForTopic |
apns-topic is not the app’s bundle id |
403 InvalidProviderToken |
Key ID or Team ID wrong, or the key was revoked |
403 ExpiredProviderToken |
The JWT is over an hour old; make a new one |
400 MissingTopic |
No apns-topic header |
410 Unregistered |
The app was deleted from the phone |
Every reason, with the fix and whether to retry, is in APNs error codes.
From curl to your server
The same three steps in code: make the JWT once and cache it for about 50 minutes, keep one HTTP/2 connection open, and read the reason of every failure to delete dead tokens. SignOps Push does that with your .p8 key: it tries production and falls back to sandbox for development tokens, removes tokens Apple calls dead, retries 429 and 5xx, and sends to Android and the web from the same call.
Sources: Apple: Sending push notifications using command-line tools, Apple: Sending notification requests to APNs, Apple: Establishing a token-based connection to APNs.