SDK

iOS SDK

A Swift package for iOS 14 and newer. QEEmbedController owns a WKWebView with the trading module in embed mode and speaks the bridge; QEEmbedView (SwiftUI) and QEEmbedViewController (UIKit) show it. No third-party dependencies.

#Install

  1. Add the package

    Octolit provides the QEEmbed Swift package on request. Xcode > File > Add Package Dependencies > Add Local… and pick the package folder, or copy Sources/QEEmbed into your project.

  2. Info.plist and capabilities

    Info.plist
    <!-- Info.plist -->
    <key>NSCameraUsageDescription</key>        <string>Take a photo of your ID for verification</string>
    <key>NSPhotoLibraryUsageDescription</key>  <string>Choose a photo of your ID for verification</string>
    <key>NSFaceIDUsageDescription</key>        <string>Unlock your trading account</string>
    <!-- Capabilities: Push Notifications; Associated Domains: applinks:app.your-brand.com -->

    The two photo strings are needed for KYC uploads, the Face ID string for the lock screen. Push Notifications for trade / margin pushes, Associated Domains for universal links into the module.

#Configure

QEEmbedConfig
import QEEmbed

var config = QEEmbedConfig(baseURL: URL(string: "https://live.your-brand.com")!)
config.screen = "terminal"
config.language = Locale.current.languageCode
config.theme = QEEmbedTheme(mode: .dark, primary: "#ff7a1a", background: "#101418", paper: "#171c22",
                            radius: 14, fontFamily: "SF Pro Text")
config.depositByHost = true      // the Deposit button opens YOUR cashier (deposit_requested)
config.linksByHost = true        // outside links open in Safari (open_external)
config.lockEnabled = true        // Face ID after 5 min in the background
config.lockAfterSeconds = 300

let controller = QEEmbedController(config: config)
controller.unlockReason = "Unlock your trading account"
FieldDefaultMeaning
baseURLrequiredThe trader address of your brand, https://live.your-brand.com.
screen"terminal"First screen on a cold start: terminal, wallet, history, account, positions.
accountnilTrading account id to select.
languagenilUI language (en, de, es, pt, ar, …). nil = the module detects the device language.
themeQEEmbedTheme()Colours, shape and font - see the theme fields below.
hide["header", "nav", "branding"]Parts of the module's chrome to hide; your app has its own navigation.
depositByHosttrueThe Deposit button sends deposit_requested instead of opening the platform cashier.
linksByHosttrueOutside links go to onOpenExternal / Safari instead of opening inside the web view.
lockEnabledtrueLock screen + biometric re-unlock through the host.
lockAfterSeconds300Seconds in the background after which the module locks itself.
proModeAllowednilfalse = simple terminal only; nil / true = each user keeps their own Pro mode choice.
showLoginFormfalsetrue = keep the module's own sign-in form when there is no session (default: a neutral page, you sign in by SSO).

QEEmbedTheme fields (all optional):

FieldDefaultMeaning
modenil.light or .dark.
primarynil"#RRGGBB" accent colour.
backgroundnil"#RRGGBB" page background.
papernil"#RRGGBB" card background (default = background).
textnil"#RRGGBB" main text colour; checked for 4.5:1 contrast.
radiusnilCorner radius in px, clamped to 0 … 32.
fontFamilynilFont family name.
fontURLnilhttps URL of a stylesheet that loads the font (other schemes are dropped).

The controller injects the options as window.__QE_EMBED__ before the page scripts run and also puts them on the first URL (config.startURL(loginToken:)) so a reload inside the web view keeps embed mode. Everything is validated by the module.

#Sign-in (SSO)

Your app signs the user in with your backend; your backend asks the Partner API for a one-time trading login token (POST /api/external/v1/users/{external_id}/sso, see the SSO guide); the token goes to the module. The sample below is the whole host side.

TradingHost.swift
/// One controller for the whole app; the host decides when to start it (after its own login).
final class TradingHost: ObservableObject {
    static let shared = TradingHost()
    let controller: QEEmbedController
    @Published var signedIn = false
    @Published var cashierAccount: String?

    private init() {
        var config = QEEmbedConfig(baseURL: URL(string: "https://live.your-brand.com")!)
        config.theme = QEEmbedTheme(mode: .dark, primary: "#ff7a1a", background: "#101418", radius: 14)
        controller = QEEmbedController(config: config)
        wire()
    }

