# iOS SDK


`openpush-ios` is a receive-only Swift package. It registers an APNs device token with OpenPush, carries the user's identity and tags, drives Live Activities, and posts delivery receipts. It contains no message creation and no send API.

## When to use

Add this SDK to any iOS app that should receive push from OpenPush. Pair it with a Notification Service Extension — on iOS that extension is not optional decoration, it is what makes images and confirmed receipts work at all.

## Requirements

| Requirement | Value |
|---|---|
| SDK version | `1.1.5` |
| Minimum iOS | 16 |
| Swift tools | 6.0 |
| Package manager | Swift Package Manager; podspecs exist in the repository but are not published to CocoaPods trunk |
| Capabilities | Push Notifications enabled on the app target |
| Extension | A Notification Service Extension target |
| Credentials | Your app id and the app's **SDK key** (`X-OP-SDK-Key`) |
| Server | Nothing to choose — the API lives at `https://app.openpush.ai`. You do need an APNs p8 key on the app — see [platform-setup-apns.md](platform-setup-apns.md) |

Install with Swift Package Manager; podspecs exist in the repository but are not published to CocoaPods trunk. There is no Carthage support.

The SDK key is public by design and ships in the app binary. The REST API key must never appear in an app. See [security-and-limits.md](security-and-limits.md).

## Products

