Browse docsOverview
iOS SDK

iOS SDK

ReflectSDK for native iOS — a Swift facade over the shared ReflectCore engine, consumable from Swift and Objective-C. This is the native SDK for a UIKit or SwiftUI app; if you ship through Flutter, React Native or Unity, use that wrapper instead.

One artifact serves Swift and Objective-C — there is no separate Obj-C SDK

ReflectSDK 1.0.0 is written in Swift and is fully usable from a pure Objective-C app. That takes deliberate work, because most of a modern Swift API is invisible to Objective-C: structs, enums with associated values, generics, default arguments and async/await simply do not cross the bridge.

So the SDK ships an @objc RFL* facade over NSObject-derived parameter classes — RFLReflect, RFLConfig,RFLDeepLinkData, RFLAttributionData, RFLLifecycle,RFLStandardEvents and friends — each with an explicit selector so nothing is mangled, and with a completion-handler twin of every async method. A pure Objective-C sample app (Examples/ObjCSample, which contains no Swift at all) plus a suite of interop tests keep it honest: they assert that every type reaches the Obj-C runtime under its RFL* name and only that name, and that every completion block fires exactly once, on the main queue.

Installation (CocoaPods)

Both pod lines below are required. Neither ReflectSDK nor the shared ReflectCore engine is published on CocoaPods trunk — both are consumed from their GitHub tags — and CocoaPods does not follow a git-sourced pod's dependency to another git source. Declaring only ReflectSDK fails with"None of your spec sources contain a spec satisfying the dependency: ReflectCore (~> 1.1)".

# Podfile
platform :ios, '13.0'

target 'YourApp' do
  pod 'ReflectSDK',  :git => 'https://github.com/bablu147/reflect-sdk-ios.git', :tag => '1.0.1'
  pod 'ReflectCore', :git => 'https://github.com/bablu147/reflect-ios.git',     :tag => '1.1.4'
end
LANG=en_US.UTF-8 pod install

pod needs a UTF-8 locale or it crashes inside unicode_normalize. Afterwards open the generated .xcworkspace, never the.xcodeproj.

Pin both tags exactly. ReflectCore 1.1.4 fixes first-session delivery (install latched at durable persistence, retry ladder, activation + background flush); 1.1.3 fixes jailbreak detection false-positiving on all real iPhones; 1.1.2 adds the batch-size/byte clamps; 1.1.1 is the first core release that does not brand every event flutter on the wire; 1.1.0 carries the privacy engine but has that branding bug, and 1.0.0 predates the privacy engine this facade's gate assumes. Neither is a valid target.

Swift Package Manager is not supported

Known limitation, not a preference
There is no Package.swift in this SDK, and shipping one that cannot resolve would be worse than shipping none. The reason is upstream: ReflectCore imports UIKit and is distributed as a CocoaPod, and the core repository's ownPackage.swift is a macOS executable target for its privacy test suite — it deliberately excludes the production core sources, so there is no SPM library product for a facade manifest to depend on. Unblocking it means restructuring the core repository into an iOS library product exporting ReflectCore. Until then, CocoaPods is the only supported integration path.

Requirements

  • iOS 13.0+, Swift 5.0+, Xcode 15+
  • Depends on the shared ReflectCore engine (~> 1.1, resolving to 1.1.4) — no other third-party dependencies
  • AdServices and AppTrackingTransparency are weak-linked; AdAttributionKit is deliberately not linked
  • Sends sdk_version = ios-1.0.0 on the wire, to https://api.reflect.cloud by default

Quick start

Register listeners BEFORE initialize
While no listener is attached the core buffers exactly one deep link and scrubs clickId and every query parameter from the buffered copy. Getting this order wrong permanently loses first-install click attribution — it cannot be recovered on a later launch.

Swift — UIKit AppDelegate