    private func wire() {
        let l = controller.listener
        l.onReady = { e in print("ready, module version", e.string("version") ?? "?") }
        l.onLogin = { [weak self] _, _ in self?.signedIn = true }
        l.onLogout = { [weak self] in self?.signedIn = false }
        l.onSessionExpired = { [weak self] in self?.signedIn = false; self?.signIn() }   // a fresh one-time token
        l.onAuthRequired = { [weak self] in self?.signIn() }
        l.onDepositRequested = { [weak self] account, _ in self?.cashierAccount = account }
        l.onTradeOpened = { e in print("trade opened", e.data) }
        l.onError = { code, _ in print("module error", code) }
        // onUnlockRequested not set -> QEEmbedBiometrics (Face ID / Touch ID / passcode) answers by itself
    }

    /// 1. your app signs the user in with YOUR backend, 2. your backend asks the Partner API for a one-time
    /// trading login token for that user, 3. the token goes to the module. Never put the Partner API key in the app.
    func start() {
        if controller.sessionStore.loadSession() != nil {
            controller.start()              // the kept session is restored, no new token needed
        } else {
            signIn()
        }
    }

    func signIn() {
        YourBackend.fetchTradingLoginToken { [weak self] token in
            guard let self else { return }
            if self.controller.isReady { self.controller.login(token: token) }
            else { self.controller.start(loginToken: token) }
        }
    }
}
  • start(loginToken:) on the first load, login(token:) while the module is already showing (controller.isReady).
  • The module answers login { session, client_id, account }; the SDK saves the session in the keychain and calls onLogin.
  • onSessionExpired / onAuthRequired: fetch a new token and call login(token:) - exactly what the sample does.
  • logout() clears the store and signs the module out. Call it when the user signs out of your app.
Never
put the Partner API key in the app, log the token or the session, or reuse a one-time token.

#Session restore

On the next app start call start() with no token: the kept session is sent as restoreSession and the user is signed in without a round trip through your backend. controller.sessionStore.loadSession() tells you whether there is one.

Session stores
// default: keychain, service "qe-embed", account "session", kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let controller = QEEmbedController(config: config)                       // QEEmbedKeychainSessionStore()

// tests / previews: nothing survives a restart
let test = QEEmbedController(config: config, sessionStore: QEEmbedMemorySessionStore())

// your own store
final class MyStore: QEEmbedSessionStore {
    func loadSession() -> String? { … }
    func saveSession(_ session: String) { … }
    func clearSession() { … }
}

#Views and app lifecycle

SwiftUI and UIKit
// SwiftUI
struct TradingTab: View {
    @EnvironmentObject private var host: TradingHost
    var body: some View {
        QEEmbedView(controller: host.controller)
            .ignoresSafeArea(edges: .bottom)
            .onAppear { host.start() }
    }
}

// UIKit
let vc = QEEmbedViewController(controller: TradingHost.shared.controller)
navigationController?.pushViewController(vc, animated: true)
App: deep links and resume
@main
struct YourApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    @Environment(\.scenePhase) private var scenePhase
    @StateObject private var host = TradingHost.shared

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(host)
                .onOpenURL { url in _ = host.controller.handleDeepLink(url) }      // universal links, yourapp://
                .onChange(of: scenePhase) { phase in
                    if phase == .active { host.controller.resume() }              // re-checks the lock timer
                }
        }
    }
}

Create the controller when the app starts (not when the tab opens) and keep one instance for the whole app. controller.presentingViewController is used for the biometric prompt and the file sheet; set it when your view hierarchy is unusual.

#Events

controller.listener is a QEEmbedListener with one closure per event; set the ones you need. Every callback runs on the main thread.

