Apple Search Ads Troubleshooting & Debugging | OpsBlu Docs

Apple Search Ads Troubleshooting & Debugging

Fix Apple Search Ads attribution token errors, SKAdNetwork postback failures, and conversion value mismatches with diagnostics.

Symptom Library

  • requestAttributionDetails returning nil or timing out after 3 seconds
  • SKAdNetwork postbacks not received by your endpoint
  • Conversion values stuck at 0 or not updating to expected schema values
  • Apple Ads Attribution API returning HTTP 403 or 401 errors
  • MMP (AppsFlyer, Adjust, Branch) install counts diverging from Apple dashboard by more than 20%
  • App Analytics showing zero Search Ads installs despite active campaigns

SKAdNetwork Attribution Debugging

Common failure points:

  1. Missing SKAdNetworkItems in Info.plist -- Apple Search Ads uses cstr6suwn9.skadnetwork. Verify:
<key>SKAdNetworkItems</key>
<array>
  <dict>
    <key>SKAdNetworkIdentifier</key>
    <string>cstr6suwn9.skadnetwork</string>
  </dict>
</array>
  1. Postback endpoint unreachable -- Your server must respond within 5 seconds with 200. Test with:
curl -X POST https://your-endpoint.com/skadnetwork \
  -H "Content-Type: application/json" \
  -d '{"version":"4.0","ad-network-id":"cstr6suwn9.skadnetwork","campaign-id":42}'
  1. Timer window expired -- SKAdNetwork 4.0 uses three postback windows: 0-2 days, 3-7 days, and 8-35 days. Conversion values set after the window closes are silently dropped.

Swift Diagnostic Code

Attribution Token Retrieval

Use AdServices (iOS 14.3+) to retrieve the attribution token. This replaces the deprecated iAd framework:

import AdServices

func fetchAttributionToken() {
    do {
        let token = try AAAttribution.attributionToken()
        print("[SearchAds] Token retrieved: \(token.prefix(20))...")

        var request = URLRequest(url: URL(string: "https://api-adservices.apple.com/api/v1/")!)
        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
            if let error = error { print("[SearchAds] Network error: \(error.localizedDescription)"); return }
            let status = (response as? HTTPURLResponse)?.statusCode ?? 0
            switch status {
            case 200:
                guard let data = data,
                      let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
                print("[SearchAds] Attributed: \(json["attribution"] ?? false), Org: \(json["orgId"] ?? "nil")")
            case 400: print("[SearchAds] ERROR 400: Invalid token. Regenerate.")
            case 401: print("[SearchAds] ERROR 401: Token expired/used. Tokens are single-use.")
            case 403: print("[SearchAds] ERROR 403: Not entitled. Check Xcode Signing & Capabilities.")
            case 404: print("[SearchAds] 404: Organic install (no attribution data).")
            case 500: print("[SearchAds] ERROR 500: Apple server error. Retry with backoff.")
            default:  print("[SearchAds] Unexpected status: \(status)")
            }
        }.resume()
    } catch {
        let code = (error as NSError).code
        // code 1: networkError (no internet), code 2: internalError (framework bug)
        print("[SearchAds] AAAttribution error (code \(code)): \(error.localizedDescription)")
    }
}

SKAdNetwork Conversion Value Update

import StoreKit

func updateConversionValue(fineValue: Int, coarseValue: SKAdNetwork.CoarseConversionValue) {
    if #available(iOS 16.1, *) {
        SKAdNetwork.updatePostbackConversionValue(fineValue, coarseValue: coarseValue) { error in
            if let error = error {
                let code = (error as NSError).code
                // code 1: invalid value (out of 0-63), code 2: window expired, code 3: no initial value set
                print("[SKAdNetwork] Update failed (code \(code)): \(error.localizedDescription)")
            } else {
                print("[SKAdNetwork] Updated to fine=\(fineValue), coarse=\(coarseValue)")
            }
        }
    } else {
        SKAdNetwork.updatePostbackConversionValue(fineValue) { error in
            if let error = error { print("[SKAdNetwork] Legacy update failed: \(error.localizedDescription)") }
        }
    }
}

Debug Logging on Launch

Add to AppDelegate.didFinishLaunchingWithOptions for attribution diagnostics:

#if DEBUG
print("[SearchAds] iOS \(UIDevice.current.systemVersion)")
if #available(iOS 14.3, *) { fetchAttributionToken() }
let skItems = Bundle.main.object(forInfoDictionaryKey: "SKAdNetworkItems") as? [[String: String]]
print("[SearchAds] SKAdNetwork IDs: \(skItems?.count ?? 0), Apple registered: \(skItems?.contains { $0["SKAdNetworkIdentifier"] == "cstr6suwn9.skadnetwork" } ?? false)")
#endif

Apple Ads Attribution API Response Codes

HTTP Code Error Fix
200 Success Parse the attribution and orgId fields
400 Invalid attribution token format Regenerate the token via AAAttribution.attributionToken()
401 Unauthorized -- invalid or expired token Tokens are single-use and expire after 24 hours; request a new one
403 App not entitled for Search Ads Enable the Search Ads entitlement in Xcode under Signing & Capabilities
404 No attribution data found User may have installed organically; handle this as a non-attributed install
500 Apple server error Retry with exponential backoff (max 3 retries)

Framework Error Codes

AAAttributionError.networkError (code 1)

Device cannot reach api-adservices.apple.com. Caused by no internet on launch or VPN/firewall blocking Apple's domain.

  • Fix: Cache the failure and retry on next app launch or when reachability changes. Do not block the main thread.

