Skip to content

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.

PushBy Rushi Patel, Senior iOS developerUpdated 2 November 20265 min read

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.

Frequently asked questions

More questions? Email us and the answer goes on this page.

hello@signops.dev
How do I send a test push notification to an iPhone from the terminal?

Make a JWT from your APNs .p8 key with openssl (header with alg ES256 and your Key ID, claims with your Team ID and the time), then POST the payload with curl --http2 to api.sandbox.push.apple.com at the path /3/device/ followed by the device token, with the authorization, apns-topic and apns-push-type headers. A 200 with an empty body means APNs accepted it.

Should I use api.sandbox.push.apple.com or api.push.apple.com?

A build you run from Xcode gets a sandbox (development) token, so use api.sandbox.push.apple.com. TestFlight and App Store builds get production tokens, which only work with api.push.apple.com. Sending to the wrong one returns 400 BadDeviceToken.

Do I need a certificate to send APNs pushes with curl?

No. A token-based .p8 key works for every app on your team, in both environments, and does not expire each year like a .p12 certificate. Apple also documents the certificate route, with --cert and --key, if you already have one.

Why does curl say the HTTP/2 protocol is not supported?

APNs only speaks HTTP/2. Your curl must be built with HTTP/2 support (nghttp2). The curl that ships with current macOS has it; on older Linux distributions install a newer curl or check curl --version for HTTP2 in the Features line.

Send your first push today.

Free for 1,000 push devices, 100 approvals and 1,000 passkey sign-ins a month, and 500 email codes to try. No card until you pass a free amount.