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)")
}
}
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"
}
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'));
Run it with the token from step 1:
node send-push.mjs 3f2a...your-token... sandbox
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: backgroundandapns-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
.p8key isn't allowed in this environment, usually a Sandbox-only key used againstapi.push.apple.com. Create a new key set to Sandbox & Production and update the Key ID and file. -
400 DeviceTokenNotForTopic:
apns-topicdoesn't match the bundle ID of the app that produced the token. Watch out for.debugsuffixes on dev schemes. - 403 InvalidProviderToken: the Key ID, Team ID or signature is wrong, or the key was revoked.
-
403 ExpiredProviderToken: the JWT's
iatis 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)
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.