How to Test iOS Push Notifications Without a Server

By Alok Singh · Published

You don't need a backend to work on push notifications. Depending on what you're testing, there's a faster tool:

You want to test… Use Touches real APNs?
How your app handles a payload (UI, actions, deep links, badge) xcrun simctl push or drag-and-drop an .apns file onto Simulator No
That a real device token, key/certificate and payload work end to end Apple's Push Notifications Console (web) Yes
The exact HTTP/2 request your server will send curl with token-based auth (.p8) Yes
Certificate-based sends from a GUI A Mac app such as Push Notification - Tester Yes

This guide walks through each method, then gives copy-paste payloads and a table of the APNs errors you're most likely to see.


Contents

  1. Prerequisites: capability and device token
  2. Method 1: xcrun simctl push with an .apns file
  3. Method 2: Drag and drop onto Simulator
  4. Method 3: Apple's Push Notifications Console
  5. Method 4: curl + token-based auth (.p8)
  6. Method 5: A Mac app with a certificate (Push Notification - Tester)
  7. Payload cookbook
  8. Common errors and fixes
  9. Checklist

1. Prerequisites

For any real push (methods 3–5):

  1. In Xcode, open Signing & Capabilities, add Push Notifications, and also Background Modes › Remote notifications if you'll send silent pushes.
  2. Ask for permission and register:
import UIKit
import UserNotifications

final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        Task {
            let granted = try? await UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .badge, .sound])
            if granted == true {
                await MainActor.run { application.registerForRemoteNotifications() }
            }
        }
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
        print("APNs device token: \(token)")   // copy this for testing
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("APNs registration failed: \(error)")
    }

    // Show banners even while the app is in the foreground.
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
        [.banner, .list, .sound, .badge]
    }
}

In SwiftUI, attach it with @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate.

Environment matters. Builds you run from Xcode with a development profile get sandbox tokens, which you send to api.sandbox.push.apple.com. TestFlight and App Store builds get production tokens, which go to api.push.apple.com. Mixing them up is the number-one cause of BadDeviceToken.

No app of your own yet? If you're a backend developer, Push Notification - Client (free, iPhone/iPad) is a ready-made receiver. It shows the device token, bundle ID and certificate you need, so you can test your server code against it.


2. Method 1: xcrun simctl push with an .apns file

Since Xcode 11.4, Simulator can receive simulated remote notifications from the command line. Nothing goes through APNs, so you need no certificate, key or device token. That makes it ideal for iterating on how your app presents and handles a payload.

Create welcome.apns (it's just JSON):

{
  "Simulator Target Bundle": "com.example.MyApp",
  "aps": {
    "alert": {
      "title": "Welcome back",
      "subtitle": "Your order shipped",
      "body": "Tap to track it."
    },
    "badge": 1,
    "sound": "default"
  },
  "orderID": "A1234"
}

Send it to the booted simulator:

# Bundle ID taken from "Simulator Target Bundle" inside the file
xcrun simctl push booted welcome.apns

# …or pass the bundle ID explicitly (then the key isn't needed)
xcrun simctl push booted com.example.MyApp welcome.apns

# …or pipe JSON from stdin
echo '{"aps":{"alert":"Hello from stdin"}}' | xcrun simctl push booted com.example.MyApp -

booted targets the running simulator. With several simulators booted, use a UDID from xcrun simctl list devices booted.

Things to know: - The app must be installed on that simulator. Run it once from Xcode. - The notification permission prompt still applies. Grant it first, or you'll see nothing. - Custom keys (like orderID above) arrive in userInfo, so you can test deep-link routing. - Simulated pushes don't test your token, certificate, key or server. Use methods 3–5 for those. - Service extensions (mutable-content) and background (content-available) handling have behaved differently in simulated pushes across Xcode versions. Confirm those flows with a real APNs push.

Real APNs pushes on Simulator

Starting with Xcode 14, Simulator on macOS 13 or later on a Mac with Apple silicon or a T2 chip can register for real remote notifications. The simulator gets a real (sandbox) device token, and you can send to it with methods 3–5, exactly like a device.


3. Method 2: Drag and drop onto Simulator

Same file, no Terminal: drag a .apns file onto the Simulator window. The file must contain the "Simulator Target Bundle" key so Simulator knows which app to deliver it to.

A handy habit is to keep a PushPayloads/ folder in your repo with one .apns file per notification type (order-shipped.apns, chat-message.apns, silent-refresh.apns…). That gives QA and designers one-drag reproductions of every notification.


4. Method 3: Apple's Push Notifications Console

Apple's web-based Push Notifications Console sends real notifications through APNs with nothing to install: https://icloud.developer.apple.com/dashboard/notifications

Sign in with your Apple Developer account and pick the app. Then:

  1. Choose Development or Production. This must match where the token came from.
  2. Paste the device token.
  3. Set the push type (alert, background, …), priority and expiration.
  4. Build the payload in the editor, or paste raw JSON.
  5. Send.

What makes it worth bookmarking: - Delivery log: for the development environment, you can look up a notification's delivery status. APNs returns an apns-unique-id header (development only) that you can search for in the console. - History: notifications sent from the console are kept for 30 days and are visible to your whole team. - Token tools: check whether a device token is valid, and generate a JWT for token-based auth. - Get cURL Command: turns the request you built into a ready-made curl command, including headers, authentication and payload. That's a good bridge to method 4. - Broadcast channels: create and test channels for Live Activity broadcast pushes.


5. Method 4: curl with token-based auth (.p8)

This is the closest you can get to what your server will do, and it needs only the tools that ship with macOS.

5.1 Create the key

In Certificates, Identifiers & Profiles › Keys, create a key with Apple Push Notifications service (APNs) enabled, and download the .p8 file (you can only download it once). Note the Key ID and your Team ID.

Unlike certificates, a token signing key doesn't expire yearly. Depending on how you configure it, one key can be used for multiple apps in your team.

5.2 Build a JWT and send

This follows the steps in Apple's Sending push notifications using command-line tools:

# --- fill these in ---
TEAM_ID="ABCDE12345"
AUTH_KEY_ID="XYZ987WVU6"
TOKEN_KEY_FILE_NAME="$HOME/keys/AuthKey_XYZ987WVU6.p8"
TOPIC="com.example.MyApp"              # your bundle ID
DEVICE_TOKEN="<hex device token>"
APNS_HOST_NAME="api.sandbox.push.apple.com"   # api.push.apple.com for production tokens

# --- build the JWT (ES256) ---
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}"

