Native SDK — iOS

Embed the sync surface directly inside your own iOS app with the Odynn Connect Native SDK — your chrome, your navigation, no hand-off to another app. Create a session, hand the SDK your session id, run the embed, read normalized loyalty data.

How the embed works

With connectUrl, your user hops out to the Odynn-hosted App Clip. With the Native SDK, the sync surface lives inside your own app: the Odynn Connect Native SDK creates a worker — an in-app agent that hosts a secure web view — and pairs it to the session over an encrypted realtime connection. You hand the SDK your session id; it resolves the transport internally, so no transport credentials ever pass through your code. Your user signs in to the loyalty program right there in your app; Odynn orchestrates the sync and normalizes the result server-side, and your backend reads it over the same REST API as every other flow.

Pick this path when in-app continuity matters, when you already ship a native iOS app, or when you want full control of the surrounding chrome. If you want the zero-engineering path instead, start with the White-label flow — the transport choice is laid out in How Odynn Connect works.

The five steps of the embed, end to end. Your API key never leaves your backend; the device only ever sees the single-use session id, and the SDK resolves the realtime transport from it internally. When accept() returns, the session is terminal server-side and the normalized payload is ready to read.

Requirements & SDK access

  • iOS 15.1 or later
  • Swift 6.0 or later, Xcode 16 or later
  • Distributed via Swift Package Manager