import ReflectSDK

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        // 1. Listeners first.
        Reflect.onDeepLink { link in
            print("deep link:", link.url, link.path ?? "", link.source)   // .cold / .warm / .deferred
        }
        Reflect.onAttributionChanged { attribution in
            print("partner:", attribution.partner ?? "-")
        }

        // 2. Capture the cold-launch URL (buffered in memory, replayed after init).
        Reflect.application(application, didFinishLaunchingWithOptions: launchOptions)

        // 3. Initialize.
        Reflect.initialize(ReflectConfig(
            appKey: "YOUR_APP_KEY",
            signingSecret: "YOUR_APP_HMAC_SECRET",   // required for shared_hmac apps
            debug: true
        ))
        return true
    }
}

// Anywhere afterwards:
Reflect.setUserId("user_123")
Reflect.trackEvent("level_complete", properties: ["level": 5, "score": 42_000])
Reflect.trackEvent(.purchase)                       // type-safe standard name

// async twins where a value comes back:
let attribution = await Reflect.getAttribution()
let link        = await Reflect.getInitialDeepLink()

Objective-C — same app, no Swift

@import ReflectSDK;

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    // 1. Listeners first.
    [RFLReflect onDeepLink:^(RFLDeepLinkData *link) {
        NSLog(@"deep link: %@ %@", link.url, link.path ?: @"");
    }];
    [RFLReflect onAttributionChanged:^(RFLAttributionData *attribution) {
        NSLog(@"partner: %@", attribution.partner ?: @"-");
    }];

    // 2. Capture the cold-launch URL.
    [RFLLifecycle application:application didFinishLaunchingWithOptions:launchOptions];

    // 3. Initialize.
    RFLConfig *config = [[RFLConfig alloc] initWithAppKey:@"YOUR_APP_KEY"];
    config.signingSecret = @"YOUR_APP_HMAC_SECRET";
    config.debug = YES;
    [RFLReflect initializeWithConfig:config];
    return YES;
}

@end

// Anywhere afterwards:
[RFLReflect setUserId:@"user_123"];
[RFLReflect trackEvent:@"level_complete" properties:@{@"level": @5}];

// Completion-handler twins of the async methods:
[RFLReflect getAttributionWithCompletion:^(RFLAttributionData *a) { /* main queue */ }];
[RFLReflect getInitialDeepLinkWithCompletion:^(RFLDeepLinkData *link) { /* main queue */ }];

New apps default to server policy shared_hmac: signingSecret is required, and unsigned traffic is rejected with signature_required. Inject the value through ignored local/CI configuration; never commit a real one.

App lifecycle wiring

The core installs no URL handling of its own — iOS delivers URLs toyour delegate and there is no supported way to intercept that. Everything below is one line per callback.

BehaviourAutomaticYou must wire
Deferred deep link (post-install resolution)Yes — core, on initialize
Listener delivery on the main queueYesregister before initialize
Last-link cache + late-subscriber replayYes
Sessions, batching, retry, signed ingestYes
Cold launch (launchOptions)NoReflect.application(_:didFinishLaunchingWithOptions:)
Cold launch via a sceneNoReflect.scene(willConnectWith:)
Warm custom-scheme URLNoReflect.handle(url:) / Reflect.scene(openURLContexts:)
Universal LinkNoReflect.continue(userActivity:)

(a) UIKit — AppDelegate only

func application(_ app: UIApplication, open url: URL,
                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    Reflect.handle(url: url)
    return false                       // observe only; don't claim exclusive handling
}

func application(_ application: UIApplication, continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    Reflect.continue(userActivity: userActivity)
    return false
}

Objective-C uses [RFLLifecycle handleURL:url] and [RFLLifecycle continueUserActivity:userActivity].

(b) UIKit — SceneDelegate

Keep the listeners and initialize in the AppDelegate; the scene supplies the link. With scenes, launchOptions is usually empty — callingReflect.application(_:didFinishLaunchingWithOptions:) anyway is harmless and keeps the non-scene path working.

func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {
    Reflect.scene(willConnectWith: connectionOptions)   // covers urlContexts AND userActivities
}

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    Reflect.scene(openURLContexts: URLContexts)
}

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    Reflect.continue(userActivity: userActivity)
}

(c) SwiftUI

The SwiftUI App lifecycle has no delegate by default, so use the view modifiers:

