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:
| Product | CocoaPods subspec | Contains |
|---|---|---|
X3Kit | X3Kit/Core (default) | sessions, screens, events, identify, leads, purchase hints, consent, install attribution, deep links |
X3KitUI | X3Kit/UI | consent sheet and privacy settings (SwiftUI and UIKit) |
X3KitReplay | X3Kit/Replay | wireframe 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:
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:
pod 'X3Kit', '~> 1.0'
pod 'X3Kit/UI', '~> 1.0' # consent sheet and settings
pod 'X3Kit/Replay', '~> 1.0' # recordingsConfigure
Call X3.configure(key:options:) once, as early as possible: in your App initializer (SwiftUI) or
in application(_:didFinishLaunchingWithOptions:) (UIKit).
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.modeis required:.always,.optInor.optOut(consent in apps).purposessays which purposes your app uses at all:analyticsandattributiondefault totrue,recordingsandadPlatformstofalse.host: your first-party proxy (recommended) or the defaulthttps://api.x3growth.io.- Further options with their defaults: app SDK reference › Options.
- A second
configurewith 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 logoutOnly 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:
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.
Deep links and Universal Links
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:
{
"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, .unavailableThe 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
| Key | When | Value |
|---|---|---|
NSAdvertisingAttributionReportEndpoint | copies of SKAdNetwork postbacks | https://nordlicht.app (your registrable domain) |
AdAttributionKit › AttributionCopyEndpoint | copies of AdAttributionKit postbacks | https://nordlicht.app |
AdAttributionKit › OptInForReengagementPostbackCopies | also copies of re-engagement postbacks (iOS 18+) | true (Boolean) |
SKAdNetworkItems | ad networks run app campaigns for you | the identifiers from each network's documentation |
NSUserTrackingUsageDescription | only when you call requestTrackingAuthorization() | your text, see below |
Associated Domains applinks:nordlicht.app | X3 links and Universal Links | your domain |
<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.
Consent UI
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.