The SDK ships as the OdynnConnectSDK package from its public distribution repository — no account, no token, no Odynn round-trip. Add it to your Package.swift (or Xcode's package picker) and add OdynnConnectSDK to your app target's dependencies. The package carries a runnable example app under Example/.

Package.swift — pin an exact version
.package(
    url: "https://bitbucket.org/cardcurator/odynn-connect-ios-sdk.git",
    exact: "X.Y.Z"   // ← the version you tested against; see the changelog
)

Pin an exact: version rather than a range: a sync surface is not something you want moving under a release you have already tested. The current versions, and what each one pins, are in the repository changelog; the package README states the version and support policy in full.

Versions and support. The package is semantically versioned: patch releases are fixes, minor releases add surface, and only a major release changes the surface taught below. When a major lands the previous one keeps receiving patches for six months, and anything due for removal is announced in the changelog a minor release ahead — so an upgrade is something you schedule, not something you react to.

Step-by-step: the embed

Five steps from a button in your app to loyalty data in your backend. The examples continue the fictional Horizon Travel tenant connecting an AAdvantage account.

1Create a session (server-side)

Same first move as every integration: your backend calls POST /v1/sessions with your odc_live_… key. For the embedded path, the field you care about in the response is the id — it is your SDK session handle.

Create the session at the moment your user taps “link my account” — not when they open your app. The worker-arrival timer starts as soon as the session is created: a device must call connect before workerArrivalTimeoutAt (default 2 minutes) or the session expires unused. Always set metadata.userId so the user-scoped reads and the refresh API work later.

The response also carries connectUrl — the hosted App Clip link your QR fallback uses. The embed itself needs only the id: hand it to the SDK and the realtime transport is resolved for you, server-side.

Request
POST /v1/sessions
Idempotency-Key: 7c2e9a4b-1f6d-4e3a-8b5c-2d9f7a1e0c6b

{
  "providerId": "aadvantage",
  "metadata": { "userId": "user-123" }
}
201 — the id is your SDK session handle
{
  "id": "ses_8fQ2mL0xW3vT",
  "status": "pending",
  "providerId": "aadvantage",
  "connectUrl": "https://connect.odynn.com/link/horizon-travel/ses_8fQ2mL0xW3vT",
  "workerArrivalTimeoutAt": "2026-07-21T12:02:00.000Z",
  "authTimeoutAt": "2026-07-21T12:05:00.000Z",
  "returnUrl": null,
  "metadata": { "userId": "user-123" },
  "createdAt": "2026-07-21T12:00:00.000Z"
}

Field-by-field detail on the create call lives in the White-label walkthrough's Create a session step — the idempotency and metadata guidance applies here unchanged. One exception: returnUrl only affects the Odynn-hosted connectUrl surface (relevant if you ship the QR fallback); in the embed, your own controller owns the completion UI.

2Deliver the session id to your app

Ship the session id to the device over a channel you already own — the response to the API call your app just made, a push payload, or a deep link.

The ses_… id is the SDK's session handle — and the credential the SDK uses to resolve the session's realtime transport — so treat it like a short-lived secret. Never persist it, never reuse it across attempts or users. Your odc_live_… API key stays on your backend; the device only ever sees the id.

Your own backend → app response (example)
// Your app asks your own backend to start a sync. Your backend creates
// the Odynn session and returns only the session id — the API key never
// leaves your server, and the SDK resolves everything else from the id.
GET https://api.horizontravel.example/loyalty/start-sync

{
  "sessionId": "ses_8fQ2mL0xW3vT"
}

3Mount the surface, connect, accept

Three calls run the whole embed: create a worker bound to a view controller, connect it to the session, then accept().

worker(controller:) mounts the SDK's web view into the view controller you pass — use the controller that should own the sync surface (typically the one on screen). connect(session:) takes the session id, exchanges it for the realtime transport internally — your code never sees a transport URL — then completes the handshake and claims the session for this device. accept() then runs the sync: it suspends until the worker closes — completion, failure, or cancellation — so run it from a Task and let the rest of your app continue.

Keep a strong reference to the Worker for as long as the connection is active. If the worker is deallocated mid-flight, the session closes.

Swift — the minimal embed
import UIKit
import OdynnConnectSDK

@MainActor
func startSync(in viewController: UIViewController, sessionId: String) async throws {
    let sdk = OdynnConnect()
    let worker = sdk.worker(controller: viewController)  // mounts the web view

    try await worker.connect(session: sessionId)         // ses_… claims the session

    Task {
        try await worker.accept()  // suspends until the sync ends
    }
}

Requirements: iOS 15.1+, Swift 6.0+, Xcode 16+ — see Requirements & SDK access.

4Completion & teardown

When accept() returns without throwing, the sync is complete server-side — hand control back to your UI. The production-shaped controller below adds the teardown path.

Teardown always runs in the same order: cancel the in-flight accept() task (the resulting CancellationError is expected — stay silent), drop your strong Worker reference, then best-effort close(). Skipping close() after a successful completion is harmless; skipping it when the user backs out leaks the session-side connection until the auth timeout fires.

Hosting from SwiftUI? Wrap the controller in a UIViewControllerRepresentable. SwiftUI calls makeUIViewController once and reuses the instance across body re-renders, so the SDK references survive automatically.

Swift — production-shaped controller
import UIKit
import OdynnConnectSDK

final class ConnectSessionViewController: UIViewController {
    private let sdk = OdynnConnect()
    private var worker: Worker?
    private var acceptTask: Task<Void, Never>?

    private let sessionId: String
    private let onCompleted: () -> Void

    init(sessionId: String, onCompleted: @escaping () -> Void) {
        self.sessionId = sessionId
        self.onCompleted = onCompleted
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground

        // Strong-reference the Worker: if it deallocates, the session closes.
        let worker = sdk.worker(controller: self)
        self.worker = worker

        acceptTask = Task { @MainActor in
            do {
                try await worker.connect(session: sessionId)
                try await worker.accept()
                // accept() returned: the sync is complete server-side.
                onCompleted()
                dismiss(animated: true)
            } catch is CancellationError {
                // Teardown cancelled accept() — expected, stay silent.
            } catch {
                presentFailure(error)
            }
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // Teardown order: cancel accept, drop the reference, then close.
        acceptTask?.cancel()
        acceptTask = nil
        let captured = worker
        worker = nil
        Task { try? await captured?.close() }
    }

    private func presentFailure(_ error: Error) {
        let alert = UIAlertController(
            title: "Couldn't finish linking",
            message: error.localizedDescription,
            preferredStyle: .alert
        )
        alert.addAction(.init(title: "OK", style: .default) { [weak self] _ in
            self?.dismiss(animated: true)
        })
        present(alert, animated: true)
    }
}
Swift — SwiftUI host
struct ConnectSessionView: UIViewControllerRepresentable {
    let sessionId: String
    let onCompleted: () -> Void

    func makeUIViewController(context: Context) -> ConnectSessionViewController {
        ConnectSessionViewController(sessionId: sessionId, onCompleted: onCompleted)
    }
    func updateUIViewController(_ vc: ConnectSessionViewController, context: Context) {}
}

5Read the data (server-side)

accept() returning successfully means the session reached a terminal state — the normalized payload is ready for your backend to read.

Fetch GET /v1/sessions/:id/data from your backend — or, better, don't couple your data path to the device at all: subscribe a webhook endpoint to session.completed and react server-side, exactly as in the White-label flow. The route returns 404 extraction_result_not_found until the session has a result.

The normalized result (trimmed)
GET /v1/sessions/ses_8fQ2mL0xW3vT/data

{
  "sessionId": "ses_8fQ2mL0xW3vT",
  "extractedAt": "2026-07-21T12:03:39.000Z",
  "data": {
    "profile": { "memberNumber": "7A2BC34", "name": "Alex Rivera" },
    "balances": [
      { "kind": "redeemable", "currency": "miles", "amount": 184220 }
    ],
    "tier": { "name": "Platinum" },
    "fetchedAt": "2026-07-21T12:03:37.000Z"
  }
}

Webhook setup and signature verification: Learn the outcome and Webhooks. The full payload schema: Read the data.

What the SDK path does not include

Three things the embedded path does not give you today. None of them block an integration — but plan around them rather than discovering them in review.

No hosted fallback yet

Your app hosts the sync; there is no hand-off to an Odynn-hosted screen, and the QR fallback below opens up with it. Both ride the Odynn Connect App Clip, and an App Clip is distributed from an App Store listing — so both arrive when that listing does.

No Android SDK yet

The Kotlin counterpart is not released. If you ship both platforms, plan the Android half around the Android page and talk to us about timing.

Silent refresh is coming soon

Refreshing an account today either returns cached data or asks your user to sign in again — which is a new sync, priced as one. Re-auth works through the SDK unchanged: session.auth_required carries a session id, and you hand that id to the SDK exactly like the first one. See Refresh.

Lifecycle & error handling

A session is single-use

One session id backs one connect attempt. Mint a fresh session per attempt with POST /v1/sessions — never cache an id, and never hand one to a second user.

Worker-arrival timeout

If no device calls connect before workerArrivalTimeoutAt (default 2 minutes), the session expires and a later connect rejects. Don't pre-create sessions speculatively — create at the moment of user intent.

One session, one device

The first device to connect claims the session; a second connect is rejected with a connect-time error. This is why the QR fallback mints a fresh session instead of racing two devices on one.

Authentication timeout

If your user doesn't finish the provider sign-in before authTimeoutAt (default 5 minutes), accept() throws, the session lands failed server-side, and GET /v1/sessions/:id/data returns 404.

Network loss

A drop during accept() surfaces as a thrown error. Tear down, treat the session as failed, and retry by minting a fresh session — the old session is no longer usable.

Teardown order

Always: 1. Cancel the in-flight accept() task — the CancellationError is expected and silent. 2. Drop your strong Worker reference. 3. Best-effort try? await worker.close().

Error UX

Connect failure → “Couldn't connect — try again” with a button that mints a fresh session and re-runs the lifecycle. Accept failure → same panel (usually the auth timeout or a dismissed sign-in). Cancellation during accept → silent, no UI.

Your chrome, your branding

In the embedded path, everything around the web view is yours to design — the header, the loading states, the error UI, the success screen. The SDK controls only the web-view surface itself, and what renders there is the loyalty provider's real sign-in page, dictated by the provider.

The Dev Console's Branding page themes the connectUrl App Clip surface only — it is not consumed by your embed. If you also ship the QR fallback below, your published branding applies to that flow.

Ownership of the screen. The header, loading states, error UI, and success screen are yours to design; the SDK controls only the web-view surface, and what renders there is the loyalty provider's real sign-in page.

Fallback: a QR backup

Design for this now, ship it when the hosted surface opens — see What the SDK path does not include.

If the embedded path stalls — a connect-time failure, repeated retries, or a user who opts out — don't dead-end. Mint a fresh session and render its connectUrl as a QR code (“Scan with another device”): the phone's camera opens the White-label surface and the user finishes there.

Use a fresh session because of the one-session-one-device rule — the stalled attempt may already have claimed or expired the original. Treat the fallback as serial, never a race. Whichever session completes, the result lands the same way: react to session.completed (or poll), then read GET /v1/sessions/:id/data for the id that completed.

Submitting your app

App Review sees a web view in which a user signs in to an account that is not yours. That is a normal, approvable pattern, but a reviewer who has to guess at it will come back with questions — so answer them in the submission itself.

App Review notes has a paste-ready block for App Store Connect's review notes, the walkthrough to script for the reviewer, guidance for the privacy questionnaire, and short answers to the questions reviewers actually ask.