# --- send ---
curl -v \
  --header "apns-topic: ${TOPIC}" \
  --header "apns-push-type: alert" \
  --header "apns-priority: 10" \
  --header "authorization: bearer ${AUTHENTICATION_TOKEN}" \
  --data '{"aps":{"alert":{"title":"Hello","body":"Sent with curl"},"sound":"default"}}' \
  --http2 "https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}"

An HTTP 200 means APNs accepted the notification. That isn't the same as "displayed": the device may be offline, notifications may be disabled, or Focus may hold it back.

To send a payload from a file, use --data @payload.json.

5.3 Headers that matter

Header Values / notes
apns-push-type Must match the payload: alert, background, voip, liveactivity, location, widgets, complication, fileprovider, mdm, pushtotalk…
apns-topic Usually the bundle ID. VoIP uses <bundle>.voip; Live Activities use <bundle>.push-type.liveactivity
apns-priority 10 = send immediately (alerts). 5 = power-aware (required for background). 1 = lowest
apns-expiration UNIX epoch seconds; 0 = try once, don't store
apns-collapse-id Newer notifications with the same ID replace older ones (max 64 bytes)
apns-id Optional UUID; APNs echoes it back (handy in logs)

5.4 JWT rules


6. Method 5: A Mac app with a certificate

If your project still uses certificate-based APNs authentication (a .p12 or .pem push certificate), or you just want a GUI, a desktop sender is quicker than juggling openssl flags.

Push Notification - Tester is a free Mac app (macOS 14.6+) I built for this:

  1. Install your APNs push certificate in Keychain.
  2. Open the app, select the certificate, paste the device token and your JSON payload.
  3. Choose Development (sandbox) or Production, then send.

It's useful for checking that a certificate is valid, that a token belongs to the right environment, and that a payload renders the way you expect, without a server. (It uses certificates from your Keychain. For .p8 token auth, use the console or curl above.)

Pair it with Push Notification - Client on an iPhone if you need a receiving app that shows you its token and bundle ID.

If you prefer the command line for certificates, Apple's command-line guide shows the curl form:

curl -v \
  --header "apns-topic: ${TOPIC}" \
  --header "apns-push-type: alert" \
  --cert "${CERTIFICATE_FILE_NAME}" --cert-type DER \
  --key "${CERTIFICATE_KEY_FILE_NAME}" --key-type PEM \
  --data '{"aps":{"alert":"test"}}' \
  --http2 "https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}"

7. Payload cookbook

All payloads are JSON with an aps dictionary; your own keys go outside aps. Regular notifications can be at most 4 KB (VoIP: 5 KB).

To use one with simctl or drag-and-drop, add "Simulator Target Bundle": "com.example.MyApp" at the top level.

Alert (title, subtitle, body)

