Push to iPhone and iPad.
Create an APNs key in your Apple Developer account, upload it once, and register device tokens from your app. Notify does the sending, retries and pruning.
1
Create an APNs key
- In Certificates, Identifiers & Profiles › Keys, add a key, tick Apple Push Notifications service (APNs) and download the
.p8file. Apple lets you download it only once. - Note the Key ID (10 characters, next to the key) and your Team ID (top right of the developer site).
- One token-based key works for every app of the team, in both the sandbox and production, and does not expire. Use the older .p12 certificates? Notify only takes .p8 keys.
Apple’s own walkthrough: Establishing a token-based connection to APNs.
2
Upload it
In the console: Notify › Channels › iOS push. Or from a script with notify.credential.manage. Uploading a key for the same bundle id replaces the old one; device tokens stay valid.
import { readFileSync } from "node:fs";
import { Notify } from "@productcraft/notify";
const notify = new Notify();
// Validated against Apple before it is stored; a wrong key is a 422
// CREDENTIAL_INVALID and nothing is saved. Stored encrypted, never returned.
const credential = await notify.channels.apns.uploadCredential({
key_id: "ABC123DEFG", // shown next to the key in your Apple Developer account
team_id: "9XYZ8W7V6U", // your Apple developer team id
bundle_id: "com.example.app",
environment: "both", // debug builds (sandbox) and TestFlight/App Store (production)
p8: readFileSync("AuthKey_ABC123DEFG.p8", "utf8"),
});
console.log(credential.fingerprint); // all the console ever shows again3
Enable push in the app
- In Xcode, add the Push Notifications capability (it writes the
aps-environmententitlement) and, for data-only pushes, Background Modes › Remote notifications. - Register on every launch and send the token every time: tokens change after a reinstall or restore, and Notify updates
last_seen_atso active devices are never reaped.
import UIKit
import UserNotifications
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ app: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
// Ask from a moment that makes sense to the user (not at first launch, ideally).
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
guard granted else { return }
DispatchQueue.main.async { app.registerForRemoteNotifications() }
}
return true
}
// Called on every launch after registration: send the token every time.
func application(_: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
#if DEBUG
let environment = "sandbox" // Xcode builds talk to the APNs sandbox
#else
let environment = "production" // TestFlight and App Store builds
#endif
Task { try? await NotifyAPI.registerDevice(token: token, environment: environment) }
}
}
enum NotifyAPI {
/// Registers through the client API with the recipient token your backend minted.
static func registerDevice(token: String, environment: String) async throws {
var req = URLRequest(url: URL(string: "https://api.notify.productcraft.co/v1/client/devices")!)
req.httpMethod = "POST"
req.setValue("Bearer \(try await Session.notifyToken())", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: [
"channel": "apns",
"token": token,
"app_id": Bundle.main.bundleIdentifier!,
"environment": environment,
"timezone": TimeZone.current.identifier,
])
let (data, _) = try await URLSession.shared.data(for: req)
// Keep the device id: DELETE /v1/client/devices/{id} on logout.
let device = try JSONSerialization.jsonObject(with: data) as? [String: Any]
UserDefaults.standard.set(device?["id"] as? String, forKey: "notifyDeviceId")
}
}The app calls the client API with a recipient token your backend minted (as in getting started, step 4). Native apps send no browser Origin, so no origin needs to be allowed. If your app already sends tokens to your own backend, register from there instead:
await notify.recipients.devices.create("user_123", {
channel: "apns",
token: "f1c2…", // hex string from didRegisterForRemoteNotificationsWithDeviceToken
app_id: "com.example.app", // the bundle id the key sends to
environment: "sandbox", // "production" for TestFlight and App Store builds
timezone: "Europe/Lisbon",
});4
Sandbox or production
A build run from Xcode gets a sandbox token; TestFlight and App Store builds get production tokens. A token sent to the wrong APNs environment is rejected with BadDeviceToken, and Notify marks the device invalid (shown in the console for 7 days) instead of pruning it. Register every token with the right environment, as the #if DEBUG above does, and upload the key as both so one key covers both.
5
Templates: rich, critical and badges
image_urlsendsmutable-content: 1; your app needs a Notification Service Extension that downloads and attaches the image.- Types marked
criticalgo out with thetime-sensitiveinterruption level, which breaks through Focus when the user allows it (add the Time Sensitive Notifications capability). badge: "unread_count"sets the app icon badge to the inbox’s unread count.apns_overridesis merged into Apple’s payload last, for anything else.
await notify.notificationTypes.replaceByKey("order.shipped", {
name: "Order shipped",
category_key: "orders",
routing: { always: ["inbox"], fallback: [{ channel: "push" }, { channel: "email" }] },
templates: [
{ channel: "inbox", locale: "en", body: { title: "Your order is on its way", body: "Tracking: {{tracking}}" } },
{
channel: "push",
locale: "en",
body: {
title: "Your order is on its way",
body: "Arrives {{eta}}",
url: "https://app.example.com/orders/{{order_id}}",
image_url: "https://cdn.example.com/products/{{sku}}.jpg", // rich push: needs a Notification Service Extension
badge: "unread_count", // the app icon shows the inbox's unread count
thread_id: "orders",
apns_overrides: { aps: { "relevance-score": 0.8 } },
},
},
{ channel: "email", locale: "en", body: { mail_template_name: "order-shipped" } },
],
});6
What happens after a send
- Apple answers 200: the delivery is
sentwith Apple’sapns-id(look it up in Apple’s Push Notifications Console). Apple never reports display. - The app was uninstalled: Apple answers 410 and Notify prunes the device (
device.prunedwebhook). - Your key is revoked: delivery fails as
credential_error, iOS push pauses, the workspace owners get one email and acredential.invalidwebhook fires. Upload a new key and it resumes.