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
Universal Links Setup
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/*"
]
}
]
}
}
Step 3: Handle Universal Links
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
Debug Universal Links
#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