Apple Search Ads Data Layer Setup | OpsBlu Docs

Apple Search Ads Data Layer Setup

Configure data structures and tracking parameters for Apple Search Ads attribution and analytics.

Data Layer Overview

Apple Search Ads uses a native attribution framework rather than a traditional web-based data layer. However, proper data structure is essential for storing attribution data, user properties, and conversion events for analytics and optimization.

Attribution Data Structure

Store Attribution Response

struct SearchAdsAttribution: Codable {
    let attribution: Bool
    let orgId: Int?
    let campaignId: Int?
    let conversionType: String?
    let adGroupId: Int?
    let countryOrRegion: String?
    let keywordId: Int?
    let adId: Int?
    let clickDate: String?

    func save() {
        let encoder = JSONEncoder()
        if let encoded = try? encoder.encode(self) {
            UserDefaults.standard.set(encoded, forKey: "search_ads_attribution")
        }
    }

    static func load() -> SearchAdsAttribution? {
        if let data = UserDefaults.standard.data(forKey: "search_ads_attribution") {
            let decoder = JSONDecoder()
            return try? decoder.decode(SearchAdsAttribution.self, from: data)
        }
        return nil
    }
}

Retrieve and Parse Attribution

import AdServices

func getAndStoreAttribution() {
    guard let token = try? AAAttribution.attributionToken() else {
        print("No attribution token available")
        return
    }

    validateAndStoreAttribution(token: token)
}

func validateAndStoreAttribution(token: String) {
    let endpoint = "https://api-adservices.apple.com/api/v1/"
    var request = URLRequest(url: URL(string: endpoint)!)
    request.httpMethod = "POST"
    request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
    request.httpBody = token.data(using: .utf8)

    URLSession.shared.dataTask(with: request) { data, response, error in
        guard let data = data,
              let attribution = try? JSONDecoder().decode(SearchAdsAttribution.self, from: data) else {
            return
        }

        // Store attribution data
        attribution.save()

        // Send to your analytics backend
        sendToAnalytics(attribution)
    }.resume()
}

User Properties Data Layer

Define User Properties

struct UserProperties {
    var userId: String?
    var installDate: Date
    var attributionSource: String?
    var campaignId: Int?
    var adGroupId: Int?
    var lifetimeValue: Double
    var subscriptionTier: String?
    var lastActiveDate: Date

    func toDictionary() -> [String: Any] {
        return [
            "user_id": userId ?? "",
            "install_date": installDate.timeIntervalSince1970,
            "attribution_source": attributionSource ?? "organic",
            "campaign_id": campaignId ?? 0,
            "ad_group_id": adGroupId ?? 0,
            "lifetime_value": lifetimeValue,
            "subscription_tier": subscriptionTier ?? "free",
            "last_active_date": lastActiveDate.timeIntervalSince1970
        ]
    }
}

User Properties Manager

class UserPropertiesManager {
    static let shared = UserPropertiesManager()
    private var properties: UserProperties

    private init() {
        // Load from UserDefaults or create new
        if let data = UserDefaults.standard.data(forKey: "user_properties"),
           let decoded = try? JSONDecoder().decode(UserProperties.self, from: data) {
            self.properties = decoded
        } else {
            self.properties = UserProperties(
                installDate: Date(),
                lifetimeValue: 0.0,
                lastActiveDate: Date()
            )
            save()
        }
    }

    func updateAttribution(_ attribution: SearchAdsAttribution) {
        properties.attributionSource = "apple_search_ads"
        properties.campaignId = attribution.campaignId
        properties.adGroupId = attribution.adGroupId
        save()
    }

    func updateLTV(amount: Double) {
        properties.lifetimeValue += amount
        save()
    }

    func save() {
        if let encoded = try? JSONEncoder().encode(properties) {
            UserDefaults.standard.set(encoded, forKey: "user_properties")
        }
    }

    func getProperties() -> UserProperties {
        return properties
    }
}

Event Data Layer

Event Structure

struct AnalyticsEvent {
    let name: String
    let timestamp: Date
    var parameters: [String: Any]
    let conversionValue: Int?

    func toDictionary() -> [String: Any] {
        var dict: [String: Any] = [
            "event_name": name,
            "timestamp": timestamp.timeIntervalSince1970,
            "parameters": parameters
        ]

        if let value = conversionValue {
            dict["conversion_value"] = value
        }

        // Add user properties
        let userProps = UserPropertiesManager.shared.getProperties()
        dict["user_properties"] = userProps.toDictionary()

        return dict
    }
}

Event Queue Manager

class EventQueueManager {
    static let shared = EventQueueManager()
    private var eventQueue: [AnalyticsEvent] = []
    private let maxQueueSize = 50

    func trackEvent(_ event: AnalyticsEvent) {
        eventQueue.append(event)

        // Auto-flush when queue is full
        if eventQueue.count >= maxQueueSize {
            flush()
        }
    }

    func flush() {
        guard !eventQueue.isEmpty else { return }

        let eventsToSend = eventQueue
        eventQueue.removeAll()

        // Send to your analytics backend
        sendEvents(eventsToSend)
    }

