Developers · API v1.0.0

iOS Integration (Setup and Objective-C)

This guide walks you through the Apple-side setup for native iOS apps step by step and includes Objective-C sample code. If you use Swift, follow the setup steps here and then switch to the Swift guide for the code. For the general API reference, see the Overview.

Supported versions

Component Minimum Recommended
iOS (deployment target) 13.0 15.0+
Xcode 15 latest
Objective-C ARC enabled –

Requirements: A paid Apple Developer Program membership and a physical iPhone/iPad (push testing on the simulator is not reliable).

How does it work?

NotiPilot 1.0.0 delivers notifications through the Expo Push Service:

  1. The app gets an APNs device token from Apple.
  2. This token is exchanged for an Expo Push Token via the Expo token service.
  3. The Expo Push Token is registered with NotiPilot using register-device.
  4. Notifications sent from the dashboard travel Expo → APNs → device.

Your app doesn't need to be built with Expo; Expo is used only as the delivery infrastructure.

1. Apple Developer setup

1.1 App ID and Push Notifications

  1. Open your app's App ID on the developer.apple.com → Identifiers page (create one if it doesn't exist).
  2. In the Capabilities list, check Push Notifications and save.
  3. Regenerate your provisioning profiles (this happens automatically if you use Xcode's "Automatically manage signing").

1.2 APNs Auth Key (.p8)

  1. Go to developer.apple.com → Keys and click + to create a new key.
  2. Check Apple Push Notifications service (APNs).
  3. Download the .p8 file (it can only be downloaded once, so store it securely). Note the Key ID and your account's Team ID.

A single .p8 key works for all apps in your account and for both the sandbox and production environments. You don't need legacy .p12 certificates.

1.3 Expo project

  1. Create a project on expo.dev and note its project ID (UUID).
  2. On expo.dev → Project → Credentials → iOS, add your Bundle ID, then upload the .p8 file along with the Key ID and Team ID under Push Key.

1.4 NotiPilot dashboard

Add your app: Expo Project ID = your Expo project ID, Expo Access Token = expo.dev → Access Tokens, iOS Bundle ID = your app's bundle identifier.

2. Xcode settings

Target → Signing & Capabilities:

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

This adds the aps-environment key to your .entitlements file. In debug builds the value is development (APNs sandbox); in TestFlight/App Store builds it is production.

3. Objective-C code

3.1 NotiPilot.h

Objective-C
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NotiPilot : NSObject

+ (instancetype)shared;

/// Call from application:didRegisterForRemoteNotificationsWithDeviceToken:.
- (void)registerDeviceWithAPNsToken:(NSData *)deviceToken
                    extraAttributes:(nullable NSDictionary<NSString *, id> *)extra
                         completion:(nullable void (^)(NSDictionary *_Nullable response, NSError *_Nullable error))completion;

/// Call when the user signs in.
- (void)identifyWithExternalId:(NSString *)externalId
                    attributes:(nullable NSDictionary<NSString *, id> *)attributes
                    completion:(nullable void (^)(NSDictionary *_Nullable response, NSError *_Nullable error))completion;

@end

NS_ASSUME_NONNULL_END

3.2 NotiPilot.m

Objective-C
#import "NotiPilot.h"
#import <Security/Security.h>
#import <UIKit/UIKit.h>

static NSString *const kBaseURL = @"https://app.notipilot.com/api/v1";
static NSString *const kAppId   = @"YOUR-EXPO-PROJECT-ID"; // "Expo Project ID" in the dashboard
static NSString *const kUidKey  = @"notipilot_device_uid";

@implementation NotiPilot

+ (instancetype)shared {
    static NotiPilot *instance;
    static dispatch_once_t once;
    dispatch_once(&once, ^{ instance = [NotiPilot new]; });
    return instance;
}

#pragma mark - device_uid (Keychain)

- (NSString *)deviceUid {
    NSDictionary *query = @{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
                             (__bridge id)kSecAttrAccount: kUidKey,
                             (__bridge id)kSecReturnData: @YES,
                             (__bridge id)kSecMatchLimit: (__bridge id)kSecMatchLimitOne };
    CFTypeRef result = NULL;
    if (SecItemCopyMatching((__bridge CFDictionaryRef)query, &result) == errSecSuccess && result) {
        NSData *data = (__bridge_transfer NSData *)result;
        return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
    }

    NSString *uid = [[NSUUID UUID].UUIDString lowercaseString];
    NSDictionary *item = @{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
                            (__bridge id)kSecAttrAccount: kUidKey,
                            (__bridge id)kSecValueData: [uid dataUsingEncoding:NSUTF8StringEncoding],
                            (__bridge id)kSecAttrAccessible: (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly };
    SecItemAdd((__bridge CFDictionaryRef)item, NULL);
    return uid;
}

#pragma mark - Public API

- (void)registerDeviceWithAPNsToken:(NSData *)deviceToken
                    extraAttributes:(NSDictionary<NSString *, id> *)extra
                         completion:(void (^)(NSDictionary *, NSError *))completion {
    NSMutableString *hex = [NSMutableString stringWithCapacity:deviceToken.length * 2];
    const unsigned char *bytes = deviceToken.bytes;
    for (NSUInteger i = 0; i < deviceToken.length; i++) [hex appendFormat:@"%02x", bytes[i]];

    [self exchangeAPNsToken:hex completion:^(NSString *expoToken, NSError *error) {
        if (!expoToken) { if (completion) completion(nil, error); return; }

        NSMutableDictionary *attributes = [@{
            @"locale": [NSLocale currentLocale].languageCode ?: [NSNull null],
            @"country": [NSLocale currentLocale].countryCode ?: [NSNull null],
            @"app_version": [NSBundle mainBundle].infoDictionary[@"CFBundleShortVersionString"] ?: [NSNull null],
            @"os_version": [UIDevice currentDevice].systemVersion,
        } mutableCopy];
        if (extra) [attributes addEntriesFromDictionary:extra];

        [self post:@"register-device" body:@{
            @"app_id": kAppId,
            @"device_uid": [self deviceUid],
            @"token": expoToken,
            @"platform": @"ios",
            @"provider": @"expo",
            @"attributes": attributes,
        } attempt:0 completion:completion];
    }];
}

- (void)identifyWithExternalId:(NSString *)externalId
                    attributes:(NSDictionary<NSString *, id> *)attributes
                    completion:(void (^)(NSDictionary *, NSError *))completion {
    NSMutableDictionary *body = [@{ @"app_id": kAppId,
                                    @"device_uid": [self deviceUid],
                                    @"external_id": externalId } mutableCopy];
    if (attributes) body[@"attributes"] = attributes;
    [self post:@"identify-device" body:body attempt:0 completion:completion];
}

#pragma mark - Expo token exchange

- (void)exchangeAPNsToken:(NSString *)hexToken completion:(void (^)(NSString *, NSError *))completion {
#if DEBUG
    BOOL development = YES;  // Debug builds use the APNs sandbox
#else
    BOOL development = NO;
#endif
    NSDictionary *body = @{ @"type": @"apns",
                            @"deviceId": [self deviceUid],
                            @"development": @(development),
                            @"appId": [NSBundle mainBundle].bundleIdentifier ?: @"",
                            @"deviceToken": hexToken,
                            @"projectId": kAppId };

    NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:
        [NSURL URLWithString:@"https://exp.host/--/api/v2/push/getExpoPushToken"]];
    req.HTTPMethod = @"POST";
    [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    req.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    [[[NSURLSession sharedSession] dataTaskWithRequest:req
                                     completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
        NSInteger status = [(NSHTTPURLResponse *)resp statusCode];
        NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
        NSString *token = [json isKindOfClass:NSDictionary.class] ? json[@"data"][@"expoPushToken"] : nil;
        if (status == 200 && [token isKindOfClass:NSString.class]) {
            completion(token, nil);
        } else {
            completion(nil, err ?: [NSError errorWithDomain:@"NotiPilot" code:status userInfo:@{
                NSLocalizedDescriptionKey: @"Expo token exchange failed" }]);
        }
    }] resume];
}

#pragma mark - HTTP

- (void)post:(NSString *)path body:(NSDictionary *)body attempt:(NSInteger)attempt
  completion:(void (^)(NSDictionary *, NSError *))completion {
    NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:
        [NSURL URLWithString:[NSString stringWithFormat:@"%@/%@", kBaseURL, path]]];
    req.HTTPMethod = @"POST";
    [req setValue:@"application/json" forHTTPHeaderField:@"Content-Type"];
    [req setValue:@"application/json" forHTTPHeaderField:@"Accept"];
    req.HTTPBody = [NSJSONSerialization dataWithJSONObject:body options:0 error:nil];

    [[[NSURLSession sharedSession] dataTaskWithRequest:req
                                     completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
        NSInteger status = [(NSHTTPURLResponse *)resp statusCode];
        NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;

        if ((status == 429 || status >= 500) && attempt < 3) {
            double wait = [json[@"retry_after"] doubleValue] ?: pow(2, attempt);
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(wait * NSEC_PER_SEC)),
                           dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
                [self post:path body:body attempt:attempt + 1 completion:completion];
            });
            return;
        }
        if (completion) completion(json, err);
    }] resume];
}