AAAttributionError.internalError (code 2)

Internal framework failure, not caused by your code. Rare, typically an iOS bug.

  • Fix: Log the iOS version and device model. File a Feedback Assistant report. No client-side workaround.

SKAdNetworkError.missingPostbackConversionValue (code 3)

Called updatePostbackConversionValue without setting an initial value after install.

  • Fix: Call updatePostbackConversionValue on first launch after install, even with value 0, to start the SKAdNetwork timer.

App Not Entitled Error (Xcode Fix)

If AAAttribution.attributionToken() throws or the API returns 403:

  1. In Xcode, select the app target > Signing & Capabilities > + Capability > search "AdServices".
  2. If missing, regenerate your Provisioning Profile in the Apple Developer portal with com.apple.developer.ad-services enabled for your App ID.
  3. Clean build folder (Cmd+Shift+K) and rebuild.

App Store Connect Integration Issues

  1. Confirm the same Apple ID owns both the App Store Connect account and the Search Ads organization.
  2. Verify the SKAdNetwork conversion value schema is configured in App Store Connect > App Analytics > Frameworks > SKAdNetwork.
  3. Allow 48 hours after first install for data to appear (Apple aggregates and applies privacy thresholds).
  4. Minimum 10 installs/day from Search Ads required; below this, Apple suppresses data for privacy.

MMP Discrepancy Causes

Attribution Methodology Differences

  • AppsFlyer uses deterministic matching on the Apple attribution token, falling back to probabilistic fingerprinting. This can inflate counts by 5-15% compared to Apple's deterministic-only dashboard.
  • Adjust relies on the AdServices token as primary signal with device-level click matching as fallback. Adjust may attribute to Search Ads even if a later organic link click exists, depending on your attribution window.
  • Branch uses server-to-server integration and attributes differently when deep links are involved. A user who clicks a Search Ads result, then later installs via a Branch deep link, may be attributed to Branch instead.

Reporting Lag

Apple Search Ads data updates on a 24-hour cycle (midnight PST cutoff). MMPs process installs in near-real-time. Always compare completed date ranges with a 48-hour buffer to avoid mismatches.

Privacy Threshold Suppression

Apple suppresses data below privacy thresholds:

  • Fewer than 10 installs/day per campaign: counts may be rounded or hidden.
  • Demographic breakdowns suppressed below 100 installs per segment.
  • LAT-on (Limit Ad Tracking) users are excluded from attribution entirely and appear as organic.

The MMP-vs-Apple gap widens for low-volume campaigns and privacy-conscious user bases.

Keyword Match Type Conflicts

  • Exact vs. Broad overlap: Exact Match takes priority. Use negative keywords in the Broad Match ad group to prevent cannibalization.
  • Search Match conflicts: When enabled alongside manual keywords, Apple may serve ads for queries you already bid on, inflating costs. Check the Search Terms report weekly.
  • Budget delivery: "Standard" delivery may underspend if bids are below the suggested range. Switch to "Accelerated" temporarily to diagnose.

Campaign Budget Delivery Issues

  1. Check the Suggested Bid Range -- bids below the minimum yield zero impressions.
  2. Review Audience Refinements -- overly narrow age/gender/location reduces inventory.
  3. Verify the correct storefront (country/region). A US campaign will not serve in UK App Store.
  4. Apple may overspend by up to 20% on high-traffic days and compensate over the billing period.

Search Ads API Troubleshooting

Error Code Fix
UNAUTHORIZED Refresh OAuth token. If refresh fails, re-authenticate through the Search Ads OAuth flow.
FORBIDDEN API user needs "Campaign Manager" role. Update in Search Ads UI > Users.
INVALID_FIELD Check: dailyBudgetAmount >= 0.01, cpaGoal > 0, countries use ISO 3166-1 alpha-2.
ENTITY_NOT_FOUND Verify entity ID belongs to the orgId in your request header.
LIMIT_EXCEEDED Max 1,000 entities per batch request. Split into multiple calls.

Report API Timeout Handling

  • Reports spanning 90+ days may take 30-60 seconds. Set HTTP client timeout to at least 120 seconds.
  • If a report times out, reduce the date range or filter to a single campaign.
  • For recurring reports, use incremental date ranges (fetch yesterday only) instead of the full window each time.
  • The API returns 429 Too Many Requests above 200 calls/hour per organization. Implement a request queue with 20-second intervals for bulk operations.

OAuth 2.0 Client Secret Rotation

Apple Search Ads API client secrets have a 180-day maximum lifetime. When expired, all calls fail with UNAUTHORIZED.

  1. Generate a new secret in the Search Ads UI under Settings > API.
  2. The secret is a JWT signed with your private key. Set exp to no more than 180 days from iat.
  3. Deploy the new secret before the old one expires (set a reminder 2 weeks prior). The old secret is immediately invalid after rotation with no grace period.

Escalation & Communication

  • Apple Search Ads support: File tickets via Campaign Manager with Org ID and campaign ID.
  • Apple Developer support for ADServices and SKAdNetwork framework bugs.
  • MMP technical support for SDK-level attribution discrepancies.

Preventive Maintenance

  • Monthly: Audit SKAdNetwork conversion value schema against actual in-app events.
  • Quarterly: Compare Apple dashboard installs to MMP-reported installs.
  • After iOS updates: Test AAAttribution.attributionToken() -- behavior changes with major releases.
  • Weekly: Review Search Terms report and prune negative keywords.
  • After SDK changes: Verify SKAdNetworkItems in Info.plist.