@main
struct SampleApp: App {
    init() {
        Reflect.onDeepLink { DeepLinkRouter.shared.handle($0) }
        Reflect.onAttributionChanged { AttributionStore.shared.update($0) }
        Reflect.initialize(ReflectConfig(appKey: "YOUR_APP_KEY",
                                         signingSecret: "YOUR_APP_HMAC_SECRET"))
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { Reflect.handle(url: $0) }
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) {
                    Reflect.continue(userActivity: $0)
                }
        }
    }
}

Info.plist keys

None of this can be automated for a native app — there is no build post-processor here.

KeyRequired whenNote
NSUserTrackingUsageDescriptionany ATT promptWithout it, requesting tracking authorization crashes the app.
NSPrivacyTrackingDomains (your app's PrivacyInfo.xcprivacy)opt-in onlyDeclaring the ingest host blocks all SDK traffic until ATT is authorized — undecided/denying users deliver nothing. Default recommendation: leave it undeclared (iOS zeroes the IDFA without ATT anyway). See the transport gate below.
SKAdNetworkItemsusing SKANOne SKAdNetworkIdentifier dict per ad network you buy from.
NSAdvertisingAttributionReportEndpointSKAN with a custom endpointe.g. https://api.reflect.cloud
CFBundleURLTypescustom-scheme deep linksCFBundleURLSchemes = ["yourscheme"]
com.apple.developer.associated-domains (entitlement)Universal Linksapplinks:links.yourdomain.com
NSAppTransportSecurityneverThe SDK is HTTPS-only; leave arbitrary loads off.

The SDK ships its own PrivacyInfo.xcprivacy inside ReflectSDK.bundle, but a bundle manifest cannot declare NSPrivacyTrackingDomains on your behalf — that one belongs to your app.

App Tracking Transparency

Reflect.requestTrackingAuthorization { status in
    // .authorized / .denied / .restricted / .notDetermined / .gateClosed / .unavailable
}

let status = await Reflect.requestTrackingAuthorization()   // async twin

// Raw wire string, for parity with the Flutter/RN surface:
Reflect.requestTrackingAuthorizationRaw { raw in /* "authorized" | "denied" | ... */ }
[RFLReflect requestTrackingAuthorizationWithCompletion:^(RFLTrackingStatus status) {
    NSLog(@"ATT: %ld", (long)status);
}];
StatusMeaning
.authorized / .denied / .restricted / .notDeterminedThe user's real answer, as reported by the system.
.gateClosed (98)The privacy gate was shut at call time, so no prompt was shown and the call never reached ATTrackingManager. Grant consent and call again. Also returned when a privacy transition lands while the prompt is up — the answer is withheld because the posture changed mid-flight.
.unavailable (99)This OS cannot present a prompt: iOS < 14, the framework is missing, or the call timed out / returned something unusable. It does not mean the gate was closed.
The prompt only appears once the privacy gate is open

An app that presents ATT before it collects Reflect consent gets.gateClosed and no prompt. That is the intended behaviour, and the distinct status exists so it is discoverable — a closed gate must never be reported as.unavailable, which would tell a developer on a modern device that their OS cannot do ATT.

Other details: the completion is always delivered on the main queue; the deadline is 600 s (a user can leave the app parked on the system prompt), after which you get .unavailable; ReflectConfig(autoRequestIosTracking: true)presents the prompt during initialize instead; and if you callATTrackingManager.requestTrackingAuthorization yourself, tell the SDK — a status change it never learns about leaves the first install batch parked until the next launch.

The tracking-domain transport gate. An app that declares its ingest host inNSPrivacyTrackingDomains — an opt-in posture; the default since 2026-08 is to leave the host undeclared — has every connection to that host refused by iOS until ATT is answered. It surfaces asNSURLErrorNotConnectedToInternet while the status is stillnot_determined. The core recognises exactly that pair, parks the drain against a 5-minute ceiling and persists no backoff deadline, so the queue is preserved and flushes once the prompt is answered. A denial does not reopen the domain. "No events until the second launch" is almost always this, not a bug.

SKAdNetwork

SKAN is armed automatically on first launch (unless autoRegisterSkan: false), and conversion values are driven automatically from trackRevenue,trackPurchase and trackSubscription using a bucket schema fetched from GET {baseUrl}/skan/cv-schema?app_key=… (24 h TTL, exactly 64 entries).Without a cached schema no conversion value is ever sent — the SDK never invents buckets. Non-increasing updates are suppressed.

Reflect.updateConversionValue(7, coarseValue: "medium", lockWindow: false) { result in
    if result.success { print("sent via", result.method ?? "-") }
    else { print("failed:", result.error ?? "-") }
}

// async twin — the completion parameter has NO default value, so that
// Reflect.updateConversionValue(7) inside an async function is unambiguous.
let result = await Reflect.updateConversionValue(7)
[RFLReflect updateConversionValue:7 coarseValue:@"medium" lockWindow:NO
                       completion:^(RFLConversionValueResult *r) { /* ... */ }];

A fineValue outside 0...63 fails client-side withfine_value_out_of_range before anything is dispatched. Other failure codes:measurement_disabled, privacy_state_changed,skan_not_available, collection_blocked (the privacy gate refused).

SKAN cannot be exercised on the Simulator
StoreKit ad-network attribution is a device-and-App-Store facility. On the Simulator the calls are accepted but no postback is ever produced, so an end-to-end SKAN check needs a real device and a real install. Test the SDK-side plumbing (schema fetch, bucket selection, the_skan_cv event) on device.

Privacy & consent

The facade runs its own fail-closed gate in front of the core's — the same contract the native Android SDK implements. It exists for two reasons the core cannot cover: there is no server-side PII strip for the sticky email the facade injects, and the bridge is asynchronous while the public API is not — setConsent(false) must closesynchronously at the call boundary, because the app has already told the user they opted out.

Reflect.setConsent(false) { persisted in /* false => the denial did not durably persist */ }
Reflect.setEmail("[email protected]")        // ignored while denied
Reflect.trackEvent("checkout")              // dropped while denied

Reflect.setConsent(true) { _ in }           // waits for native confirmation
Reflect.setEmail("[email protected]")        // re-supply after the grant
  • setConsent, setEnabled, setAdvertisingConsent and setThirdPartySharing each take an optional completion carrying a Bool. The core returns real privacy_persistence_failed errors on exactly these four — without the completion you could never learn that a GDPR opt-out failed to persist.
  • Pass the raw email; the server hashes it. Never hash it yourself. It stays sticky until clearEmail(), a consent denial, deleteUserData(), or replacement.
  • setEnabled(false) suppresses reversibly; a consent denial or deleteUserData() destroys irreversibly. Only the latter clears the facade's local email, global-property and deep-link caches.
  • Calls made before initialize are folded into the init arguments, but only the restrictive ones: a pre-init setConsent(false) / setEnabled(false) is carried forward, a pre-init grant is not.
  • During warm-up (initialize issued, posture not yet read back) measurement calls wait in a bounded FIFO. If the readback fails the gate stays closed and the FIFO is discarded — an unknown posture is treated as a denial.

See Privacy & consent for the server-side model.

API surface

Every method below is available from Objective-C on RFLReflect under an explicitRFL* selector. Methods that return a value also have an async twin in Swift (getAttribution, getAttributionWithTimeout,getInitialDeepLink, getLastDeepLink, resolveDeepLink,getInstallUuid, isEnabled, deleteUserData,verifyPurchase, verifyAndTrackPurchase,requestTrackingAuthorization, updateConversionValue,getDebugState).

GroupAPI
LifecycleReflect.initialize(_ config:) · initialize(baseUrl:) · isInitialized · isDebugMode · sdkVersion ("ios-1.0.0")
Events & revenuetrackEvent(_:) · trackEvent(_:properties:) · trackEvent(_:options:) · trackEvent(_ name: ReflectEventName, properties:) · trackRevenue(_:) · trackPurchase(_:) · trackSubscription(_:) · trackAdRevenue(_:) · verifyPurchase(_:completion:) · verifyAndTrackPurchase(_:completion:) · flush()
Identity & propertiessetUserId(_:) · clearUserId() · setEmail(_:) · clearEmail() · setUserProperties(_:) · setExternalDeviceId(_:) (nil clears) · userId · installUuid · getInstallUuid(completion:) · setGlobalProperty(_:value:) · unsetGlobalProperty(_:) · clearGlobalProperties() · setPartnerParameter(_:value:) · unsetPartnerParameter(_:) · clearPartnerParameters() · setPushToken(_:) · registerPushToken(_:provider:) · setAudience(_:)
PrivacysetConsent(_:completion:) · getConsent() · setAdvertisingConsent(_:completion:) · setThirdPartySharing(_:completion:) · setPartnerSharing(partner:key:value:) · setEnabled(_:completion:) · isEnabled(completion:) · setOfflineMode(_:) · setIntegrityToken(_:) · deleteUserData(completion:)
ATT & SKAdNetworkrequestTrackingAuthorization(completion:) · requestTrackingAuthorizationRaw(completion:) · updateConversionValue(_:coarseValue:lockWindow:completion:)
Deep links & attributiononDeepLink(_:) · removeDeepLinkListener(_:) · lastDeepLink · getLastDeepLink(completion:) · getInitialDeepLink(completion:) · resolveDeepLink(_:completion:) · handleDeepLink(_:) · onAttributionChanged(_:) · removeAttributionListener(_:) · getAttribution(completion:) · getAttributionWithTimeout(timeoutMs:completion:)
Host lifecycleReflect.application(_:didFinishLaunchingWithOptions:) · Reflect.handle(url:) · Reflect.continue(userActivity:) · Reflect.scene(willConnectWith:) · Reflect.scene(openURLContexts:) (Obj-C: RFLLifecycle)
Standard events & mediationReflectStandardEvents (20 typed helpers over the canonical wire names) · ReflectMax · ReflectAdMob · ReflectLevelPlay ad-revenue mappers
DiagnosticsgetDebugState(completion:) · ReflectDebugOverlay.attach(to:) / .detach() · ReflectEventValidator.validate(_:properties:)

getAttributionWithTimeout takes milliseconds(timeoutMs: Int = 3000, clamped to 0...3_600_000) — named that way deliberately, because a developer carrying the Flutter/RN habit of passing 3000to a TimeInterval would otherwise get 3000 seconds.

Troubleshooting

SymptomCause / fix
"None of your spec sources contain a spec satisfying the dependency: ReflectCore (~> 1.1)"Only ReflectSDK was declared. Neither pod is on CocoaPods trunk, and CocoaPods will not follow a git-sourced pod's dependency to another git source — add the explicit ReflectCore :git/:tag line too.
pod install crashes in unicode_normalizeRun it as LANG=en_US.UTF-8 pod install.
ATT returns gateClosed and no prompt appearsThe privacy gate is shut. Grant consent (setConsent(true)) and call again.
requestTrackingAuthorization crashes the appNSUserTrackingUsageDescription is missing from Info.plist.
No events until the second launchThe ATT / tracking-domain transport gate. Expected, not a bug.
Deep link arrives with no clickId and no query paramsA listener was registered after initialize. Register first.
getInitialDeepLink() returns nilReflect.application(_:didFinishLaunchingWithOptions:) / Reflect.scene(willConnectWith:) was never called.
An event never arrives and nothing is loggedClient-side validation dropped it (name must match [a-z][a-z0-9_-]*, 1–64 chars, ≤25 properties). Turn on debug: true, or pre-flight with ReflectEventValidator.validate.
Events are dropped right after launchThe privacy gate is closed: consent denied, setEnabled(false), or a failed posture readback (which fails closed). Check Reflect.getConsent() and getDebugState.
No conversion value is ever sentNo cached SKAN schema — confirm the CV-schema endpoint returns 64 entries. The SDK never invents buckets.
@import ReflectSDK; fails in Obj-CDEFINES_MODULE was overridden, or you opened the .xcodeproj instead of the .xcworkspace.
Obj-C app fails to link: Undefined symbols: __swift_FORCE_LOAD_$_swiftCompatibility56A pure Obj-C target compiles no Swift, so Xcode adds no Swift library search paths. The podspec fixes this via user_target_xcconfig; the error means that LIBRARY_SEARCH_PATHS entry was overwritten by a $(inherited)-less setting in your own xcconfig.
Reinstall is not counted as a new installExpected — the install_uuid is preserved by design.