DEV Community

Alok Singh
Alok Singh

Posted on

How to Test iOS Push Notifications on a Real Device (APNs .p8 Keys, Sandbox vs Production, and the Errors You'll Hit)

The Simulator is great for checking how your app handles a notification. Drag an .apns file onto it, or run xcrun simctl push, and the banner appears. But that never touches Apple's servers, so it can't catch the problems that actually break push in production: a wrong environment, a topic that doesn't match your bundle ID, or an auth key that isn't allowed to send.

This guide walks through sending a real push to a real iPhone through APNs, with nothing but Node.js and a .p8 key. No third-party service, no backend.

1. Get a device token

Add the Push Notifications capability to your target in Xcode (Signing & Capabilities), then register and log the token:

import UIKit
import UserNotifications

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
            guard granted else { return }
            DispatchQueue.main.async { 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)")
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        print("Registration failed: \(error)")
    }
}
Enter fullscreen mode Exit fullscreen mode

If you're using SwiftUI, hook it up with @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate. Run on a physical device and copy the hex string from the console.

Remember where this token came from. A build you run from Xcode gets a sandbox (development) token. TestFlight and App Store builds get production tokens. They are not interchangeable, and this is the cause of most push bugs.

2. Create a .p8 auth key

In your Apple Developer account, go to Certificates, Identifiers & Profiles > Keys, create a key and enable Apple Push Notifications service (APNs). When you configure it, Apple asks for an environment (Sandbox, Production, or Sandbox & Production). Pick Sandbox & Production unless you have a reason not to, because you can't change it later.

Download the AuthKey_XXXXXXXXXX.p8 file (you only get one chance) and note:

  • the Key ID (10 characters, shown next to the key)
  • your Team ID (under Membership details)

Unlike the old .p12 certificates, a .p8 key doesn't expire and can sign for every app in your team.

3. Write the payload

Save this as payload.json:

{
  "aps": {
    "alert": {
      "title": "Order shipped",
      "body": "Your package is on its way."
    },
    "badge": 1,
    "sound": "default"
  },
  "orderId": "A1234"
}
Enter fullscreen mode Exit fullscreen mode

Everything Apple cares about lives inside aps. Custom keys like orderId go next to it and arrive in your app's userInfo. The whole payload must stay under 4 KB.

For a silent (background) push, drop the alert, badge and sound and use {"aps": {"content-available": 1}}. You'll also need to change two headers (see below).

4. Send it with Node.js (no dependencies)

APNs uses HTTP/2 and a short-lived JWT signed with your key. Node's built-in crypto and http2 modules handle both. Save as send-push.mjs:

// node send-push.mjs <device-token> [sandbox|production]
import { readFileSync } from 'node:fs';
import { createSign } from 'node:crypto';
import http2 from 'node:http2';

const TEAM_ID = 'ABCDE12345';
const KEY_ID = 'XYZ987WVU6';
const BUNDLE_ID = 'com.example.myapp';
const key = readFileSync('./AuthKey_XYZ987WVU6.p8', 'utf8');

const [token, env = 'sandbox'] = process.argv.slice(2);
const host = env === 'production'
  ? 'https://api.push.apple.com'
  : 'https://api.sandbox.push.apple.com';

// Provider token: a JWT signed with ES256
const b64url = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
const unsigned = `${b64url({ alg: 'ES256', kid: KEY_ID })}.${b64url({ iss: TEAM_ID, iat: Math.floor(Date.now() / 1000) })}`;
const signature = createSign('SHA256').update(unsigned)
  .sign({ key, dsaEncoding: 'ieee-p1363' }, 'base64url');
const jwt = `${unsigned}.${signature}`;

const client = http2.connect(host);
client.on('error', (err) => console.error('Connection error:', err.message));

const req = client.request({
  ':method': 'POST',
  ':path': `/3/device/${token}`,
  authorization: `bearer ${jwt}`,
  'apns-topic': BUNDLE_ID,
  'apns-push-type': 'alert',
  'apns-priority': '10',
  'content-type': 'application/json',
});

let status;
let body = '';
req.on('response', (headers) => { status = headers[':status']; });
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
  console.log(status, body || '(accepted)');
  client.close();
});
req.end(readFileSync('./payload.json'));
Enter fullscreen mode Exit fullscreen mode

Run it with the token from step 1:

node send-push.mjs 3f2a...your-token... sandbox
Enter fullscreen mode Exit fullscreen mode

A 200 with an empty body means APNs accepted the push. Lock the phone and it should arrive within a second or two.

Two details that matter:

  • dsaEncoding: 'ieee-p1363' makes Node output the raw 64-byte ES256 signature that JWTs need. Without it you get a DER signature and APNs rejects the token.
  • For a silent push, set apns-push-type: background and apns-priority: 5. Apple may throttle background pushes, and won't deliver them at all to an app the user has force-quit.

5. Sandbox vs production, in one table

Build Token type Host
Run from Xcode Sandbox api.sandbox.push.apple.com
TestFlight Production api.push.apple.com
App Store Production api.push.apple.com
Ad Hoc Production api.push.apple.com

If you install a TestFlight build over a debug build on the same phone, the token changes. Log it again.

6. The errors you'll actually see

APNs answers with an HTTP status and a JSON reason. The common ones:

  • 400 BadDeviceToken: the token is malformed, or (far more often) you sent a sandbox token to production or the other way round. Check which build produced it and switch hosts.
  • 403 BadEnvironmentKeyInToken: your .p8 key isn't allowed in this environment, usually a Sandbox-only key used against api.push.apple.com. Create a new key set to Sandbox & Production and update the Key ID and file.
  • 400 DeviceTokenNotForTopic: apns-topic doesn't match the bundle ID of the app that produced the token. Watch out for .debug suffixes on dev schemes.
  • 403 InvalidProviderToken: the Key ID, Team ID or signature is wrong, or the key was revoked.
  • 403 ExpiredProviderToken: the JWT's iat is more than an hour old. In a real server, cache the token and refresh it every 20 to 60 minutes (refreshing more often than every 20 minutes gets you 429 TooManyProviderTokenUpdates).
  • 410 Unregistered: the app was deleted or the token is no longer valid. Stop sending to it.

If APNs returns 200 but nothing shows up, check that notifications are allowed for the app in Settings, that Focus mode isn't hiding them, and that the payload is an alert push. Apple's Push Notifications Console (linked from the Push Notifications capability in Xcode) shows delivery logs for test pushes, which helps you tell whether the problem is on Apple's side or the device's.

A GUI option if you use certificates

If your project still uses a .p12 push certificate instead of a .p8 key, I built a small free Mac app for this: Push Notification รขโ‚ฌโ€œ Tester. You pick the certificate from Keychain, paste a device token, choose Development or Production, and send a custom JSON payload (alert, badge, sound or silent) with no server. It's certificate-only, so for .p8 keys the script above is the way to go.


This article was written with AI assistance and reviewed by the author.

Top comments (1)

Collapse
 
beusebiu profile image
Eusebiu Balan •

What stopped me guessing which build a token came from: the app sends the environment along with the token. It's read off the build itself (a run from Xcode has a development profile, TestFlight and App Store builds don't), and the server keeps the pair and picks the sandbox or production host per token. A tester moving from a debug build to TestFlight just updates their row.

If you get to WidgetKit pushes, the topic is the bundle ID plus .push-type.widgets, not the plain bundle ID.