CallbackEventUse
onReady(event)readyThe module shows its first screen - hide your splash. event.string("version"), event.bool("authenticated").
onState(event)stateAnswer to init / getState / resume: authenticated, locked, gate, path.
onLogin(session, event)loginSigned in; the SDK already saved session in the store. event has client_id, account, email.
onLogout()logoutSigned out; the store is cleared.
onSessionExpired()session_expiredThe server refused the session; the store is cleared - fetch a new token.
onAuthRequired()auth_requiredA screen needs a signed-in user and there is none - fetch a token.
onNavigation(path)navigationThe module moved; also in controller.currentPath.
onTradeOpened(event)trade_openedevent.data has position { id, symbol, side, lots, price } and request.
onTradeClosed(event)trade_closedevent.data has position_id, partial, lots, position, bulk?, reason?.
onDepositRequested(account, clientId)deposit_requestedOpen your cashier.
onOpenExternal(URL)open_externalOpen the URL yourself; when not set the SDK opens Safari.
onUnlockRequested(reason)unlock_requestedRun your own unlock and call unlock(ok:); when not set the SDK runs the biometric prompt.
onUnlocked()unlockedThe lock screen is gone.
onThemeApplied(event)theme_appliedWhat the module applied: mode, primary, bg, paper, text, radius, font.
onPushRegistered(ok, reason)push_registeredAnswer to registerPushToken.
onError(code, event)errorembed_disabled, bad_command, command_failed, unlock_denied; event has command and message.
onAny(event)every eventRaw access: event.type, event.rawType, event.data, event.ts.

QEEmbedEvent carries type (QEEmbedEventType), rawType, data and helpers string(_:), int(_:), bool(_:). Read-only state on the controller: isReady, isAuthenticated, isLocked, currentPath.

#Commands

Every bridge command is a method. Commands sent before ready are queued and flushed in order.

MethodCommandEffect
start(loginToken: String? = nil)load + init (+ login / restoreSession)Loads the module. With a token: signs in. Without: restores the kept session when there is one.
login(token:account:)loginA one-time token while the module is already showing.
logout()logoutSigns the module out and clears the store.
setTheme(_ theme: QEEmbedTheme?)setThemeAny subset; nil clears back to the brand theme.
setLanguage(_ lang: String)setLanguageSwitch the UI language.
navigate(screen:) / navigate(path:)navigateMove to a screen or a module path.
lock() / unlock(ok:)lock / unlockShow the lock screen; answer an unlock request.
resume()resumeCall when the app becomes active; the module re-checks the lock timer.
getState()getStateAnswered by onState.
registerPushToken(_ deviceToken: Data)pushTokenRegisteredAPNs token (provider "apns"); registerPushToken(_ token: String, provider:) for others. Queued until signed in.
handleDeepLink(_ url: URL) -> BoolnavigateMaps a universal link / scheme URL; false = not for the module.
send(_ command: QEEmbedCommand)anyThe raw bridge: .initialize, .login, .restoreSession, .logout, .setTheme, .clearTheme, .setLanguage, .navigate, .pushTokenRegistered, .lock, .unlock, .resume, .getState.

#Theming and language

At runtime
controller.setTheme(QEEmbedTheme(mode: .light, primary: "#2563eb"))   // any subset; the module answers theme_applied
controller.setTheme(nil)                                               // back to the brand's own theme
controller.setLanguage("de")                                           // RTL languages flip the layout

Text colours you pass are checked for 4.5:1 readability; an unreadable value is replaced by the module's own and onThemeApplied tells you what was applied. The OS dark-mode switch does not override your theme.

navigate + QEEmbedDeepLink
controller.navigate(screen: "wallet")              // terminal | wallet | history | account | positions
controller.navigate(path: "/dashboard/positions")  // a module path
controller.listener.onNavigation = { path in print("module is at", path) }   // sync your tab bar / analytics

// Deep links: QEEmbedDeepLink maps three shapes to navigate
//   https://app.your-brand.com/trade/positions    (universal link on your host, prefix "/trade")
//   yourapp://trade/wallet                         (custom scheme, host "trade")
//   https://live.your-brand.com/dashboard/wallet   (the module's own address)
QEEmbedDeepLink.linkPrefix = "/trade"
QEEmbedDeepLink.schemeHost = "trade"
let handled = controller.handleDeepLink(url)       // false = not for the module, route it yourself

Only module paths are accepted (QEEmbedDeepLink.safePath): no scheme, no host, so a link can never move the web view to another site. The web view itself navigates only to baseURL's host; a target=_blank link never opens a second web view - it goes to onOpenExternal or Safari.

#Biometric lock

