Apple Search Ads Cross-Domain Tracking | OpsBlu Docs

Apple Search Ads Cross-Domain Tracking

Configure cross-platform attribution and tracking for Apple Search Ads across web and mobile.

Overview

Apple Search Ads operates primarily in the mobile app ecosystem, but cross-platform tracking becomes essential when users interact with both web and app experiences. This includes scenarios like:

  • User clicks Search Ad, visits website first, then downloads app
  • Web-to-app handoff with attribution preservation
  • Universal Links connecting web and app experiences
  • Cross-device user journey tracking

Web-to-App Attribution

Configure Universal Links to seamlessly transition users from web to app while preserving attribution:

Step 1: Configure Associated Domains

In Xcode, add associated domains entitlement:

<!-- Entitlements file -->
<key>com.apple.developer.associated-domains</key>
<array>
    <string>applinks:yourdomain.com</string>
    <string>applinks:www.yourdomain.com</string>
</array>

Step 2: Apple App Site Association File

Host this file at https://yourdomain.com/.well-known/apple-app-site-association:

{
  "applinks": {
    "apps": [],
    "details": [
      {
        "appID": "TEAM_ID.com.yourcompany.yourapp",
        "paths": [
          "/products/*",
          "/campaigns/*",
          "/offers/*"
        ]
      }
    ]
  }
}
import UIKit

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

    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else {
        return false
    }

    // Extract campaign parameters from URL
    let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
    let campaignId = components?.queryItems?.first(where: { $0.name == "campaign_id" })?.value

    // Store for later attribution
    if let campaignId = campaignId {
        UserDefaults.standard.set(campaignId, forKey: "web_campaign_id")
    }

    // Handle deep link routing
    handleDeepLink(url)

    return true
}

Deferred Deep Linking

Track users who install the app after clicking a web link:

import Branch  // Example using Branch.io

func configureBranch() {
    let branch = Branch.getInstance()

    branch?.initSession(launchOptions: launchOptions) { params, error in
        if let params = params as? [String: AnyObject] {
            // Check if user came from a link
            if let campaignId = params["campaign_id"] as? String {
                // Store campaign attribution
                self.storeDeferredAttribution(campaignId: campaignId)
            }

            // Check for Search Ads attribution
            if let clickedBranchLink = params["+clicked_branch_link"] as? Bool,
               clickedBranchLink {
                self.handleDeferredDeepLink(params)
            }
        }
    }
}

func storeDeferredAttribution(campaignId: String) {
    UserDefaults.standard.set(campaignId, forKey: "deferred_campaign_id")

    // Also check for Search Ads attribution
    checkSearchAdsAttribution()
}

Attribution Data Persistence

Store Attribution Across Sessions

struct AttributionData: Codable {
    var searchAdsCampaignId: Int?
    var searchAdsAdGroupId: Int?
    var webCampaignId: String?
    var deferredCampaignId: String?
    var utmSource: String?
    var utmMedium: String?
    var utmCampaign: String?
    var firstTouchTimestamp: Date
    var lastTouchTimestamp: Date

    mutating func merge(searchAdsData: SearchAdsAttribution) {
        self.searchAdsCampaignId = searchAdsData.campaignId
        self.searchAdsAdGroupId = searchAdsData.adGroupId
        self.lastTouchTimestamp = Date()
    }

    mutating func merge(webData: [String: String]) {
        self.utmSource = webData["utm_source"]
        self.utmMedium = webData["utm_medium"]
        self.utmCampaign = webData["utm_campaign"]
        self.lastTouchTimestamp = Date()
    }
}

class AttributionManager {
    static let shared = AttributionManager()
    private let storageKey = "attribution_data"

    func getAttribution() -> AttributionData {
        if let data = UserDefaults.standard.data(forKey: storageKey),
           let decoded = try? JSONDecoder().decode(AttributionData.self, from: data) {
            return decoded
        }

        return AttributionData(
            firstTouchTimestamp: Date(),
            lastTouchTimestamp: Date()
        )
    }

    func updateWithSearchAds(_ searchAdsData: SearchAdsAttribution) {
        var attribution = getAttribution()
        attribution.merge(searchAdsData: searchAdsData)
        save(attribution)
    }

    func updateWithWebData(_ webData: [String: String]) {
        var attribution = getAttribution()
        attribution.merge(webData: webData)
        save(attribution)
    }

    private func save(_ attribution: AttributionData) {
        if let encoded = try? JSONEncoder().encode(attribution) {
            UserDefaults.standard.set(encoded, forKey: storageKey)
        }
    }
}

Smart App Banner Integration

Configure Smart App Banner

Add to your website's <head> section:

<meta name="apple-itunes-app" content="app-id=YOUR_APP_ID, app-argument=https://yourdomain.com/campaign?campaign_id=123&source=banner">

Handle Smart Banner Opens

func handleSmartBanner(url: URL) {
    let components = URLComponents(url: url, resolvingAgainstBaseURL: true)

    // Extract parameters
    let campaignId = components?.queryItems?.first(where: { $0.name == "campaign_id" })?.value
    let source = components?.queryItems?.first(where: { $0.name == "source" })?.value

    // Store banner attribution
    if source == "banner" {
        UserDefaults.standard.set(campaignId, forKey: "smart_banner_campaign")
    }
}

Custom URL Scheme Tracking

Register Custom URL Scheme

In Info.plist:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>yourapp</string>
        </array>
        <key>CFBundleURLName</key>
        <string>com.yourcompany.yourapp</string>
    </dict>
</array>

Handle Custom URL Scheme