    private func sendEvents(_ events: [AnalyticsEvent]) {
        let eventData = events.map { $0.toDictionary() }

        guard let url = URL(string: "https://api.yourdomain.com/events") else { return }

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

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

E-commerce Data Layer

Product Data Structure

struct Product: Codable {
    let id: String
    let name: String
    let category: String
    let price: Double
    let currency: String
    let quantity: Int

    func toDictionary() -> [String: Any] {
        return [
            "product_id": id,
            "product_name": name,
            "category": category,
            "price": price,
            "currency": currency,
            "quantity": quantity
        ]
    }
}

Purchase Event

func trackPurchase(products: [Product], orderId: String, totalAmount: Double) {
    let parameters: [String: Any] = [
        "order_id": orderId,
        "total_amount": totalAmount,
        "currency": "USD",
        "products": products.map { $0.toDictionary() },
        "payment_method": "in_app_purchase"
    ]

    let event = AnalyticsEvent(
        name: "purchase",
        timestamp: Date(),
        parameters: parameters,
        conversionValue: 30 // SKAdNetwork conversion value
    )

    EventQueueManager.shared.trackEvent(event)

    // Update SKAdNetwork
    if #available(iOS 14.0, *) {
        SKAdNetwork.updateConversionValue(event.conversionValue ?? 0)
    }

    // Update user LTV
    UserPropertiesManager.shared.updateLTV(amount: totalAmount)
}

Subscription Data Layer

Subscription Status

struct SubscriptionStatus: Codable {
    let tier: String
    let startDate: Date
    let renewalDate: Date
    let price: Double
    let isActive: Bool
    let trialPeriod: Bool

    func toDictionary() -> [String: Any] {
        return [
            "subscription_tier": tier,
            "start_date": startDate.timeIntervalSince1970,
            "renewal_date": renewalDate.timeIntervalSince1970,
            "price": price,
            "is_active": isActive,
            "trial_period": trialPeriod
        ]
    }
}

Track Subscription Event

func trackSubscription(status: SubscriptionStatus) {
    let parameters = status.toDictionary()

    let conversionValue: Int
    switch status.tier {
    case "premium":
        conversionValue = 60
    case "standard":
        conversionValue = 50
    default:
        conversionValue = 40
    }

    let event = AnalyticsEvent(
        name: "subscribe",
        timestamp: Date(),
        parameters: parameters,
        conversionValue: conversionValue
    )

    EventQueueManager.shared.trackEvent(event)

    if #available(iOS 14.0, *) {
        SKAdNetwork.updateConversionValue(conversionValue)
    }
}

Campaign Data Integration

func enrichEventWithCampaignData(_ event: inout AnalyticsEvent) {
    if let attribution = SearchAdsAttribution.load() {
        event.parameters["campaign_id"] = attribution.campaignId ?? 0
        event.parameters["ad_group_id"] = attribution.adGroupId ?? 0
        event.parameters["keyword_id"] = attribution.keywordId ?? 0
        event.parameters["country"] = attribution.countryOrRegion ?? "unknown"
        event.parameters["attribution_source"] = "apple_search_ads"
    } else {
        event.parameters["attribution_source"] = "organic"
    }
}

Server-Side Data Layer

Send Attribution to Server

func syncAttributionToServer() {
    guard let attribution = SearchAdsAttribution.load() else { return }

    let endpoint = URL(string: "https://api.yourdomain.com/attribution")!
    var request = URLRequest(url: endpoint)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let data: [String: Any] = [
        "user_id": UserPropertiesManager.shared.getProperties().userId ?? "",
        "attribution_source": "apple_search_ads",
        "campaign_id": attribution.campaignId ?? 0,
        "ad_group_id": attribution.adGroupId ?? 0,
        "keyword_id": attribution.keywordId ?? 0,
        "install_date": Date().timeIntervalSince1970
    ]

    request.httpBody = try? JSONSerialization.data(withJSONObject: data)

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

MMP Data Layer Integration

AppsFlyer Custom Data

import AppsFlyerLib

func setAppsFlyerCustomData() {
    // Set custom user properties
    AppsFlyerLib.shared().customData = [
        "subscription_tier": "premium",
        "user_segment": "high_value",
        "registration_date": "2024-01-15"
    ]

    // Add Search Ads attribution if available
    if let attribution = SearchAdsAttribution.load(),
       let campaignId = attribution.campaignId {
        AppsFlyerLib.shared().customData?["asa_campaign_id"] = String(campaignId)
    }
}

Testing Data Layer

Debug Logging

class AnalyticsDebugger {
    static func logEvent(_ event: AnalyticsEvent) {
        #if DEBUG
        print("Analytics Event")
        print("Name: \(event.name)")
        print("Timestamp: \(event.timestamp)")
        print("Parameters: \(event.parameters)")
        if let cv = event.conversionValue {
            print("Conversion Value: \(cv)")
        }
        print("User Properties: \(UserPropertiesManager.shared.getProperties().toDictionary())")
        print("---")
        #endif
    }
}

Validate Data Structure

func validateEventData(_ event: AnalyticsEvent) -> Bool {
    // Check required fields
    guard !event.name.isEmpty else {
        print("Event name is empty")
        return false
    }

    // Validate conversion value range
    if let cv = event.conversionValue {
        guard cv >= 0 && cv <= 63 else {
            print("Conversion value out of range: \(cv)")
            return false
        }
    }

    return true
}

Best Practices

  • Store attribution data immediately upon first app launch
  • Implement event queue for batch sending to reduce network calls
  • Include user properties with every event for complete context
  • Use consistent naming conventions for events and parameters
  • Validate data structure before sending to prevent errors
  • Implement retry logic for failed event uploads
  • Respect user privacy - only collect necessary data
  • Document your data layer schema for team reference
  • Test data layer thoroughly before production release
  • Monitor data quality with server-side validation