Developers · API v1.0.0
Swift Integration (iOS)
This guide is for native iOS apps built in Xcode with Swift (UIKit or SwiftUI). If you use Objective-C, or want a step-by-step walkthrough of the Apple Developer / APNs setup on the iOS side, see the iOS guide. For the general API reference, see the Overview.
Supported versions
| Component | Minimum | Recommended |
|---|---|---|
| iOS (deployment target) | 13.0 | 15.0+ |
| Swift | 5.9 | 6.x |
| Xcode | 15 | latest |
Push notifications can't be reliably tested on the iOS Simulator; use a physical device. A paid Apple Developer account is required.
How does it work?
NotiPilot 1.0.0 delivers notifications through the Expo Push Service. Your native iOS app:
- Gets an APNs device token from Apple,
- Exchanges this token for an Expo Push Token via Expo's token service,
- Registers the Expo Push Token with NotiPilot using
register-device.
The Expo Push Service delivers notifications to the device through APNs; no additional SDK is needed in the app.
1. Prerequisites (one-time)
- In Apple Developer → Keys, create a key (
.p8) with Apple Push Notifications service (APNs) enabled. Note the Key ID and Team ID. - Create an EAS project with an Expo account (expo.dev → Create project) and note its project ID (UUID).
- Upload the
.p8key to your Expo project: expo.dev → Project → Credentials → iOS → Push Key (oreas credentials). Make sure the Bundle ID matches your app. - Add your app in the NotiPilot dashboard: Expo Project ID = your Expo project ID, Expo Access Token = the token you created on expo.dev, iOS Bundle ID = your app's bundle identifier.
2. Xcode settings
Target → Signing & Capabilities:
- + Capability → Push Notifications
- + Capability → Background Modes → check Remote notifications
3. NotiPilot.swift
import Foundation
import Security
import UIKit
enum NotiPilotConfig {
static let baseURL = URL(string: "https://app.notipilot.com/api/v1")!
/// "Expo Project ID" in the NotiPilot dashboard (your Expo project ID)
static let appId = "YOUR-EXPO-PROJECT-ID"
}
final class NotiPilot {
static let shared = NotiPilot()
private init() {}
// MARK: - device_uid (persisted in the Keychain)
var deviceUid: String {
if let existing = Keychain.read("notipilot_device_uid") { return existing }
let uid = UUID().uuidString.lowercased()
Keychain.save("notipilot_device_uid", uid)
return uid
}
// MARK: - Public API
/// Call from `didRegisterForRemoteNotificationsWithDeviceToken`.
@discardableResult
func registerDevice(apnsToken: Data, extraAttributes: [String: Any?] = [:]) async throws -> [String: Any] {
let hexToken = apnsToken.map { String(format: "%02x", $0) }.joined()
let expoToken = try await exchangeForExpoToken(apnsHexToken: hexToken)
var attributes: [String: Any] = [
"locale": Locale.current.languageCode ?? NSNull(), // "tr"
"country": Locale.current.regionCode ?? NSNull(), // "TR"
"app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? NSNull(),
"os_version": UIDevice.current.systemVersion,
]
for (key, value) in extraAttributes { attributes[key] = value ?? NSNull() }
return try await post("register-device", body: [
"app_id": NotiPilotConfig.appId,
"device_uid": deviceUid,
"token": expoToken,
"platform": "ios",
"provider": "expo",
"attributes": attributes,
])
}
/// Call when the user signs in.
@discardableResult
func identify(externalId: String, attributes: [String: Any]? = nil) async throws -> [String: Any] {
var body: [String: Any] = [
"app_id": NotiPilotConfig.appId,
"device_uid": deviceUid,
"external_id": externalId,
]
if let attributes { body["attributes"] = attributes }
return try await post("identify-device", body: body)
}
// MARK: - Expo token exchange
private func exchangeForExpoToken(apnsHexToken: String) async throws -> String {
#if DEBUG
let development = true // Debug builds use the APNs sandbox environment
#else
let development = false
#endif
var request = URLRequest(url: URL(string: "https://exp.host/--/api/v2/push/getExpoPushToken")!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: [
"type": "apns",
"deviceId": deviceUid,
"development": development,
"appId": Bundle.main.bundleIdentifier ?? "",
"deviceToken": apnsHexToken,
"projectId": NotiPilotConfig.appId,
])
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200,
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let payload = json["data"] as? [String: Any],
let token = payload["expoPushToken"] as? String
else {
throw NSError(domain: "NotiPilot", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Expo token exchange failed: \(String(decoding: data, as: UTF8.self))"])
}
return token // "ExponentPushToken[...]"
}
// MARK: - HTTP
private func post(_ path: String, body: [String: Any], attempt: Int = 0) async throws -> [String: Any] {
var request = URLRequest(url: NotiPilotConfig.baseURL.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
let json = (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]
if (status == 429 || status >= 500) && attempt < 3 {
let wait = (json["retry_after"] as? Double) ?? pow(2, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
return try await post(path, body: body, attempt: attempt + 1)
}
if !(200..<300).contains(status) {
print("[NotiPilot] \(status) \(json["error"] ?? "") \(json["errors"] ?? json["message"] ?? "")")
}
return json
}
}
// MARK: - Minimal Keychain helper
enum Keychain {
static func save(_ key: String, _ value: String) {
let base: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key]
SecItemDelete(base as CFDictionary)
var item = base
item[kSecValueData as String] = Data(value.utf8)
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
SecItemAdd(item as CFDictionary, nil)
}
static func read(_ key: String) -> String? {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
}
4. AppDelegate.swift
import UIKit
import UserNotifications
@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
UNUserNotificationCenter.current().delegate = self
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
guard granted else { return }
DispatchQueue.main.async { application.registerForRemoteNotifications() }
}
return true
}
// APNs token received (called again on every launch and whenever the token changes)
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Task {
do {
try await NotiPilot.shared.registerDevice(apnsToken: deviceToken,
extraAttributes: ["city": "Istanbul"])
} catch {
print("[NotiPilot] register failed: \(error)")
}
}
}
func application(_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error) {
print("[NotiPilot] APNs registration failed: \(error)")
}
// Show the notification while the app is in the foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
[.banner, .list, .sound]
}
// Notification tapped
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
let data = NotiPilotPayload.customData(from: response.notification.request.content.userInfo)
if let screen = data["screen"] as? String {
// navigation: e.g. screen == "product" → data["product_id"]
print("Open screen: \(screen)")
}
}
}
enum NotiPilotPayload {
/// Custom `data` sent from the dashboard is delivered by Expo under the `body` key.
static func customData(from userInfo: [AnyHashable: Any]) -> [String: Any] {
if let dict = userInfo["body"] as? [String: Any] { return dict }
if let string = userInfo["body"] as? String,
let data = string.data(using: .utf8),
let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return dict }
return [:]
}
}
If you use SwiftUI
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene { WindowGroup { ContentView() } }
}
In this case, remove the @main attribute from the AppDelegate class.
5. Sign-in / sign-out
// After sign-in
Task { try? await NotiPilot.shared.identify(externalId: user.id, attributes: ["gender": "female"]) }
On sign-out, to remove the external_id match, call register-device again with "external_id": NSNull() added to the request.
Checklist
- APNs
.p8key uploaded to the Expo project - Expo Project ID in the dashboard =
NotiPilotConfig.appId - iOS Bundle ID in the dashboard = the app's bundle identifier
- Push Notifications + Background Modes → Remote notifications enabled
-
development = truein debug builds,falsein App Store/TestFlight builds -
countryand, if possible,cityare sent as attributes
Common issues
| Symptom | Solution |
|---|---|
didRegisterForRemoteNotifications is never called |
Check the Push Notifications capability and provisioning profile; use a physical device. |
Token exchange returns 4xx |
Check the projectId and appId (bundle ID) values. |
| Notifications work in debug but not in TestFlight (or vice versa) | The development value points to the wrong environment. |
404 unknown_app |
The app_id is not registered in the dashboard. |