func application(_ app: UIApplication,
                open url: URL,
                options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {

    // Example: yourapp://campaign?id=123&source=email
    guard url.scheme == "yourapp" else { return false }

    let components = URLComponents(url: url, resolvingAgainstBaseURL: true)

    if url.host == "campaign" {
        let campaignId = components?.queryItems?.first(where: { $0.name == "id" })?.value
        let source = components?.queryItems?.first(where: { $0.name == "source" })?.value

        storeCrossPlatformAttribution(campaignId: campaignId, source: source)
    }

    return true
}

func storeCrossPlatformAttribution(campaignId: String?, source: String?) {
    let webData: [String: String] = [
        "campaign_id": campaignId ?? "",
        "utm_source": source ?? "direct"
    ]

    AttributionManager.shared.updateWithWebData(webData)
}

Multi-Touch Attribution

Track Full User Journey

struct TouchPoint: Codable {
    let source: String
    let medium: String
    let campaign: String?
    let timestamp: Date
    let conversionValue: Int?
}

class JourneyTracker {
    static let shared = JourneyTracker()
    private let storageKey = "user_journey"
    private var touchPoints: [TouchPoint] = []

    init() {
        loadTouchPoints()
    }

    func addTouchPoint(_ touchPoint: TouchPoint) {
        touchPoints.append(touchPoint)
        save()
    }

    func addSearchAdsTouchPoint(attribution: SearchAdsAttribution) {
        let touchPoint = TouchPoint(
            source: "apple_search_ads",
            medium: "cpc",
            campaign: attribution.campaignId.map(String.init),
            timestamp: Date(),
            conversionValue: nil
        )
        addTouchPoint(touchPoint)
    }

    func addWebTouchPoint(utmSource: String, utmMedium: String, utmCampaign: String?) {
        let touchPoint = TouchPoint(
            source: utmSource,
            medium: utmMedium,
            campaign: utmCampaign,
            timestamp: Date(),
            conversionValue: nil
        )
        addTouchPoint(touchPoint)
    }

    func getTouchPoints() -> [TouchPoint] {
        return touchPoints
    }

    private func loadTouchPoints() {
        if let data = UserDefaults.standard.data(forKey: storageKey),
           let decoded = try? JSONDecoder().decode([TouchPoint].self, from: data) {
            touchPoints = decoded
        }
    }

    private func save() {
        if let encoded = try? JSONEncoder().encode(touchPoints) {
            UserDefaults.standard.set(encoded, forKey: storageKey)
        }
    }
}

Server-Side Attribution Tracking

Send Cross-Platform Data to Server

func syncCrossPlatformAttribution() {
    let attribution = AttributionManager.shared.getAttribution()
    let journey = JourneyTracker.shared.getTouchPoints()

    let payload: [String: Any] = [
        "user_id": getUserId(),
        "device_id": getDeviceId(),
        "attribution": [
            "search_ads_campaign_id": attribution.searchAdsCampaignId ?? 0,
            "search_ads_ad_group_id": attribution.searchAdsAdGroupId ?? 0,
            "web_campaign_id": attribution.webCampaignId ?? "",
            "utm_source": attribution.utmSource ?? "",
            "utm_medium": attribution.utmMedium ?? "",
            "utm_campaign": attribution.utmCampaign ?? ""
        ],
        "journey": journey.map { [
            "source": $0.source,
            "medium": $0.medium,
            "campaign": $0.campaign ?? "",
            "timestamp": $0.timestamp.timeIntervalSince1970
        ]},
        "first_touch": attribution.firstTouchTimestamp.timeIntervalSince1970,
        "last_touch": attribution.lastTouchTimestamp.timeIntervalSince1970
    ]

    sendToServer(payload)
}

func sendToServer(_ payload: [String: Any]) {
    guard let url = URL(string: "https://api.yourdomain.com/attribution") else { return }

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try? JSONSerialization.data(withJSONObject: payload)

    URLSession.shared.dataTask(with: request).resume()
}

Cross-Device User Identification

Implement User ID Mapping

class UserIdentityManager {
    static let shared = UserIdentityManager()

    func linkDeviceToUser(userId: String) {
        let deviceId = getDeviceId()

        let payload: [String: Any] = [
            "user_id": userId,
            "device_id": deviceId,
            "platform": "ios",
            "attribution": AttributionManager.shared.getAttribution()
        ]

        // Send to server for cross-device tracking
        linkDeviceOnServer(payload)
    }

    private func linkDeviceOnServer(_ payload: [String: Any]) {
        guard let url = URL(string: "https://api.yourdomain.com/link-device") else { return }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try? JSONSerialization.data(withJSONObject: payload)

        URLSession.shared.dataTask(with: request).resume()
    }

    private func getDeviceId() -> String {
        // Use IDFV or your own generated ID
        return UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
    }
}

Testing Cross-Platform Tracking

#if DEBUG
func testUniversalLink(url: String) {
    guard let url = URL(string: url) else { return }

    print("Testing Universal Link: \(url)")

    let activity = NSUserActivity(activityType: NSUserActivityTypeBrowsingWeb)
    activity.webpageURL = url

    _ = application(UIApplication.shared,
                   continue: activity,
                   restorationHandler: { _ in })
}

// Usage in development
testUniversalLink(url: "https://yourdomain.com/campaign?campaign_id=123&source=email")
#endif

Best Practices

  • Implement Universal Links for seamless web-to-app transitions
  • Store all attribution data persistently across app sessions
  • Track multi-touch journeys for comprehensive attribution analysis
  • Use server-side attribution reconciliation for accuracy
  • Implement user ID mapping for cross-device tracking
  • Test Universal Links thoroughly before production
  • Document your attribution schema and data flow
  • Respect user privacy and comply with ATT requirements
  • Use first-touch and last-touch attribution models
  • Monitor attribution data quality with server-side validation