Overview
Apple Search Ads attribution can be implemented using client-side (in-app) or server-side (backend API) approaches. Understanding the trade-offs is essential for accurate attribution, privacy compliance, and optimal performance.
Client-Side Attribution
How It Works
- ADServices framework runs in the iOS app
- Attribution token requested from device
- Token validated with Apple's API from client
- SKAdNetwork postbacks received from Apple servers
- Attribution data stored locally in app
Implementation
import AdServices
// Client-side attribution token retrieval
func getClientSideAttribution() {
guard let token = try? AAAttribution.attributionToken() else {
print("No attribution token available")
return
}
// Validate directly from client
validateAttributionClientSide(token: token)
}
func validateAttributionClientSide(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 else {
print("Error: \(error?.localizedDescription ?? "Unknown")")
return
}
if let attribution = try? JSONDecoder().decode(AttributionResponse.self, from: data) {
// Store locally
saveAttributionLocally(attribution)
// Optionally send to your server
sendAttributionToServer(attribution)
}
}.resume()
}
struct AttributionResponse: Codable {
let attribution: Bool
let orgId: Int?
let campaignId: Int?
let adGroupId: Int?
let keywordId: Int?
}
Advantages
- Simple implementation: Add framework and make API call
- Real-time attribution: Immediate results on device
- No server dependencies: Works without backend infrastructure
- Lower latency: Direct communication with Apple's API
- Offline capable: Can queue and retry locally
- Easier debugging: Test directly on device
Limitations
- Network dependency: Requires internet connection at app launch
- Client-side storage: Attribution data only on device
- Limited analytics: Harder to aggregate across users
- Privacy constraints: Limited by iOS privacy frameworks
- No historical analysis: Data lost if app deleted
- Fraud vulnerability: Client-side validation easier to spoof
Server-Side Attribution
How It Works
- App sends attribution token to your server
- Server validates token with Apple's API
- Attribution data stored in your database
- Server-to-server communication for validation
- Centralized attribution data management
Implementation
Client-Side: Send Token to Server
import AdServices
func getServerSideAttribution() {
guard let token = try? AAAttribution.attributionToken() else {
print("No attribution token available")
return
}
// Send to your server for validation
sendTokenToServer(token: token)
}
func sendTokenToServer(token: String) {
let endpoint = "https://api.yourdomain.com/attribution/validate"
var request = URLRequest(url: URL(string: endpoint)!)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [
"attribution_token": token,
"device_id": getDeviceId(),
"app_version": getAppVersion(),
"os_version": UIDevice.current.systemVersion,
"timestamp": Date().timeIntervalSince1970
]
request.httpBody = try? JSONSerialization.data(withJSONObject: payload)
URLSession.shared.dataTask(with: request) { data, response, error in
guard let data = data else { return }
if let result = try? JSONDecoder().decode(ServerAttributionResult.self, from: data) {
print("Server attribution: \(result)")
}
}.resume()
}
struct ServerAttributionResult: Codable {
let success: Bool
let attribution: AttributionResponse?
let userId: String?
}
func getDeviceId() -> String {
return UIDevice.current.identifierForVendor?.uuidString ?? ""
}
func getAppVersion() -> String {
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
Server-Side: Validate and Store (Python Example)
from flask import Flask, request, jsonify
import requests
import hashlib
import time
from datetime import datetime
app = Flask(__name__)
@app.route('/attribution/validate', methods=['POST'])
def validate_attribution():
data = request.json
token = data.get('attribution_token')
device_id = data.get('device_id')
if not token:
return jsonify({'success': False, 'error': 'No token provided'}), 400
# Validate with Apple's API
attribution = validate_with_apple(token)
if attribution:
# Store in database
user_id = store_attribution(
device_id=device_id,
attribution=attribution,
app_version=data.get('app_version'),
os_version=data.get('os_version')
)
return jsonify({
'success': True,
'attribution': attribution,
'user_id': user_id
})
else:
return jsonify({
'success': False,
'error': 'Attribution validation failed'
}), 400
def validate_with_apple(token):
"""Validate attribution token with Apple's API"""
endpoint = 'https://api-adservices.apple.com/api/v1/'
try:
response = requests.post(
endpoint,
data=token,
headers={'Content-Type': 'text/plain'},
timeout=10
)
if response.status_code == 200:
return response.json()
else:
print(f"Apple API error: {response.status_code}")
return None
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
return None
def store_attribution(device_id, attribution, app_version, os_version):
"""Store attribution in database"""
# Example using SQLAlchemy or your DB
user_id = hashlib.sha256(device_id.encode()).hexdigest()[:16]
attribution_record = {
'user_id': user_id,
'device_id': device_id,
'attribution_source': 'apple_search_ads' if attribution.get('attribution') else 'organic',
'campaign_id': attribution.get('campaignId'),
'ad_group_id': attribution.get('adGroupId'),
'keyword_id': attribution.get('keywordId'),
'country': attribution.get('countryOrRegion'),
'app_version': app_version,
'os_version': os_version,
'attributed_at': datetime.utcnow(),
'created_at': datetime.utcnow()
}
# Insert into database
# db.attribution.insert_one(attribution_record)
return user_id
Server-Side: Node.js Example
const express = require('express');
const axios = require('axios');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.post('/attribution/validate', async (req, res) => {
const { attribution_token, device_id, app_version, os_version } = req.body;
if (!attribution_token) {
return res.status(400).json({
success: false,
error: 'No token provided'
});
}
try {
// Validate with Apple's API
const attribution = await validateWithApple(attribution_token);
if (attribution) {
// Store in database
const userId = await storeAttribution({
deviceId: device_id,
attribution: attribution,
appVersion: app_version,
osVersion: os_version
});
res.json({
success: true,
attribution: attribution,
userId: userId
});
} else {
res.status(400).json({
success: false,
error: 'Attribution validation failed'
});
}
} catch (error) {
console.error('Attribution error:', error);
res.status(500).json({
success: false,
error: 'Internal server error'
});
}
});
async function validateWithApple(token) {
const endpoint = 'https://api-adservices.apple.com/api/v1/';
try {
const response = await axios.post(endpoint, token, {
headers: {
'Content-Type': 'text/plain'
},
timeout: 10000
});
return response.data;
} catch (error) {
console.error('Apple API error:', error);
return null;
}
}
async function storeAttribution(data) {
const userId = crypto.createHash('sha256')
.update(data.deviceId)
.digest('hex')
.substring(0, 16);
const attributionRecord = {
userId: userId,
deviceId: data.deviceId,
attributionSource: data.attribution.attribution ? 'apple_search_ads' : 'organic',
campaignId: data.attribution.campaignId,
adGroupId: data.attribution.adGroupId,
keywordId: data.attribution.keywordId,
country: data.attribution.countryOrRegion,
appVersion: data.appVersion,
osVersion: data.osVersion,
attributedAt: new Date(),
createdAt: new Date()
};
// Insert into database
// await db.collection('attributions').insertOne(attributionRecord);
return userId;
}
Advantages
- Centralized data: All attribution in one database
- Advanced analytics: Easy to aggregate and analyze
- Data persistence: Attribution data survives app deletion
- Fraud prevention: Server-side validation harder to spoof
- Cross-platform: Link mobile attribution with web analytics
- Historical analysis: Query attribution trends over time
- Data enrichment: Combine with other data sources
- Privacy control: Manage PII server-side
Limitations
- Implementation complexity: Requires backend development
- Infrastructure costs: Server hosting and maintenance
- Latency: Additional network hop to your server
- Server dependency: Requires backend availability
- More moving parts: Client + server + database
- Debugging complexity: Harder to troubleshoot
Hybrid Approach (Recommended)
Combine both methods for maximum reliability and functionality:
Implementation
// Client-side: Hybrid attribution
func getHybridAttribution() {
guard let token = try? AAAttribution.attributionToken() else {
print("No attribution token available")
return
}
// 1. Validate client-side for immediate use
validateAttributionClientSide(token: token)
// 2. Send to server for long-term storage
sendTokenToServer(token: token)
}
func validateAttributionClientSide(token: String) {
// Quick client-side validation
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(AttributionResponse.self, from: data) else {
return
}
// Store locally for offline access
saveAttributionLocally(attribution)
// Update UI immediately
DispatchQueue.main.async {
self.updateUIWithAttribution(attribution)
}
}.resume()
}
SKAdNetwork: Server-Side Postback Handling
Configure Postback URL
In App Store Connect, configure your postback URL to receive SKAdNetwork conversions:
https://api.yourdomain.com/skadnetwork/postback
Server-Side Postback Handler (Python)
from flask import Flask, request
import json
import hmac
import hashlib
@app.route('/skadnetwork/postback', methods=['POST'])
def handle_skadnetwork_postback():
"""Handle SKAdNetwork postback from Apple"""
data = request.json
# Validate postback signature
if not validate_skadnetwork_signature(data):
return jsonify({'error': 'Invalid signature'}), 403
# Extract conversion data
conversion_data = {
'campaign_id': data.get('campaign-id'),
'conversion_value': data.get('conversion-value'),
'source_app_id': data.get('source-app-id'),
'transaction_id': data.get('transaction-id'),
'redownload': data.get('redownload', False),
'received_at': datetime.utcnow()
}
# Store in database
store_skadnetwork_conversion(conversion_data)
return jsonify({'status': 'success'}), 200
def validate_skadnetwork_signature(data):
"""Validate SKAdNetwork postback signature"""
# Implement signature validation
# https://developer.apple.com/documentation/storekit/skadnetwork
return True # Simplified for example
def store_skadnetwork_conversion(data):
"""Store SKAdNetwork conversion"""
# db.skadnetwork_conversions.insert_one(data)
pass
Use Case Recommendations
E-commerce Apps
Recommended: Hybrid approach
- Client-side: Immediate attribution for personalization
- Server-side: Long-term LTV tracking and analytics
Gaming Apps
Recommended: Client-side + SKAdNetwork
- Client-side: Quick attribution for gameplay customization
- SKAdNetwork: Privacy-preserving conversion tracking
- Optional server-side for cohort analysis
Subscription Apps
Recommended: Server-side
- Centralized subscription tracking
- Cross-platform subscription management
- Advanced revenue analytics
Enterprise Apps
Recommended: Server-side
- Compliance and audit requirements
- Integration with enterprise systems
- Advanced security controls
Decision Matrix
| Scenario | Client-Side | Server-Side | Hybrid |
|---|---|---|---|
| Simple attribution | ✓ | ||
| Advanced analytics | ✓ | ✓ | |
| Offline capability | ✓ | ✓ | |
| Fraud prevention | ✓ | ✓ | |
| Cross-platform tracking | ✓ | ✓ | |
| Data persistence | ✓ | ✓ | |
| Low latency | ✓ | ✓ | |
| Privacy compliance | ✓ | ✓ | ✓ |
Performance Comparison
| Metric | Client-Side | Server-Side | Hybrid |
|---|---|---|---|
| Implementation Time | 1-2 days | 3-5 days | 4-7 days |
| Latency | 100-300ms | 200-500ms | 100-500ms |
| Reliability | Medium | High | High |
| Data Quality | Medium | High | High |
| Maintenance | Low | Medium | Medium |
| Cost | Low | Medium | Medium |
Best Practices
- Start with client-side, migrate to hybrid as needs grow
- Always validate server-side for high-value conversions
- Implement retry logic for failed server communications
- Cache attribution locally for offline access
- Use server-side for fraud detection and validation
- Monitor attribution latency and success rates
- Implement fallback mechanisms for server failures
- Respect user privacy regardless of implementation
- Test both approaches before production deployment
- Document your architecture for team reference
Troubleshooting
Client-Side Issues
- No attribution token: Check ADServices framework integration
- API timeout: Implement retry with exponential backoff
- Invalid response: Verify network connectivity and Apple API status
Server-Side Issues
- Token validation fails: Check server can reach Apple's API
- Database errors: Implement proper error handling and logging
- High latency: Optimize database queries and use caching
Hybrid Issues
- Inconsistent data: Implement deduplication logic
- Race conditions: Use proper synchronization mechanisms
- Increased complexity: Maintain clear documentation