Do you run Apple Search Ads for your iOS app, and would like to see which users came from which ads? This post describes the necessary steps to fetch this attribution data (which Apple provides, natively)!
I'm part of a small startup building an iOS app that teaches coding, and a few months ago ago we decided that we needed to ramp up our attribution efforts, in order to see which of our marketing endeavours were paying off.
We started out using Branch links for our non-Apple Search Ads campaigns. It's a service that allows you to create links leading to your app, and then figure out which campaigns users came from with some minimal app-level changes. We realized that for Apple Search Ads we would need a different solution, as the ads are presented inside the App Store, and therefore we would not have the ability to link the user to a third-party service which would do the attribution magic.
The Apple Search Ads console provides information about clicks and installs, however we wanted to be able to also follow users beyond their install. Thankfully, Apple provides a native way to get campaign data, allowing developers to fetch this data after users either install or open the app from an Apple Search ad.
This tutorial was created using Xcode Version 12.5, using Swift 5, on iOS 14.5, and I'll be updating it if need be after this week's WWDC!
Fetching and sending attribution data for Apple Search Ads
We live in a post iOS 14.3 world, which was the iOS version where Apple replaced its iAd framework with a framework called AdServices. Therefore, as long as you support versions prior to iOS 14.3, you will need to implement two ways of fetching campaign data, using the two frameworks.
Let's start by adding the necessary frameworks to our app. In order to do so, head to your app's General settings, and under Frameworks, Libraries, and Embedded Content, add the following two frameworks: AdServices.framework and iAd.framework.
AdServices implementation, valid for versions 14.3 and up.
The first step is to check for the user's attribution token. This token is generated regardless of whether the user actually opened the app from a campaign or not.
if #available(iOS 14.3, *) {
if let attributionToken = try? AAAttribution.attributionToken() {
}
}
The next step is to request the attribution data, using the user's attribution token.
if #available(iOS 14.3, *) {
if let attributionToken = try? AAAttribution.attributionToken() {
let request = NSMutableURLRequest(url: URL(string:"https://api-adservices.apple.com/api/v1/")!)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(attributionToken.utf8)
}
}
You can now find the campaign info you're looking for! You can see the full list of provided info in the attribution payload here. For the sake of this tutorial I'll be using just the campaign ID.
The values of the various attributes, like the campaign ID, are set to a mock Int value of 1234567890 in the case in which the user has not actually come from a campaign.
I then proceed to send the entire result object to Amplitude, which is the analytics tool that we use. If you don't already use some sort of analytics tool, I highly recommend Amplitude, with their free tier being very comprehensive. You could however also store this data as a user attribute in your backend if you'd prefer, and only store one of the attribution attributes as opposed to all of them. I've left that part of the implementation blank, assuming you already have some sort of tracking system in place.
if #available(iOS 14.3, *) {
if let attributionToken = try? AAAttribution.attributionToken() {
let request = NSMutableURLRequest(url: URL(string:"https://api-adservices.apple.com/api/v1/")!)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(attributionToken.utf8)
let task = URLSession.shared.dataTask(with: request as URLRequest) { (data, _, error) in
if let error = error {
print(error)
return
}
do {
let result = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as! [String:Any]
print("Search Ads attribution info:", result)
if let campaignId = result["campaignId"] as? Int {
// Only send data to Amplitude if it is not mock data, in which case the campaign id would be the integer below
if campaignId != 1234567890 {
// Send data to your tracking tool, we use Amplitude, with the line of code below.
// Amplitude.instance().logEvent("open_app_from_apple_search_ad, with EventProperties: result)
}
}
} catch {
print(error)
}
}
task.resume()
}
}
And that's it for iOS 14.3+!
iAd framework implementation (deprecated, used in versions 14.2 and under)
The iAd framework has a class called ADClient, through which you can simply request attribution details. You then receive a result object which differs slightly from the one fetched with AdServices. It contains two items: the Search Ads version and an attribution dictionary similar to the one returned above, and you can see the full payload here.
ADClient.shared().requestAttributionDetails({ (attributionDetails, error) in
guard let attributionDetails = attributionDetails else {
print("Search Ads error: \(error?.localizedDescription ?? "")")
return
}
for (version, adDictionary) in attributionDetails {
print("Search Ads version:", version)
if var attributionInfo = adDictionary as? Dictionary<String, Any> {
print("Search Ads attribution info:", attributionInfo)
}
}
}
})
In order to be able to consistently segment our users using Amplitude, I've added an extra attribute to the dictionary called campaignId, which is what it is called in AdServices, as opposed to iad-campaign-id in the iAd framework. Depending on your tracking system, you could do so as well, or potentially rename the attributes in the result.
It's also important to note that the mock data is in String format as opposed to Int format.
ADClient.shared().requestAttributionDetails({ (attributionDetails, error) in
guard let attributionDetails = attributionDetails else {
print("Search Ads error: \(error?.localizedDescription ?? "")")
return
}
for (version, adDictionary) in attributionDetails {
print("Search Ads version:", version)
if var attributionInfo = adDictionary as? Dictionary<String, Any> {
print("Search Ads attribution info:", attributionInfo)
if let campaignId = attributionInfo["iad-campaign-id"] as? String {
// Only send data to Amplitude if it is not mock data, in which case the campaign id would be the string of numbers below
if campaignId != "1234567890" {
// Add campaignID attribute in order to have consistent property which which to segment users on all iOS versions
attributionInfo["campaignId"] = campaignId
// Send data to your tracking tool, we use Amplitude, with the line of code below.
// Amplitude.instance().logEvent("open_app_from_apple_search_ad, with EventProperties: attributionInfo)
}
}
}
}
})
You can now package the whole implementation into a function:
func requestAndSendAttributionData() {
if #available(iOS 14.3, *) {
if let attributionToken = try? AAAttribution.attributionToken() {
let request = NSMutableURLRequest(url: URL(string:"https://api-adservices.apple.com/api/v1/")!)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(attributionToken.utf8)
let task = URLSession.shared.dataTask(with: request as URLRequest) { (data, _, error) in
if let error = error {
print(error)
return
}
do {
let result = try JSONSerialization.jsonObject(with: data!, options: .allowFragments) as! [String:Any]
print("Search Ads attribution info:", result)
if let campaignId = result["campaignId"] as? Int {
// Only send data to Amplitude if it is not mock data, in which case the campaign id would be the integer below
if campaignId != 1234567890 {
// Send data to your tracking tool, we use Amplitude, with the line of code below.
// Amplitude.instance().logEvent("open_app_from_apple_search_ad, with EventProperties: result)
}
}
} catch {
print(error)
}
}
task.resume()
}
} else {
ADClient.shared().requestAttributionDetails({ (attributionDetails, error) in
guard let attributionDetails = attributionDetails else {
print("Search Ads error: \(error?.localizedDescription ?? "")")
return
}
for (version, adDictionary) in attributionDetails {
print("Search Ads version:", version)
if var attributionInfo = adDictionary as? Dictionary<String, Any> {
print("Search Ads attribution info:", attributionInfo)
if let campaignId = attributionInfo["iad-campaign-id"] as? String {
// Only send data to Amplitude if it is not mock data, in which case the campaign id would be the string of numbers below
if campaignId != "1234567890" {
// Add campaignID attribute in order to have consistent property which which to segment users on all iOS versions
attributionInfo["campaignId"] = campaignId
// Send data to your tracking tool, we use Amplitude, with the line of code below.
// Amplitude.instance().logEvent("open_app_from_apple_search_ad, with EventProperties: attributionInfo)
}
}
}
}
})
}
}
You can then call requestAndSendAttributionData() whenever the app is opened, and this will fetch and send the user's campaign information both after a fresh install, and if they already had the app installed.
If you'd like to check out Apple's official documentation for the frameworks above, you can find it here:
Happy attributing! I hope this helps with your marketing efforts, and let me know if you'd like me to go more in-depth into how we use Branch, or how we measure our analytics as a whole.
Top comments (5)
Awesome article- thanks for posting!
For anyone coming across this in 2023:
Per this documentation, Apple seems to have retired the Apple Search Ads iAd Attribution API (used here for iOS 14.2 and earlier).
Hi,
does it work also without asking permission to AppTrackingTransparency framework?
Thanks!
Hi,
the right question, I'm afraid the answer would be 'No' for iAd framework implementation:
developer.apple.com/documentation/...
That's not the case for Apple Search Ads - we've been successfully receiving attribution data for all OS versions without requesting tracking permission.
Hi! For the purpose of Apple Search Ads, there is no sort of tracking permission needed from the user's end, for either the iAd or AdServices implementation.