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:

  1. Gets an APNs device token from Apple,
  2. Exchanges this token for an Expo Push Token via Expo's token service,
  3. 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)

  1. In Apple Developer → Keys, create a key (.p8) with Apple Push Notifications service (APNs) enabled. Note the Key ID and Team ID.
  2. Create an EAS project with an Expo account (expo.dev → Create project) and note its project ID (UUID).
  3. Upload the .p8 key to your Expo project: expo.dev → Project → Credentials → iOS → Push Key (or eas credentials). Make sure the Bundle ID matches your app.
  4. 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:

  1. + Capability → Push Notifications
  2. + Capability → Background Modes → check Remote notifications

3. NotiPilot.swift

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

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

Swift
@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

Swift
// 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 .p8 key 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 = true in debug builds, false in App Store/TestFlight builds
  • country and, if possible, city are 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.