iOS SDK (Swift)

X3Kit is the native iOS SDK (iOS 15 or later, Xcode 16 or later, no third-party dependencies). It has three products:

ProductCocoaPods subspecContains
X3KitX3Kit/Core (default)sessions, screens, events, identify, leads, purchase hints, consent, install attribution, deep links
X3KitUIX3Kit/UIconsent sheet and privacy settings (SwiftUI and UIKit)
X3KitReplayX3Kit/Replaywireframe session recordings

Every method is safe to call from any thread, never throws (except lead, which reports errors), never blocks the caller and is processed in call order on one serial queue. Before configure every call is a no-op with a debug log.

Install

Swift Package Manager: in Xcode File › Add Package Dependencies…, URL https://github.com/X3Growth/x3-sdk-ios, rule Up to Next Major Version from 1.0.0. In a Package.swift:

Package.swift
dependencies: [
  .package(url: "https://github.com/X3Growth/x3-sdk-ios", from: "1.0.0"),
],
targets: [
  .target(name: "Nordlicht", dependencies: [
    .product(name: "X3Kit", package: "x3-sdk-ios"),
    .product(name: "X3KitUI", package: "x3-sdk-ios"),
    .product(name: "X3KitReplay", package: "x3-sdk-ios"),
  ]),
]

CocoaPods:

Podfile
pod 'X3Kit', '~> 1.0'
pod 'X3Kit/UI', '~> 1.0'      # consent sheet and settings
pod 'X3Kit/Replay', '~> 1.0'  # recordings

Configure

Call X3.configure(key:options:) once, as early as possible: in your App initializer (SwiftUI) or in application(_:didFinishLaunchingWithOptions:) (UIKit).

NordlichtApp.swift
import X3Kit

X3.configure(key: "pk_3x_YOUR_APP_KEY", options: X3Options(
  consent: X3ConsentOptions(mode: .optIn, purposes: .init(recordings: true)),
  host: URL(string: "https://nordlicht.app/n3")!,
  recording: X3RecordingOptions(mode: .wireframe)
))
  • consent.mode is required: .always, .optIn or .optOut (consent in apps). purposes says which purposes your app uses at all: analytics and attribution default to true, recordings and adPlatforms to false.
  • host: your first-party proxy (recommended) or the default https://api.x3growth.io.
  • Further options with their defaults: app SDK reference › Options.
  • A second configure with the same key is ignored; with another key it logs an error and is ignored.

The SDK keeps its files in Application Support/io.x3growth.sdk/, excluded from backups and independent of the key, so a key roll keeps the install. It uses no UserDefaults and no keychain.

Screens

UIKit view controllers are tracked automatically when they appear (autoScreens, default on). Containers (UINavigationController, UITabBarController, UIPageViewController, UISplitViewController), UIHostingController, system and private classes are skipped. The class name becomes the path (PaywallViewController → /paywall, SignUpViewController → /sign-up). Choose your own name with X3ScreenNameProviding:

final class PlanDetailViewController: UIViewController, X3ScreenNameProviding {
  var x3ScreenName: String { "Plan" }
}

SwiftUI has no view controllers per screen, so mark screens with the modifier (it calls X3.screen in onAppear):

PaywallView().x3Screen("Paywall")

Or call X3.screen(_:properties:) yourself. Names are normalized (screen paths); route-like names that start with / are kept as paths (/plans/yearly). Properties are sent as the event screen_view. Screens matching the project's excluded paths are not tracked.

Events

X3.track("plan_selected", properties: ["plan": "year", "trial": true, "price": 59.99])

Names are lowercase snake_case (^[a-z][a-z0-9_]{0,39}$), up to 20 properties with string, integer, double or boolean values. lead and purchase cannot be sent with track; use the dedicated calls. The SDK sends app_install, app_open and app_update itself (automatic events).

Identify

After signup, after every login and at app start when the user is still logged in:

X3.identify(userId: user.id, email: user.email)   // leadId: when you know the CRM or X3 lead id
X3.track("account_created", properties: ["method": "apple"])  // once, at signup
X3.track("login", properties: ["method": "password"])         // every login
X3.reset()                                                      // at logout

Only SHA-256 hashes leave the device. Details: users, logins and leads.

Leads

do {
  try await X3.lead(X3Lead(form: "consultation", email: email, firstName: name, phone: phone,
                           message: text, marketingConsent: newsletter))
} catch let error as X3Error {
  // .invalid, .disabled, .rateLimited, .network, .server(status), .notConfigured
}

X3.lead(_:completion:) is the callback variant. Leads work in every consent state, because the user sends an inquiry on purpose; they are posted once over TLS and never stored on the device.

Purchases

The SDK reports a purchase as a hint; the conversion is held until a server confirms the same order id (purchases and subscriptions). Pass the account token to StoreKit so that App Store Server Notifications name the person:

Store.swift
import StoreKit
import X3Kit

func buy(_ product: Product) async throws {
  var options: Set<Product.PurchaseOption> = []
  if let token = X3.purchaseAccountToken() { options.insert(.appAccountToken(token)) }
  let result = try await product.purchase(options: options)
  if case .success(let verification) = result, case .verified(let transaction) = verification {
    X3.purchase(transaction)          // order id, product, quantity, price and currency from StoreKit 2
    await transaction.finish()
  }
}

X3.purchase(transaction) takes Transaction.id as the order id, productID, purchasedQuantity and, on iOS 16 and later, price and currency. Without StoreKit 2 use X3.purchase(X3Purchase(orderId: "2000000123456789", value: 59.99, currency: "EUR", productId: "pro_yearly")). purchaseAccountToken() is nil before identify; it is derived from the bundle id and the hashed user id. It is a pseudonymous identifier: it reveals neither the user id nor the e-mail address, but it is linked to the person and counts as personal data.

Hand every URL that opens the app to the SDK. It returns true when the URL carried X3 attribution (an x3c token, UTM parameters or click ids, or an X3 link path) and never stores the full URL.

// SwiftUI
ContentView()
  .onOpenURL { X3.handleOpenURL($0) }
  .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
    if let url = activity.webpageURL { X3.handleOpenURL(url) }
  }

// UIKit scene delegate
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
  if let url = userActivity.webpageURL { X3.handleOpenURL(url) }
}
func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
  contexts.forEach { X3.handleOpenURL($0.url) }
}

