This is the recommended way to integrate without the SDK on iOS. You add one Swift file, configure your token, and call typed methods that mirror the SDK. The client collects device signals, manages the install instance ID, applies SKAdNetwork conversion values, and retries failed requests.
No third-party dependencies are needed. The client uses URLSession and Apple frameworks (AdSupport, AppTrackingTransparency, AdServices, StoreKit). It targets iOS 15 and above.
To collect the IDFA, add a tracking usage description. Without it, iOS will not show the App Tracking Transparency prompt and the IDFA stays empty.
< key > NSUserTrackingUsageDescription </ key >
< string > We use your data to attribute installs and measure ad performance. </ string >
2. Add the client
Create LinkrunnerClient.swift. It authenticates with your project token in the request body.
import Foundation
import UIKit
import AdSupport
import AppTrackingTransparency
import AdServices
import StoreKit
@available ( iOS 15.0 , * )
final class LinkrunnerClient {
static let shared = LinkrunnerClient ()
private init () {}
private let baseURL = "https://api.linkrunner.io"
private let platform = "IOS"
private var token = "YOUR_PROJECT_TOKEN"
private var debug = false
private let installIdKey = "linkrunner_install_instance_id"
private let installTimeKey = "linkrunner_install_time"
func configure ( token : String , debug : Bool = false ) {
self . token = token
self . debug = debug
_ = installInstanceId ()
if UserDefaults.standard. object ( forKey : installTimeKey) == nil {
UserDefaults. standard . set ( Date (), forKey : installTimeKey)
}
}
/// Stable per-install identity. Generated once, then reused forever.
func installInstanceId () -> String {
if let id = UserDefaults.standard. string ( forKey : installIdKey) { return id }
let id = UUID (). uuidString
UserDefaults. standard . set (id, forKey : installIdKey)
return id
}
/// Ask for IDFA permission. Requires NSUserTrackingUsageDescription. Call when the app is active.
func requestTrackingAuthorization () async {
await withCheckedContinuation { cont in
ATTrackingManager. requestTrackingAuthorization { _ in cont. resume () }
}
}
// MARK: - Endpoints
@discardableResult
func initialize ( link : String ? = nil ) async throws -> [ String : Any ] {
var body = base ()
body[ "package_version" ] = "ios-rest-1.0"
body[ "app_version" ] = appVersion ()
body[ "device_data" ] = await deviceData ()
body[ "debug" ] = debug
if let link { body[ "link" ] = link }
return try await post ( "/api/client/init" , body)
}
func attributionData () async throws -> [ String : Any ] {
var body = base ()
body[ "device_data" ] = await deviceData ()
body[ "debug" ] = debug
return try await post ( "/api/client/attribution-data" , body)
}
@discardableResult
func signup ( userData : [ String : Any ], additionalData : [ String : Any ] = [ : ]) async throws -> [ String : Any ] {
var data = additionalData
data[ "device_data" ] = await deviceData ()
var body = base ()
body[ "user_data" ] = userData
body[ "data" ] = data
body[ "time_since_app_install" ] = timeSinceInstall ()
let res = try await post ( "/api/client/trigger" , body)
applySKAN (res)
return res
}
@discardableResult
func setUserData ( _ userData : [ String : Any ]) async throws -> [ String : Any ] {
var body = base ()
body[ "user_data" ] = userData
body[ "device_data" ] = await deviceData ()
return try await post ( "/api/client/set-user-data" , body)
}
@discardableResult
func trackEvent ( _ name : String , data eventData : [ String : Any ] ? = nil ) async throws -> [ String : Any ] {
var body = base ()
body[ "event_name" ] = name
if let eventData { body[ "event_data" ] = eventData }
body[ "device_data" ] = await deviceData ()
body[ "time_since_app_install" ] = timeSinceInstall ()
let res = try await post ( "/api/client/capture-event" , body)
applySKAN (res)
return res
}
@discardableResult
func capturePayment (
amount : Double ,
userId : String ,
paymentId : String ? = nil ,
type : String = "DEFAULT" ,
status : String = "PAYMENT_COMPLETED" ,
eventData : [ String : Any ] ? = nil
) async throws -> [ String : Any ] {
var body = base ()
body[ "amount" ] = amount
body[ "user_id" ] = userId
body[ "type" ] = type
body[ "status" ] = status
if let paymentId { body[ "payment_id" ] = paymentId }
if let eventData { body[ "event_data" ] = eventData }
body[ "time_since_app_install" ] = timeSinceInstall ()
body[ "data" ] = [ "device_data" : await deviceData ()]
let res = try await post ( "/api/client/capture-payment" , body)
applySKAN (res)
return res
}
@discardableResult
func removePayment ( userId : String , paymentId : String ? = nil ) async throws -> [ String : Any ] {
var body = base ()
body[ "user_id" ] = userId
if let paymentId { body[ "payment_id" ] = paymentId }
body[ "data" ] = [ "device_data" : await deviceData ()]
return try await post ( "/api/client/remove-captured-payment" , body)
}
@discardableResult
func setPushToken ( _ pushToken : String ) async throws -> [ String : Any ] {
var body = base ()
body[ "push_token" ] = pushToken
return try await post ( "/api/client/update-push-token" , body)
}
@discardableResult
func setIntegrationData ( clevertapId : String ) async throws -> [ String : Any ] {
var body = base ()
body[ "integration_info" ] = [ "clevertap_id" : clevertapId]
return try await post ( "/api/client/integrations" , body)
}
@discardableResult
func handleDeeplink ( _ url : String ) async throws -> [ String : Any ] {
var body = base ()
body[ "deeplink_url" ] = url
body[ "device_data" ] = await deviceData ()
let res = try await post ( "/api/client/handle-deeplink" , body)
applySKAN (res)
return res
}
// MARK: - Networking
private enum LRError : Error { case http ( Int , String ) }
private let retriableStatus: Set < Int > = [ 408 , 429 , 500 , 502 , 503 , 504 ]
private let maxRetries = 3
private func base () -> [ String : Any ] {
[ "token" : token, "platform" : platform, "install_instance_id" : installInstanceId ()]
}
private func post ( _ path : String , _ body : [ String : Any ]) async throws -> [ String : Any ] {
let url = URL ( string : baseURL + path) !
var attempt = 0
while true {
var request = URLRequest ( url : url)
request. httpMethod = "POST"
request. addValue ( "application/json" , forHTTPHeaderField : "Content-Type" )
request. httpBody = try JSONSerialization. data ( withJSONObject : body)
do {
let (data, response) = try await URLSession. shared . data ( for : request)
let code = (response as? HTTPURLResponse) ? . statusCode ?? 0
if ( 200 ..< 300 ). contains (code) {
return ( try ? JSONSerialization. jsonObject ( with : data) as? [ String : Any ]) ?? [ : ]
}
if ! (retriableStatus. contains (code) && attempt < maxRetries) {
throw LRError. http (code, String ( data : data, encoding : . utf8 ) ?? "" )
}
} catch let error as LRError {
throw error
} catch {
if attempt >= maxRetries { throw error }
}
try await Task. sleep ( nanoseconds : UInt64 ( pow ( 2.0 , Double (attempt)) * 1_000_000_000 )) // 1s, 2s, 4s
attempt += 1
}
}
// MARK: - SKAdNetwork
/// Applies the conversion value returned by the server, if present.
private func applySKAN ( _ response : [ String : Any ]) {
guard let data = response[ "data" ] as? [ String : Any ],
let fine = data[ "fine_conversion_value" ] as? Int else { return }
let coarse = data[ "coarse_conversion_value" ] as? String
let lock = data[ "lock_postback" ] as? Bool ?? false
if #available ( iOS 16.1 , * ) {
let coarseValue: SKAdNetwork.CoarseConversionValue
switch coarse ? . lowercased () {
case "high" : coarseValue = . high
case "medium" : coarseValue = . medium
default : coarseValue = . low
}
SKAdNetwork. updatePostbackConversionValue (fine, coarseValue : coarseValue, lockWindow : lock) { _ in }
} else if #available ( iOS 15.4 , * ) {
SKAdNetwork. updatePostbackConversionValue (fine) { _ in }
} else {
SKAdNetwork. updateConversionValue (fine)
}
}
// MARK: - Device data
private func deviceData () async -> [ String : Any ] {
var d = await uiDeviceData ()
d[ "brand" ] = "Apple"
d[ "manufacturer" ] = "Apple"
d[ "install_instance_id" ] = installInstanceId ()
if let bundleId = Bundle.main.bundleIdentifier { d[ "bundle_id" ] = bundleId }
d[ "version" ] = appVersion ()
if let build = Bundle.main.infoDictionary ? [ "CFBundleVersion" ] as? String { d[ "build_number" ] = build }
if let idfa = idfa () { d[ "idfa" ] = idfa }
let locale = Locale. current
d[ "locale" ] = locale. identifier
if let lang = locale.languageCode { d[ "language" ] = lang }
if let region = locale.regionCode { d[ "country" ] = region }
d[ "timezone" ] = TimeZone. current . identifier
d[ "timezone_offset" ] = TimeZone. current . secondsFromGMT () / 60
if let token = adServicesToken () { d[ "adservices_attribution_token" ] = token }
return d
}
@MainActor
private func uiDeviceData () -> [ String : Any ] {
let device = UIDevice. current
let screen = UIScreen. main
var d: [ String : Any ] = [
"device" : device. model ,
"device_name" : device. name ,
"system_version" : device. systemVersion ,
"device_display" : [
"width" : screen. bounds . width ,
"height" : screen. bounds . height ,
"scale" : screen. scale
]
]
if let idfv = device.identifierForVendor ? .uuidString { d[ "idfv" ] = idfv }
return d
}
private func idfa () -> String ? {
guard ATTrackingManager.trackingAuthorizationStatus == .authorized else { return nil }
return ASIdentifierManager. shared (). advertisingIdentifier . uuidString
}
private func adServicesToken () -> String ? {
try ? AAAttribution. attributionToken ()
}
private func appVersion () -> String {
Bundle. main . infoDictionary ? [ "CFBundleShortVersionString" ] as? String ?? "1.0"
}
private func timeSinceInstall () -> TimeInterval {
let installed = UserDefaults. standard . object ( forKey : installTimeKey) as? Date ?? Date ()
return Date (). timeIntervalSince (installed)
}
}
3. Initialize and read attribution
Call configure once, then initialize, then poll attributionData.
import SwiftUI
@main
struct MyApp : App {
init () {
LinkrunnerClient. shared . configure ( token : "YOUR_PROJECT_TOKEN" )
Task {
try ? await LinkrunnerClient. shared . initialize ()
for _ in 0 ..< 5 {
let data = ( try ? await LinkrunnerClient. shared . attributionData ()) ? [ "data" ] as? [ String : Any ]
if let campaign = data ? [ "campaign_data" ] as? [ String : Any ] {
print ( "Campaign: \( campaign ) " )
break
}
try ? await Task. sleep ( nanoseconds : 2_000_000_000 )
}
}
}
var body: some Scene {
WindowGroup { ContentView () }
}
}
To collect the IDFA, call await LinkrunnerClient.shared.requestTrackingAuthorization() when the app is active (for example after your first screen appears), not during launch. The IDFA improves matching but is optional.
4. Identify the user
Call signup when the user signs up or logs in. Events and payments are only stored for attributed users.
Task {
try ? await LinkrunnerClient. shared . signup (
userData : [ "id" : "user_123" , "email" : "user@example.com" , "name" : "John Doe" ]
)
}
5. Track events and payments
// Custom event
Task {
try ? await LinkrunnerClient. shared . trackEvent (
"purchase_initiated" ,
data : [ "product_id" : "12345" , "amount" : 99.99 ]
)
}
// Payment
Task {
try ? await LinkrunnerClient. shared . capturePayment (
amount : 99.99 ,
userId : "user_123" ,
paymentId : "payment_123" ,
type : "FIRST_PAYMENT"
)
}
Add amount as a number in event or payment data to enable revenue sharing with Google and Meta.
Other endpoints
try ? await LinkrunnerClient. shared . setUserData ([ "id" : "user_123" , "phone" : "9876543210" ])
try ? await LinkrunnerClient. shared . setPushToken (apnsOrFcmToken)
try ? await LinkrunnerClient. shared . setIntegrationData ( clevertapId : "YOUR_CLEVERTAP_ID" )
try ? await LinkrunnerClient. shared . handleDeeplink ( "https://app.example.com/product/123" )
try ? await LinkrunnerClient. shared . removePayment ( userId : "user_123" , paymentId : "payment_123" )
SKAdNetwork
init, signup, capture-event, and capture-payment responses can include a conversion value (fine_conversion_value, coarse_conversion_value, lock_postback). The client applies it automatically through applySKAN, which calls SKAdNetwork.updatePostbackConversionValue. You do not need to manage conversion values yourself. See Collecting device data for how this fits with Apple’s attribution.
Retries and idempotency
The client retries failed requests up to 3 times with exponential backoff (1s, 2s, 4s). It retries on network errors and on HTTP 408, 429, and 5xx. Other responses, such as 400 or 401, are not retried.
A retry can send the same request twice if the first attempt reached the server but the response was lost. How that is handled depends on the endpoint:
Endpoint Safe to retry? Why initYes The server deduplicates on install_instance_id. capture-paymentYes, with payment_id The server deduplicates on payment_id. Always send one. capture-eventNot deduplicated A retried event can be counted twice.
Always pass a paymentId to capturePayment so retries cannot double count revenue. Events have no server-side deduplication, so if exact event counts matter, disable retries for those calls.
Troubleshooting
Attribution data is always organic
Make sure initialize ran on the device, not your backend. For Apple Search Ads, confirm adservices_attribution_token is present in device_data. The IDFA helps but only after the user grants App Tracking Transparency. See Collecting device data .
The IDFA is only available after the user authorizes tracking. Add NSUserTrackingUsageDescription and call requestTrackingAuthorization() while the app is active.
Events or payments do not appear
Events and payments are only stored for attributed users. Call signup first, and use the same install_instance_id everywhere.
Every install looks like the same device or location
You are likely calling from your backend. The server matches on the calling IP, so calls must come from the user’s device.
Need help? Contact support@linkrunner.io