@end

3.3 AppDelegate.m

Objective-C
#import "AppDelegate.h"
#import "NotiPilot.h"
#import <UserNotifications/UserNotifications.h>

@interface AppDelegate () <UNUserNotificationCenterDelegate>
@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
    center.delegate = self;
    [center requestAuthorizationWithOptions:(UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge)
                          completionHandler:^(BOOL granted, NSError *error) {
        if (granted) {
            dispatch_async(dispatch_get_main_queue(), ^{ [application registerForRemoteNotifications]; });
        }
    }];
    return YES;
}

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
    [[NotiPilot shared] registerDeviceWithAPNsToken:deviceToken
                                    extraAttributes:@{ @"city": @"Istanbul" }
                                         completion:^(NSDictionary *res, NSError *err) {
        if (err) NSLog(@"[NotiPilot] register failed: %@", err);
    }];
}

- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
    NSLog(@"[NotiPilot] APNs registration failed: %@", error);
}

// Show the notification while the app is in the foreground
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
    if (@available(iOS 14.0, *)) {
        completionHandler(UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList | UNNotificationPresentationOptionSound);
    } else {
        completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionSound);
    }
}

// Notification tapped — custom data sent from the dashboard arrives under the `body` key
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
         withCompletionHandler:(void (^)(void))completionHandler {
    id raw = response.notification.request.content.userInfo[@"body"];
    NSDictionary *data = nil;
    if ([raw isKindOfClass:NSDictionary.class]) {
        data = raw;
    } else if ([raw isKindOfClass:NSString.class]) {
        data = [NSJSONSerialization JSONObjectWithData:[raw dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
    }
    NSString *screen = data[@"screen"];
    // Navigate based on screen
    completionHandler();
}

@end

When the user signs in:

Objective-C
[[NotiPilot shared] identifyWithExternalId:user.userId attributes:nil completion:nil];

Checklist

  • Push Notifications capability is enabled on the App ID
  • APNs .p8 key uploaded to the Expo project with Key ID + Team ID
  • Expo Project ID in the dashboard = kAppId, iOS Bundle ID = the app's bundle identifier
  • Push Notifications + Background Modes → Remote notifications enabled in Xcode
  • development = YES in debug builds, NO for TestFlight/App Store
  • 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.
no valid "aps-environment" entitlement error The provisioning profile wasn't regenerated after adding the capability.
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.