App SDK reference
The iOS (X3Kit), Android (io.x3growth.sdk), React Native (@x3growth/react-native) and Flutter
(x3growth) SDKs share one API with the same names. Common rules for every call:
- safe to call from any thread, never blocks the caller for IO, never throws (exceptions:
leadreports errors, and the React Nativeconfigurethrows aTypeErrorwithout a consent mode); - processed in call order on one serial queue;
- before
configurea no-op with a debug log; - nothing is sent before the purpose
analyticsis granted (leadexcepted).
Version of all four app SDKs: 1.0.0.
configure
| Platform | Signature |
|---|---|
| Swift | X3.configure(key: String, options: X3Options) |
| Kotlin | X3.configure(context: Context, key: String, options: X3Options) |
| TypeScript | X3.configure(key: string, options: X3Options): void |
| Dart | static Future<void> X3.configure(String key, X3Options options) |
Validates the key (pk_3x_ plus 30 characters; otherwise an error log and the SDK stays off), loads
the persisted state, registers lifecycle observers. Network activity starts only when analytics
is granted: then it fetches the app config, starts or continues the session, schedules the install
request and the automatic events. A second call with the same key is ignored; with another key it
logs an error and is ignored. Kotlin uses context.applicationContext; call it in
Application.onCreate.
Options
| Option | Type | Default | |
|---|---|---|---|
consent.mode | always · opt_in · opt_out | required, no default | consent modes |
consent.purposes.analytics | Bool | true | false means the SDK sends nothing |
consent.purposes.attribution | Bool | true | install attribution and conversion values |
consent.purposes.recordings | Bool | false | session recordings |
consent.purposes.adPlatforms | Bool | false | conversions to ad platforms |
consent.bufferUntilDecision | Bool | false | opt_in only: keep up to 200 events of this process in memory until a decision |
host | URL | https://api.x3growth.io | API root or your proxy, e.g. https://nordlicht.app/n3 |
recording.mode | off · wireframe | off | recordings |
recording.maskAllText | Bool | true | |
recording.maskAllImages | Bool | true | |
recording.screenshotScreens | [String] | [] | screen names or paths |
recording.indicator | pill · dot · hidden | pill | |
recording.sampleRate | Double 0…1 | 1.0 | per session |
recording.wifiOnly | Bool | false | |
recording.source | native · external | native | for wrappers only |
autoScreens | Bool | true | UIKit view controllers, Android activities (wrappers always pass false) |
autoAppEvents | Bool | true | app_install, app_open, app_update |
trackDeepLinks | Bool | true | event deep_link_open and link attribution in handleOpenURL |
test | Bool? | automatic | nil / null: debug builds are test traffic; true marks everything as test |
debug | Bool | false | verbose logs: os.Logger subsystem io.x3growth.sdk, Logcat tag X3 |
flushIntervalSeconds | Int 2…120 | 10 | the server config may lower or raise it |
wrapper | X3Wrapper? | nil | for wrappers only: name and version |
Spelling per platform: Swift X3ConsentOptions(mode: .optIn, purposes: .init(recordings: true)),
enum cases .always / .optIn / .optOut, .off / .wireframe, .pill / .dot / .hidden;
Kotlin X3ConsentMode.ALWAYS | OPT_IN | OPT_OUT, X3RecordingMode.OFF | WIREFRAME,
X3Indicator.PILL | DOT | HIDDEN, data classes with named arguments; TypeScript string unions
"always" | "opt_in" | "opt_out"; Dart enums X3ConsentMode.always | optIn | optOut.
Debug detection: Android FLAG_DEBUGGABLE; iOS simulator or a provisioning profile with
get-task-allow. The effective behaviour is the code options and the project's server config;
the stricter wins (recordings need recording.mode = wireframe, the purpose used and granted, and
recordings enabled in the project).
screen
| Platform | Signature |
|---|---|
| Swift | X3.screen(_ name: String, properties: X3Properties? = nil), SwiftUI .x3Screen(_ name: String) |
| Kotlin | X3.screen(name: String, properties: Map<String, Any>? = null) |
| TypeScript | X3.screen(name: string, properties?: X3Properties): void |
| Dart | X3.screen(String name, [Map<String, Object>? properties]) |
Ends the current screen view and starts a new one. With properties, the SDK also sends the event
screen_view with them. Screens matching the project's excluded screen paths are not tracked.
Screen paths. Names become paths:
- trimmed; empty →
/; - starting with
/(route paths): cut at?or#, repeated/collapsed, trailing/dropped, at most 200 characters (/(tabs)/home?ref=push→/(tabs)/home); - otherwise a name: the suffix
ViewController,Controller,Activity,Fragment,Screen,PageorViewis removed, camel case becomes kebab case, accents are removed, everything is lowercased and other characters become-(PaywallViewController→/paywall,SignUpViewController→/sign-up,Übersicht→/ubersicht,🔥 Deals→/deals).
On the server the app's bundle id is the host of these pageviews, so screens appear in page reports
as app.nordlicht/paywall.
track
| Platform | Signature |
|---|---|
| Swift | X3.track(_ name: String, properties: X3Properties? = nil) |
| Kotlin | X3.track(name: String, properties: Map<String, Any>? = null) |
| TypeScript | X3.track(name: string, properties?: X3Properties): void |
| Dart | X3.track(String name, [Map<String, Object>? properties]) |
name matches ^[a-z][a-z0-9_]{0,39}$ (otherwise dropped with a debug log; no case conversion).
Up to 20 properties; keys ^[A-Za-z][A-Za-z0-9_]{0,39}$; values string (up to 200 characters),
finite number or boolean. The event attaches to the current screen view; before the first screen of
a session it waits up to 2 seconds for one, then attaches to a screen view /. lead and
purchase are dropped here: use the dedicated calls. Event names blocked in the project are dropped
on the device.
identify
| Platform | Signature |
|---|---|
| Swift | X3.identify(userId: String? = nil, email: String? = nil, leadId: String? = nil) |
| Kotlin | X3.identify(userId: String?, email: String? = null, leadId: String? = null) |
| TypeScript | X3.identify(userId: string, traits?: { email?: string; leadId?: string }): void |
| Dart | X3.identify(String userId, {String? email, String? leadId}) |
Hashes the user id, e-mail (plus the Gmail variant) and lead id with SHA-256 on the device, stores
the hashes and sends them with the next batch and with the first batch of every new session until
reset. Computes the account token. Never creates a lead.
Users, logins and leads.
reset
X3.reset() on every platform. Forgets the identity and the account token, ends the current session
(its data is sent) and starts a new one. The install id stays.
lead
| Platform | Signature |
|---|---|
| Swift | try await X3.lead(_ lead: X3Lead), X3.lead(_ lead: X3Lead, completion: (Result<Void, X3Error>) -> Void) |
| Kotlin | suspend fun X3.lead(lead: X3Lead), X3.lead(lead: X3Lead, callback: X3Callback) |
| TypeScript | X3.lead(lead: X3Lead): Promise<void> (rejects with an Error whose code is an error code) |
| Dart | Future<void> X3.lead(X3Lead lead) (throws X3Exception(code)) |
Posts the inquiry once over TLS, immediately; retries up to 3 times (2, 8, 30 seconds) on network
errors and server errors, never stores it on disk. Works in every consent state, because the user
sends an inquiry on purpose; the install and session ids are attached only while analytics is
granted. The project's lead switch per app (Leads aus der App, default on), spam checks, CRM push,
team notifications and the webhook lead.created apply as for web leads.
purchase
| Platform | Signature |
|---|---|
| Swift | X3.purchase(_ purchase: X3Purchase), X3.purchase(_ transaction: StoreKit.Transaction) |
| Kotlin | X3.purchase(purchase: X3Purchase) |
| TypeScript | X3.purchase(purchase: X3Purchase): void |
| Dart | X3.purchase(X3Purchase purchase) |
Sends the event purchase (order_id, value rounded to 2 decimals, currency uppercased,
items, product, store) at once. The conversion is held until a server confirms the same order
id. The StoreKit 2 overload takes Transaction.id, productID, purchasedQuantity and, on iOS 16
and later, price and currency. Purchases.
purchaseAccountToken
| Platform | Signature |
|---|---|
| Swift | X3.purchaseAccountToken() -> UUID? |
| Kotlin | X3.purchaseAccountToken(): String? |
| TypeScript | X3.purchaseAccountToken(): Promise<string | null> |
| Dart | Future<String?> X3.purchaseAccountToken() |
A UUID version 5 of the bundle id and the hashed user id (namespace
ac8463a7-8c0f-4776-908d-ce30d53b369d, name <bundle id>\n<user id hash>), available after
identify. Pass it as StoreKit 2 appAccountToken and Play Billing setObfuscatedAccountId.
consent
| Platform | Signature |
|---|---|
| Swift | X3.consent(_ update: X3ConsentUpdate) |
| Kotlin | X3.consent(update: X3ConsentUpdate) |
| TypeScript | X3.consent(update: X3ConsentUpdate): void |
| Dart | X3.consent(X3ConsentUpdate update) |
Records explicit decisions per purpose (analytics, attribution, recordings, adPlatforms;
omitted purposes keep their state). Persisted before the next call runs.
Transitions.
consentAll
X3.consentAll(granted) on every platform (Swift X3.consentAll(_ granted: Bool)): grants or
denies every used purpose. The buttons Alle akzeptieren and Ablehnen of the consent sheet call
it.
consentState
| Platform | Signature |
|---|---|
| Swift | X3.consentState() -> X3ConsentState |
| Kotlin | X3.consentState(): X3ConsentState |
| TypeScript | X3.consentState(): Promise<X3ConsentState> |
| Dart | Future<X3ConsentState> X3.consentState() |
Returns the mode, the state of each purpose (granted, denied, pending, notUsed), optedOut
and needsDecision (mode opt_in, a used purpose still pending, not opted out).
onConsentChange
| Platform | Signature |
|---|---|
| Swift | X3.onConsentChange(_ handler: @escaping (X3ConsentState) -> Void) -> X3Subscription (main queue; cancel()) |
| Kotlin | X3.addConsentListener(listener: X3ConsentListener): X3Subscription (a fun interface, so X3.addConsentListener { state -> } works; main thread) |
| TypeScript | X3.onConsentChange(listener: (state: X3ConsentState) => void): () => void |
| Dart | Stream<X3ConsentState> X3.consentChanges |
Called after every change of the effective consent state, including opt-out.
setOptOut and isOptedOut
| Platform | Signature |
|---|---|
| Swift | X3.setOptOut(_ optOut: Bool), X3.isOptedOut() -> Bool |
| Kotlin | X3.setOptOut(optOut: Boolean), X3.isOptedOut(): Boolean |
| TypeScript | X3.setOptOut(optOut: boolean): void, X3.isOptedOut(): Promise<boolean> |
| Dart | Future<void> X3.setOptOut(bool optOut), Future<bool> X3.isOptedOut() |
true denies every used purpose with all effects (local data deleted); false returns to the
mode's states and keeps explicit decisions. The switch Messung erlauben in the settings helper is
bound to it.
flush
X3.flush() on every platform: sends queued data now, respecting a server's back-off after 429.
The SDK also sends every flushIntervalSeconds, when enough events are queued, when the app goes
to the background and after purchase.
handleOpenURL
| Platform | Signature |
|---|---|
| Swift | X3.handleOpenURL(_ url: URL) -> Bool |
| Kotlin | X3.handleOpenURL(uri: Uri): Boolean, X3.handleIntent(intent: Intent): Boolean |
| TypeScript | X3.handleOpenURL(url: string): Promise<boolean> (called automatically for Linking) |
| Dart | Future<bool> X3.handleOpenURL(Uri url) (called automatically by the plugin) |
Returns true when the URL carried X3 attribution: an x3c parameter, any of the 13 attribution
parameters, or a path <pathPrefix>/<code> on one of your link hosts (default prefix /n3/go).
Records it as link attribution and sends deep_link_open { x3 } (with trackDeepLinks). Never
stores the full URL. handleIntent reads intent.data and marks the intent as handled.
applyLinkToken
X3.applyLinkToken(token) on every platform: the same as a URL with x3c=<token>, for magic links
and apps that obtain a link token themselves (link tokens).
requestTrackingAuthorization
| Platform | Signature |
|---|---|
| Swift | await X3.requestTrackingAuthorization() -> X3TrackingAuthorization |
| Kotlin | – |
| TypeScript | X3.requestTrackingAuthorization(): Promise<X3TrackingAuthorization> (Android: "unavailable") |
| Dart | Future<X3TrackingAuthorization> X3.requestTrackingAuthorization() |
Shows Apple's ATT prompt (needs NSUserTrackingUsageDescription; without it returns the current
status and logs) and stores the status, which the next batch reports. Results: authorized,
denied, notDetermined, restricted, unavailable.
ids
| Platform | Signature |
|---|---|
| Swift | X3.installId: String?, X3.sessionId: String? |
| Kotlin | X3.installId, X3.sessionId |
| TypeScript | X3.ids(): Promise<{ installId: string | null; sessionId: string | null }> |
| Dart | Future<({String? installId, String? sessionId})> X3.ids() |
For support requests, server calls (sessionId for leads.create or events from your server) and
deletion by install id. null while analytics is not granted.
version
Swift X3.version, Kotlin X3.VERSION, TypeScript X3.version, Dart X3.version: "1.0.0".
eraseDeviceData
| Platform | Signature |
|---|---|
| Swift | await X3.eraseDeviceData() -> Bool (true when X3 confirmed the deletion) |
| Kotlin | suspend fun X3.eraseDeviceData() (throws X3Error), X3.eraseDeviceData(callback: X3Callback) |
| TypeScript, Dart | not in the 1.0.0 wrappers; delete by install id from your server (deleting a user) |
Sends the install id and the device secret once to X3, which deletes this install's sessions,
recordings and journey keys after checking the secret. Then the SDK deletes its local files
(queue, install id, device secret, identity, config cache) except the consent choice, and
continues with a new install id. Call setOptOut(true) as well when the user also wants no
further measurement. If X3 cannot be reached or refuses, the local data is kept so you can try
again. It needs an existing install: after an analytics denial the SDK has already deleted
the install id and there is nothing left to erase on the device. Details:
deleting the data of this device.
Types
| Type | Fields |
|---|---|
X3Properties | up to 20 entries of string, number or boolean |
X3Lead | form?, email?, phone?, firstName?, lastName?, company?, message?, answers? (questionnaire → question → option), marketingConsent?, crm? (CRM aliases), skipCrm (default false), fillMs?, eventId? |
X3Purchase | orderId (required), value, currency, productId?, quantity (default 1), store (apple, google, other; default by platform) |
X3ConsentUpdate | analytics?, attribution?, recordings?, adPlatforms? |
X3PurposeState | granted, denied, pending, notUsed |
X3ConsentState | mode, analytics, attribution, recordings, adPlatforms, optedOut, needsDecision |
X3TrackingAuthorization | authorized, denied, notDetermined, restricted, unavailable |
X3Subscription | cancel() |
X3ConsentTexts | every text of the consent helpers, all optional, plus appName and the privacy policy URL (default texts) |
Automatic events
Sent by the SDK when autoAppEvents (or the respective call) is on:
| Event | When | Properties |
|---|---|---|
app_install | first session of an install (once per install id) | none |
app_open | start of every session | cold (process start or return from the background) |
app_update | first session after the app version changed | from (previous version) |
deep_link_open | handleOpenURL with trackDeepLinks | x3 (the URL carried X3 attribution) |
purchase | X3.purchase | order_id, value, currency, items, product, store |
screen_view | screen(name, properties) with properties | your properties |
Conventions (your app sends them; dashboard features read them): account_created (once at signup),
login (every login, property method), trial_started (also created from store notifications).
Sessions: a session continues across app restarts while the last activity is less than 30 minutes
ago; after 30 minutes in the background or closed, the next start begins a new session with
app_open. Returning to a screen after less than 30 seconds in the background continues the same
screen view. Data is queued on disk (at most 1 000 items or 7 days) while analytics is granted.
Framework helpers
| Helper | iOS | Android | React Native | Flutter |
|---|---|---|---|---|
| screen names | X3ScreenNameProviding, SwiftUI .x3Screen("Paywall") | X3ScreenNameProvider, Compose X3TrackNavigation(navController) | useX3NavigationTracking(ref), useX3ExpoRouterTracking() | X3NavigatorObserver() |
| masking | view.x3Masking = .mask / .unmask / .inherit, .x3Mask(), .x3Unmask() | X3.setMasking(view, X3Masking.MASK / UNMASK / INHERIT), Modifier.x3Mask(), Modifier.x3Unmask() | <X3Mask>, <X3Unmask> | not in 1.0.0 (the Flutter view is recorded as one masked area) |
| tap target names | view.x3Anchor, .x3Anchor(…), accessibilityIdentifier | X3.setAnchor(view, …), X3.setAnchorResolver { view -> String? }, Modifier.x3Anchor(…), testTag | testID | not in 1.0.0 |
| consent sheet | X3ConsentView(texts:onDone:), X3.presentConsent(from:texts:completion:) | X3ConsentDialog.show(activity, texts) { }, Compose X3ConsentSheet(...) | <X3ConsentSheet visible onDone texts />, useX3Consent() | X3ConsentSheet.show(context, texts: …) |
| settings | X3PrivacySettingsView(texts:), X3PrivacySettingsViewController | X3PrivacySettingsDialog.show(activity, texts), Compose X3PrivacySettings(...) | <X3PrivacySettings texts /> | X3PrivacySettings(texts: …) |
For wrappers
Public on the native SDKs for wrapper authors (React Native and Flutter use them): the option
wrapper (name react-native or flutter and version), the consent listener, iOS
UIView.x3Masking / UIView.x3Anchor, Android X3.setMasking, X3.setAnchor,
X3.setAnchorResolver.
External recording source: iOS X3.recordExternal(screenPath:events:fullSnapshot:) and Android
X3.recordExternal(screenPath, events, fullSnapshot) with recording.source = external accept
rrweb events produced by the wrapper for the current screen view; the native recorder packs and
uploads them under the same consent, sampling and budget rules. No wrapper of version 1.0.0 uses
it yet (Flutter records its view as one masked area).
Error codes
lead reports these errors (Swift X3Error, Kotlin X3Error, TypeScript error.code, Dart
X3Exception.code):
| Code | Meaning |
|---|---|
notConfigured | configure was not called or the key is invalid |
invalid | the server rejected the input (field limits, e-mail format) |
disabled | leads from this app are switched off (Leads aus der App) |
rateLimited | more than 3 inquiries per minute from this device |
network | no connection after the retries |
server(status) | the server answered with another error status |
Batches handle server answers themselves: 400 drops the batch, 403 (key, app_mismatch,
disabled) stops sending until the next successful config fetch, 413 splits the batch, 429
waits for retryAfter, 503 retries with back-off (5 s, 30 s, 2 min, 10 min, then every 30 min).
The wire format and every endpoint: app ingest API.