With lockEnabled the module locks after lockAfterSeconds in the background (and on lock()) and sends unlock_requested { reason }. The SDK runs QEEmbedBiometrics - Face ID / Touch ID with the device passcode as fallback (deviceOwnerAuthentication; a device without biometrics passes) - and answers unlock itself.

Lock
// default: the SDK runs Face ID / Touch ID (device passcode as fallback) and answers unlock itself
controller.unlockReason = "Unlock your trading account"

// your own prompt instead
controller.listener.onUnlockRequested = { reason in        // user | host | resume | timeout
    MyAuth.confirm { ok in controller.unlock(ok: ok) }
}
controller.listener.onUnlocked = { print("unlocked") }
controller.lock()                                          // lock now (for example on your own "Lock" button)

Call resume() whenever the app becomes active: the module re-checks the lock timer and answers state, plus unlock_requested when locked. unlock(ok: false) answers error unlock_denied and the lock screen stays.

#Push notifications

You register with APNs as today and hand the token over once. The SDK queues it until the user is signed in, sends pushTokenRegistered { token, platform: "ios", provider: "apns" } and the module answers push_registered { ok }. Trade, margin and deposit pushes for that user then go to that device.

AppDelegate
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
            guard granted else { return }
            DispatchQueue.main.async { application.registerForRemoteNotifications() }
        }
        return true
    }

    // APNs token -> the module (queued until the user is signed in; answered with push_registered)
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        TradingHost.shared.controller.registerPushToken(deviceToken)          // provider "apns"
    }

    // a tap on a trade / margin push opens the right screen inside the module
    // payload: userInfo["qe"] = { "path": "/dashboard/positions" } or { "screen": "positions" }
    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        if let target = QEEmbedPush.target(in: response.notification.request.content.userInfo) {
            if let screen = target.screen { TradingHost.shared.controller.navigate(screen: screen) }
            else if let path = target.path { TradingHost.shared.controller.navigate(path: path) }
        }
        completionHandler()
    }
}

QEEmbedPush.hexString(_:) turns the Data token into the hex string the module gets; QEEmbedPush.target(in:) returns nil for pushes that are not for the module.

#KYC upload

The module's KYC page uses a normal file input. WKWebView shows the photo / camera / files sheet itself - nothing to code, but the two Info.plist strings above must be present or the sheet will not appear. The sheet is presented from presentingViewController.

#Deposits and outside links

  • depositByHost = true (default): the Deposit button only sends deposit_requested { account, client_id } → onDepositRequested. Open your cashier; balances refresh by themselves. false = the platform cashier inside the module.
  • linksByHost = true (default): every link to another site goes to onOpenExternal, or to Safari when the closure is not set. The web view never leaves the module's origin.

#Test plan

Run on the demo brand before your store build:

  1. No session

    Load with no session: the neutral sign-in page shows and ready { authenticated: false } arrives.

  2. Token sign-in

    start(loginToken:) with a token from the demo Partner API: login arrives, the terminal shows, the token is no longer in the URL. Kill the app, start again without a token: signed in.

  3. Theme

    Dark / light, your primary colour, radius, font → theme_applied. Screenshots on a small phone (390 pt).

  4. Hand-overs

    Deposit button → deposit_requested; an outside link → open_external / Safari.

  5. Lock

    Background 5 min (or lock()) → lock screen → Face ID → unlocked.

  6. Push

    Token → push_registered { ok: true }; a test push with qe.screen opens that screen.

  7. KYC

    A photo from the camera and one from the library upload.

  8. Deep links

    From a cold start and from a running app, universal link and custom scheme.

  9. Rough conditions

    Rotate, switch the OS dark mode (the module keeps your theme), airplane mode on and off.

#Performance on low-end phones

  • The module is tuned for a throttled mobile profile (4× CPU slowdown, slow 4G).
  • Keep the web view warm: create the controller when your app starts, call start() early, show the tab later.
  • Do not recreate the web view per screen; use navigate. One module instance per app.

#Security notes

  • The session is stored in the keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - it never leaves the device or a backup.
  • The web view loads only the module's origin (about: and blob: aside); other URLs go to the system browser.
  • The one-time token is removed from the URL after the exchange and never stored.
  • The bridge accepts only messages from the module's own scripts (messageHandlerName = "qeEmbed"); commands are plain JSON passed to window.QEEmbed.handle.
  • No certificate pinning by design - the platform's certificates rotate automatically.