| Product | Link it to |
|---|---|
| `OpenPush` | the app target — registration, identity, receipts |
| `OpenPushCore` | shared configuration, payload parsing, Keychain credential storage; usable from both targets |
| `OpenPushNSE` | the Notification Service Extension target |
| `OpenPushLiveActivities` | the widget extension target (not `OpenPush`) |
| The compatibility facade product | opt-in source compatibility for migrating hosts — see [Compatibility facade](#compatibility-facade) |

### Compatibility facade

The compatibility facade is an opt-in product for migrating hosts. It accepts the API spelling of the previous provider's SDK, so existing call sites compile against OpenPush unchanged. It is a source-compatibility layer only. See the [migration guide](migrate-from-onesignal.md).

## Step 1 — install

In Xcode choose **File → Add Package Dependencies** and enter the package repository URL, then select the `1.1.5` version rule. Link `OpenPush` to the app target and `OpenPushNSE` to the Notification Service Extension target.

For a `Package.swift` consumer:

```swift
dependencies: [
    .package(url: "https://github.com/JuneSoftware/openpush-ios.git",
             .upToNextMajor(from: "1.1.5"))
]
```

`1.1.5` is the version the Unity package vendors; its tag is published with the release.

## Step 2 — initialize

Put the SDK key in the app's `Info.plist` under `OpenPushSDKKey` (the app id key is `OpenPushAppID`), then:

```swift
import OpenPush

OpenPush.initialize(
    appId: "app_3f9c",
    serverURL: "https://app.openpush.ai",
    credential: { DeviceSession.currentCredential }
)
```

`serverURL` already defaults to `https://app.openpush.ai`, so the argument above is optional and shown only to make the target explicit. To keep the key in code instead, use `initialize(appId:sdkKey:…)` — an explicit key always beats the bundled one. With no key in either place, `initialize` **refuses** rather than half-configuring.

`attach(configuration:credential:transport:)` remains available for hosts that build their own `OpenPushConfiguration`.

There is an `initialize(_:withLaunchOptions:)` overload for migrating hosts' source compatibility. The launch options are accepted and ignored — OpenPush does not swizzle and does not install any delegate.

When authorization already exists, a normal initialize asks iOS to re-issue the APNs token without prompting, so a rotated token resynchronizes.

**Verify:** `OpenPush.isInitialized` is `true` and `OpenPush.onLog` reports no refusal.

## Step 3 — forward the APNs token

The SDK never swizzles your app delegate. Forward both callbacks yourself:

```swift
func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    OpenPush.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}

func application(_ application: UIApplication,
                 didFailToRegisterForRemoteNotificationsWithError error: Error) {
    OpenPush.didFailToRegisterForRemoteNotifications(error: error)
}
```

`OpenPush.register(token:externalID:)` and `OpenPush.hexToken(_:)` are available if you manage the conversion yourself.

Transport errors and 5xx responses retry at +2 s and +8 s; `onRegistrationFailed` fires only after the final attempt. A `scope_conflict` response is surfaced as an instruction to reset the device, not as a connection failure. Forwarded APNs failures use the same hook, with diagnostics that name a missing Push Notifications capability and simulator limitations.

**Verify:** `OpenPush.onRegistered` fires and the device appears in `GET /v1/apps/{app_id}/subscriptions`.

## Step 4 — request permission

```swift
OpenPush.Notifications.requestPermission(fallbackToSettings: true) { granted in
    // A grant also starts APNs registration.
}
```

`fallbackToSettings` deep-links to the OS notification settings once iOS stops showing the prompt.

### Provisional (quiet) authorization

iOS is the only OpenPush SDK with a soft prompt:

```swift
OpenPush.initialize(appId: "app_3f9c", serverURL: "https://app.openpush.ai", provisional: true)
```

This requests provisional plus alert, sound and badge, so notifications are delivered quietly to Notification Center with no prompt. Provisional is a **positive** status carrying bit `64` — a provisional device is subscribed and fully targetable.

### Status reporting

Every registration reports the iOS authorization bitmask. The bits are `1` badge, `2` sound, `4` alert, `8` CarPlay, `16` critical, `32` app settings, `64` provisional, `128` announcement, `256` time-sensitive; any positive value means subscribed. `0` is denied, `-18` never prompted, `-19` prompt unanswered, `-2` explicitly opted out. The full table is in [users-and-subscriptions.md](users-and-subscriptions.md).

The SDK recomputes authorization at launch and on foreground, and re-registers only when the value changed — so a change made in Settings reaches the server on the next launch. UIKit apps are observed automatically; a host without UIKit should call `OpenPush.Notifications.onAppForegrounded()`.

Watch changes with `addPermissionObserver` (returns a `UUID`) and `removePermissionObserver`.

## Step 5 — identity

```swift
OpenPush.login(externalId: user.id, authHash: user.openPushAuthHash) { result in
    if case .identityRejected(let note) = result {
        // Registered, but anonymous. Do not show the user as identified.
        Diagnostics.record("openpush identity rejected: \(note)")
    }
}

OpenPush.logout()
```

A login before the APNs token arrives is kept and sent with the next registration. `logout` re-registers with a present-and-empty `external_id`, which is what detaches the account.

Compute `authHash` on your server. The external ID persists across a cold start; the auth hash is a bearer proof and is deliberately **memory-only**, so call `login` again at launch. See [users-and-subscriptions.md](users-and-subscriptions.md).

## Step 6 — tags and aliases

```swift
OpenPush.User.addTags(["plan": "pro", "locale": "en-GB"])
OpenPush.User.removeTags(["trial_expiry", "old_segment"])

OpenPush.User.addAliases(["player_id": "p-1042", "crm_id": "c-88"])
OpenPush.User.removeAlias("crm_id")
```

Single-key forms (`addTag`, `removeTag`, `addAlias`, `removeAlias`) and `getTags()` exist too.

Rules that matter:

- **Tag values are strings only.**
- **Deleting is an empty-string value**, and a removed key keeps riding as `""` so a re-registration cannot resurrect it.
- Writes persist immediately and share a fixed **300 ms** window, so a burst becomes one whole-map registration. Backgrounding flushes pending writes.
- Client-side alias rules: an empty label is dropped, the label `external_id` is **reserved and refused locally**, and going past 10 aliases produces a warning.
- The server also caps aliases at 10 per user and names refused labels on a successful response rather than failing the request.
- With identity verification enabled, a tag or alias write without a valid auth hash comes back rejected and is logged.

## Step 7 — the Notification Service Extension

This step is mandatory for two features, not one.

Add a Notification Service Extension target, link `OpenPushNSE` (and `OpenPushCore`), and give the extension access to the same shared Keychain access group as the app so it can load the same installation credential. Never copy a device credential into `UserDefaults` or an App Group plist.

### Why it is required

OpenPush sets `mutable-content: 1` on **every alert push**, unconditionally — not only on pushes that carry an image. The extension is the only iOS code that runs when a notification arrives at a backgrounded app, so gating `mutable-content` on the presence of an image would silently forfeit the confirmed receipt on every plain-text campaign. Because it is always set, the extension always runs, and both of these work:

- **Images.** OpenPush does not build an `aps` attachment. The image travels as a top-level custom key `image_url`, and your extension downloads it and attaches it. Without an extension, images never appear.
- **Confirmed receipts.** The extension posts the `confirmed` stage for notifications the app never saw, which is the difference between "the provider accepted it" and "the device displayed it".

### Receipts from the extension

```swift
let request = OpenPushReceiptRequest.make(
    configuration: configuration,
    deviceCredential: credential,
    token: apnsToken,
    messageID: messageID,
    types: ["received", "confirmed"]
)
```

`make` builds a bounded request to `/v1/ingest` with the SDK key already attached. The extension imports Foundation only and exposes no registration, console-management or send API.

### Action buttons from the extension

```swift
OpenPushNotificationActions.apply(from: request.content.userInfo, to: content)
```

Call this before delivering mutable content. It registers the button labels configured on the message as a `UNNotificationCategory` and passes the tapped action identifier through to the app delegate. OpenPush-owned categories are bounded to the **32 most recently used** label sets; host-registered categories are never touched.

## Step 8 — foreground and click handling

```swift
func userNotificationCenter(_ center: UNUserNotificationCenter,
                            willPresent notification: UNNotification,
                            withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
    completionHandler(OpenPush.Notifications.processForeground(notification))
}

func userNotificationCenter(_ center: UNUserNotificationCenter,
                            didReceive response: UNNotificationResponse,
                            withCompletionHandler completionHandler: @escaping () -> Void) {
    OpenPush.Notifications.processClick(response)
    completionHandler()
}
```

`processForeground` returns the presentation options and posts `received`. `processClick` posts `clicked`. Listeners:

```swift
let clicks = OpenPush.Notifications.addClickListener { event in
    // event.messageId, event.payload, event.actionId
}
let fg = OpenPush.Notifications.addForegroundLifecycleListener { event in
    event.preventDefault()
}
```

Both return a `UUID` for the matching `remove…`. `ClickEvent.actionId` is `nil` for the default open action and carries the button identifier otherwise.

App-side receipts can also be posted directly:

```swift
let messageID = OpenPush.messageID(from: userInfo)
OpenPush.receipt("received", messageID: messageID)
OpenPush.receipt("clicked", messageID: messageID)
```

Receipts are deduplicated on `type|messageId` for successful posts only; a failed attempt stays retryable.

## iOS payload keys

The sender folds `op_apns_*` options into Apple's `aps` dictionary and strips them from the custom keys, so they do not reach your code. What your app sees is:

| Key | Meaning |
|---|---|
| `op_message_id` | the id to pass to `receipt` — read it with `OpenPush.messageID(from:)` |
| `op_actions` | action buttons, max 3 — `OpenPush.actions(from:)` |
| `op_action_category` | the derived `UNNotificationCategory` identifier |
| `image_url` | the image your extension fetches and attaches |
| `deep_link` | the destination, when the message sets one |
| `op_render_truncated` | `"1"` if the server trimmed the payload to fit |

The sender-side APNs options — subtitle, sound, badge, category, thread id, interruption level, relevance score — are covered in [sending-messages.md](sending-messages.md).

## Live Activities

The SDK has full ActivityKit support. Define one shared attributes type for the app and the widget:

```swift
struct DeliveryAttributes: OpenPushLiveActivityAttributes {
    struct ContentState: OpenPushLiveActivityContentState {
        var status: String
        var openpush: OpenPushLiveActivityContentStateData?
    }
    var orderNumber: String
    var openpush: OpenPushLiveActivityAttributeData
}

if #available(iOS 16.1, *) {
    OpenPush.LiveActivities.setup(DeliveryAttributes.self)
}
```

`setup` observes ActivityKit tokens, dismissals and confirmed receipts for you. `setupDefault(options:)` and `startDefault(_:attributes:content:)` cover the untyped default activity.

A host that manages tokens itself uses the direct hooks: `enter(_:withToken:)` and the `activityType` overload, `exit(_:)`, `setPushToStartToken(activityType:token:)`, `removePushToStartToken(_:)`, plus `ActivityAttributes`-typed generics on iOS 17.2. Every request is queued on disk until registration or a transient outage clears.

Link `OpenPushLiveActivities` — not `OpenPush` — to the widget extension, add `NSSupportsLiveActivities` to the app plist, and use `.openpushWidgetURL` in the widget. For activity types that need a higher remote-update budget, also add `NSSupportsLiveActivitiesFrequentUpdates`; the SDK observes both settings and synchronizes them as device tags (`device_live_activities_enabled` and `device_frequent_pushes`).

Count widget taps from the app's URL handler:

```swift
.onOpenURL { url in
    let original = OpenPush.LiveActivities.trackClickAndReturnOriginal(url)
    if let original { /* route original */ }
}
```

Server-side start, update and end live in [live-activities.md](live-activities.md).

## Custom events

```swift
OpenPush.trackEvent("onboarding_started", properties: ["source": "welcome"])
```

Names are 1–128 characters matching `[a-zA-Z0-9_\-. ]+`; properties must be JSON-safe and at most 2048 bytes. Events are persisted while offline and sent in batches of up to 50 after the same 300 ms window. See [events.md](events.md).

## Subscription state and opt-out

```swift
OpenPush.User.pushSubscription.optOut()
OpenPush.User.pushSubscription.optIn()   // prompts first if authorization is missing

let state = OpenPush.User.pushSubscription
// state.id, state.token, state.status, state.optedIn
```

The accessor is live: a value stored before registration reflects the later `id`, `token` and `status`. Call `snapshot()` for an immutable, equatable value. Opt-out intent persists across process death and does not delete the subscription or the token.

iOS is the **only** OpenPush SDK whose subscription state carries `status`. Android and Unity expose `id`, `token` and `optedIn` only.

## App sessions

Cold launches and foreground transitions send a throttled app-session ping, at most one per 30 minutes, persisted across relaunches. It feeds session counts and the activity histogram behind [best-hour delivery](best-hour-delivery.md).

## Limits

- No send surface. Sending is REST or console only.
- **No swizzling and no delegate installation** — you forward every callback.
- No badge-count setter. Badge appears only as an authorization bit and a presentation option.
- No consent gate: the compatibility facade's `consentRequired` and `consentGiven` are compile-time refusals that name the replacement, not silent no-ops. No notification inbox either — no inbox API exists and the compatibility facade declares none.
- **In-app messages are supported.** `OpenPush.InAppMessages` is real — a `WKWebView` presenter, trigger state, and the impression/click receipt path — and the compatibility facade's `InAppMessages` resolves onto it rather than refusing. See [in-app-messages.md](in-app-messages.md) and the [in-app messages API](../api-handbook/10-in-app-messages.md).
- Tag values are strings, always.
- No CocoaPods or Carthage distribution.
- Critical alerts are not supported by the sender: the critical sound dictionary is never built, and `interruption-level: critical` is deliberately excluded.
- Without a Notification Service Extension you get no images and no confirmed receipts for backgrounded arrivals.
- Nothing in the `OpenPush` module is marked deprecated, despite what some in-repo Live Activities documentation claims.

## Troubleshooting

| Symptom | Cause | Fix |
|---|---|---|
| `initialize` refuses through `onLog` | No `OpenPushSDKKey` in `Info.plist` and none passed in code | Add the plist key or use `initialize(appId:sdkKey:)` |
| No token ever arrives | The app delegate callbacks are not forwarded | Implement both `didRegisterForRemoteNotifications…` methods; OpenPush never swizzles |
| Push works in TestFlight but not the App Store build (or the reverse) | Sandbox and production tokens in one app | The device's own sandbox flag beats the app setting — see [platform-setup-apns.md](platform-setup-apns.md) |
| Images never appear | No Notification Service Extension, or it does not fetch `image_url` | Add the extension; OpenPush sends the image as a top-level key, not an `aps` attachment |
| Provider Accepted is high, Confirmed Receipt is zero | The extension is missing or is not posting receipts | Use `OpenPushReceiptRequest.make` from the extension with a shared-Keychain credential |
| Action buttons render without labels | `OpenPushNotificationActions.apply` not called in the extension | Call it before delivering mutable content |
| Every send fails with a configuration error and no device is touched | An APNs provider-token problem — wrong key type, wrong key id, or an expired token | Fix the credential; a configuration fault stops the fan-out rather than marking devices unreachable |
| Identity shows as anonymous | Identity verification on, auth hash missing or wrong | Compute the HMAC on your server and pass it as `authHash` at every launch |
| Simulator receives nothing | Remote push registration is limited there | Test on a device |

## FAQ

**Can I install with CocoaPods?**
Not from CocoaPods trunk. Install with Swift Package Manager; podspecs exist in the repository but are not published to CocoaPods trunk.

**Is the Notification Service Extension really required?**
For images and for confirmed receipts on backgrounded arrivals, yes. Plain alerts will still display without it, but you lose the receipt stage that distinguishes "displayed" from "accepted by Apple".

**Why is `mutable-content` set on a plain-text push?**
Because the extension is the only code that runs on a backgrounded arrival. Setting it only when an image is present would silently drop the confirmed receipt on text campaigns.

**Does provisional authorization count as subscribed?**
Yes. Provisional carries bit `64`, and every positive status value is subscribed and targetable.

**Which version is current?**
`1.1.5`.

## Related

- [quickstart.md](quickstart.md)
- [platform-setup-apns.md](platform-setup-apns.md)
- [live-activities.md](live-activities.md)
- [users-and-subscriptions.md](users-and-subscriptions.md)
- [sending-messages.md](sending-messages.md)
- [events.md](events.md)
- [Migration guide](migrate-from-onesignal.md)
- [in-app-messages.md](in-app-messages.md)
- [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md)
- [../api-handbook/08-live-activities.md](../api-handbook/08-live-activities.md)
- [../api-handbook/10-in-app-messages.md](../api-handbook/10-in-app-messages.md)