{
  "aps": {
    "alert": { "title": "Order shipped", "subtitle": "#A1234", "body": "Arrives Thursday." },
    "sound": "default"
  }
}

Badge only (and clearing it)

{ "aps": { "badge": 5 } }
{ "aps": { "badge": 0 } }

Sound

{ "aps": { "alert": "Timer finished", "sound": "chime.caf" } }

The sound file must be in your app bundle or Library/Sounds, and no longer than 30 seconds. Critical alerts use a dictionary, "sound": { "critical": 1, "name": "default", "volume": 1.0 }, and need a special entitlement from Apple.

Mutable content (Notification Service Extension)

{
  "aps": {
    "alert": { "title": "New photo", "body": "Alex shared a photo" },
    "mutable-content": 1
  },
  "image-url": "https://example.com/p/123.jpg"
}

mutable-content: 1 lets your service extension change the content (for example, download and attach the image) before it's shown. The extension gets about 30 seconds. The payload must include an alert, or the extension won't run.

Background / silent (content-available)

{
  "aps": { "content-available": 1 },
  "sync": "inbox"
}

Send it with apns-push-type: background and apns-priority: 5, and don't include alert, badge or sound. The system wakes the app in application(_:didReceiveRemoteNotification:fetchCompletionHandler:). Delivery is throttled and not guaranteed: avoid sending more than a few per hour, and don't expect them after the user force-quits the app.

Category (actionable notification)

{
  "aps": {
    "alert": { "title": "Meeting invite", "body": "Design review at 3 PM" },
    "category": "MEETING_INVITE"
  },
  "meetingID": "m-42"
}

Register the category in the app first:

let accept = UNNotificationAction(identifier: "ACCEPT", title: "Accept", options: [.foreground])
let decline = UNNotificationAction(identifier: "DECLINE", title: "Decline", options: [.destructive])
let category = UNNotificationCategory(identifier: "MEETING_INVITE",
                                      actions: [accept, decline],
                                      intentIdentifiers: [])
UNUserNotificationCenter.current().setNotificationCategories([category])

Thread grouping, interruption level and relevance (iOS 15+)

{
  "aps": {
    "alert": { "title": "Gate change", "body": "Flight 212 now boards at B14" },
    "thread-id": "trip-ams",
    "interruption-level": "time-sensitive",
    "relevance-score": 0.9,
    "sound": "default"
  }
}

interruption-level is one of passive, active (the default), time-sensitive (requires the Time Sensitive Notifications capability) or critical (requires Apple's entitlement).


8. Common errors and fixes

APNs returns an HTTP status and a JSON body like {"reason":"BadDeviceToken"}.

Status reason Usual cause → fix
400 BadDeviceToken Sandbox token sent to production (or the reverse), or a malformed token. Match the host to the build type, and send hex with no spaces or <>
400 DeviceTokenNotForTopic apns-topic isn't the bundle ID that produced the token
400 MissingTopic / BadTopic / TopicDisallowed Topic header missing or wrong (required with multi-topic certificates)
400 InvalidPushType / BadPriority Header value is invalid, e.g. a background push sent with priority 10
400 PayloadEmpty / BadCollapseId / BadExpirationDate Fix the corresponding body or header
403 InvalidProviderToken Wrong Key ID or Team ID, a key without APNs enabled, a skewed clock, or a broken signature
403 ExpiredProviderToken JWT older than 1 hour; mint a new one
403 MissingProviderToken No authorization header and no client certificate
403 BadCertificate / BadCertificateEnvironment Certificate invalid, or a sandbox certificate used against production (or the reverse)
403 BadEnvironmentKeyIdInToken The key's environment doesn't match the host you're calling
410 Unregistered / ExpiredToken Token no longer valid for this app (e.g. the app was deleted). Stop sending to it
413 PayloadTooLarge Over 4 KB (5 KB for VoIP); trim the payload
429 TooManyProviderTokenUpdates You're minting JWTs too often; cache for at least 20 minutes
429 TooManyRequests Too many pushes to one device in quick succession
500 / 503 InternalServerError / ServiceUnavailable / Shutdown Retry with backoff

Got 200 but nothing appears? Check: - Notification permission was granted, and Focus/Do Not Disturb isn't on. - The app is in the foreground with no willPresent delegate. iOS hides foreground banners by default. - A background push won't show anything, by design. - The device is online, and in Low Power Mode background pushes are deferred. - In the Push Notifications Console, look up the apns-unique-id in the delivery log (development only).


9. Checklist


Further reading (Apple)

Written by Alok Singh, iOS engineer and developer of Push Notification - Tester (Mac) and Push Notification - Client (iPhone). Corrections welcome: aryansbtloe.business@gmail.com.


← All developer guides