For X3 links (https://nordlicht.app/n3/go/<code>) to open the installed app directly, add the Associated Domains capability applinks:nordlicht.app and serve an apple-app-site-association file on that domain that includes the link path:

public/.well-known/apple-app-site-association
{
  "applinks": {
    "details": [
      { "appIDs": ["ABCDE12345.app.nordlicht"], "components": [{ "/": "/n3/go/*", "comment": "X3 links" }] }
    ]
  }
}

Magic links from your own e-mails pass a link token with X3.applyLinkToken(_:) (link tokens).

Apple Ads (AdServices)

When the purpose attribution is granted, the SDK calls AAAttribution.attributionToken() once per install (iOS 14.3 or later, skipped on the simulator) and sends it with the first open. The X3 server exchanges the token with Apple for campaign, ad group, keyword and ad. No ATT prompt and no IDFA are needed. The SDK never links AdSupport and never reads identifierForVendor. Apple Ads attribution.

App Tracking Transparency

X3 itself needs no ATT prompt. You need it only when you want iOS conversions with user data (hashed e-mail, user id) reported to Meta, TikTok or Google (ATT rule). Then ask at a sensible moment, for example after onboarding:

let status = await X3.requestTrackingAuthorization()   // .authorized, .denied, .notDetermined, .restricted, .unavailable

The call needs NSUserTrackingUsageDescription in Info.plist (otherwise it returns the current status and logs a warning). The SDK reads the status at every session start and sends it with the next batch.

Info.plist keys

KeyWhenValue
NSAdvertisingAttributionReportEndpointcopies of SKAdNetwork postbackshttps://nordlicht.app (your registrable domain)
AdAttributionKit › AttributionCopyEndpointcopies of AdAttributionKit postbackshttps://nordlicht.app
AdAttributionKit › OptInForReengagementPostbackCopiesalso copies of re-engagement postbacks (iOS 18+)true (Boolean)
SKAdNetworkItemsad networks run app campaigns for youthe identifiers from each network's documentation
NSUserTrackingUsageDescriptiononly when you call requestTrackingAuthorization()your text, see below
Associated Domains applinks:nordlicht.appX3 links and Universal Linksyour domain
Info.plist
<key>NSAdvertisingAttributionReportEndpoint</key>
<string>https://nordlicht.app</string>
<key>AdAttributionKit</key>
<dict>
  <key>AttributionCopyEndpoint</key>
  <string>https://nordlicht.app</string>
  <key>OptInForReengagementPostbackCopies</key>
  <true/>
</dict>
<key>NSUserTrackingUsageDescription</key>
<string>Wir nutzen diese Erlaubnis, um zu messen, welche Werbung zu Deiner Anmeldung geführt hat.</string>

Example texts for the ATT prompt (adapt them, Apple reviews them): DE "Wir nutzen diese Erlaubnis, um zu messen, welche Werbung zu Deiner Anmeldung geführt hat." EN "We use this permission to measure which ad led to your sign-up." Apple sends postback copies to fixed paths on your registrable domain (subdomains are ignored); the proxy routes for them are in postback copies. NSAdvertisingAttributionReportEndpoint also activates Private Click Measurement reports to /.well-known/private-click-measurement/, which X3 does not process.

Privacy manifest

Each product ships a PrivacyInfo.xcprivacy: NSPrivacyTracking is false, no tracking domains, collected data types linked to the user and not used for tracking (user id, e-mail address, device id, product interaction, advertising data, purchase history, coarse location for analytics; name, e-mail, phone and other user content for the lead form) and one required-reason API: system boot time (35F9.1) for monotonic durations. Xcode merges it into your app's privacy report. Your own App Store privacy details and, when you send iOS user data to ad platforms, your tracking declaration stay your responsibility: privacy details for the stores.

Recordings

Add X3KitReplay, set recording: X3RecordingOptions(mode: .wireframe) and use the purpose recordings (purposes: .init(recordings: true)). The project must allow recordings (Einstellungen › Aufnahmen & Heatmaps). Control masking per view:

cardNumberLabel.x3Masking = .mask        // always hidden
priceLabel.x3Masking = .unmask           // text visible (inputs stay masked)
buyButton.x3Anchor = "buy_button"        // tap target name (else accessibilityIdentifier)

Text(plan.price).x3Unmask()              // SwiftUI
Map(...).x3Mask()
Button("Buy") { … }.x3Anchor("buy_button")

SwiftUI draws text into internal layers that X3 never reads, so SwiftUI text is always a placeholder shape in wireframes. .x3Unmask() makes it visible only in screenshot mode (screens listed in screenshotScreens); UIKit text becomes visible in wireframes with .unmask. A SwiftUI TextField is recorded as an input without its value, a SecureField as a masked block. More: session recordings in apps.

X3KitUI brings two screens with German and English default texts (device language de* → German), Dynamic Type, VoiceOver labels, 44 pt targets and dark mode:

// SwiftUI
X3ConsentView(texts: X3ConsentTexts(appName: "Nordlicht", privacyPolicyURL: URL(string: "https://nordlicht.app/privacy"))) { state in }
X3PrivacySettingsView(texts: X3ConsentTexts())

// UIKit
X3.presentConsent(from: viewController, texts: X3ConsentTexts()) { state in }
navigationController?.pushViewController(X3PrivacySettingsViewController(), animated: true)

The helpers never show themselves; you decide when. Every text can be overridden. Consent UI helpers.