一、归因概要
广告归因,目的是用于衡量广告带来的激活用户的成本以及后续进一步的用户质量表现。 Apple Ads 广告平台是基于 App Store(站内广告),同时属于自归因平台(通常称为 SAN)。这两个因素,决定了 ASA 与其他大部分广告平台(站外广告)的区别。 ASA 广告投放前,无需创建投放链接、监测链接,ASA 归因由 Apple 自身完成,可以保证用户隐私同时还能做到用户级归因 ,目前获取归因数据可采用自归因或第三方归因的方式。
以下是该方案的要点和广告主应对措施:
-
此归因方案仅适用 Apple Search Ads 广告;仅支持 iOS 14.3 及更高版本;14.3 之前的版本,需使用 iAd Framework
-
从 2023 年 2 月 7 日开始,iAd 框架停止用于归因 Apple Ads 的广告安装。
-
所有通过iAd Framework发送的请求都会收到 "iad-attribution"=false的错误,具体请查看iAd Changelog | Apple Developer Documentation
-
开发者需实施 AdSercices 框架以归因来自 Apple Ads 的广告安装。此框架于 2021 年 1 月发布,如果已完成实施、或使用主流 MMP 的开发者无需做更改。
-
AdServices[3] 仅支持 iOS 14.3 及更高版本的设备。
-
届时 14.2 及更低旧版本的设备将无法归因苹果广告安装。
-
-
此方案将极大提高 ASA 广告安装的激活率
-
安装到激活的差距逐渐降至极低
-
此方案涉及前后端的系统开发,需自己归因的开发者应尽早制定计划、安排实施
-
使用 MMP 服务的开发者,与您的服务提供商沟通,了解其SDK对此方案的支持进度
-
无论开发者自己实施还是采用 MMP,均需要发布新的app版本
-
针对 iOS 14 及更高版本的设备,LAT 的概念已失效,受众的年龄和性别定向不再排除限制跟踪的用户
-
什么是ASA?
ASA(Apple Search Ads),也叫ASM(App Store Marketing)---应用商店广告,即用户在App Store 搜索应用时出现在搜索结果上方的广告。
搜索广告有三种展现形式:标题+icon+截图/描述/视频, App 在搜索广告上的展示内容与其在自然搜索排名下的内容相同,开发者不必也不能单独为搜索广告设置素材,且无法选择或设置广告具体以哪种方式展现;
-
ASA广告竞价逻辑
ASA广告类似谷歌搜索广告,属于竞价广告模式,以点击计费;
广告展现量受5个因素影响,分别是相关性、出价、关键词热度、竞争者、投放位置;广告单价受3个因素影响,分别是相关性、出价、竞争者;
1)相关性(关键词和APP之间的相关性):一个关键词和这个 App 之间的相关性是由这个 App 的元数据和用户 行为所决定的;元数据即 App 的标题、icon、截图、描述等。
2)出价:在其他参数不变的情况下,价高者得;
其他影响因素:产品权重(安装总量/总榜/分类榜单越高,是受到用户喜爱得产品)、关键词热度(aso吸引力-100关键词的覆盖)
二、归因实施说明
归因业务流程图
这个归因方案,包含两部分:客户端的 AdServices 框架,和从苹果Search Ads归因服务器获取归因数据的 RESTful API。
下图说明了结合使用 AdServices 框架和 RESTful API 来完成归因。
Request Token 获取 token
-
(NSString *)attributionTokenWithError:(NSError * _Nullable *)error;
AdServices 框架返回的 token 为字符串类型,并且只有 24 小时有效期。
AAAttributionErrorCode 归因错误枚举值
typedef enum AAAttributionErrorCode : NSInteger {// 没有返回 token,网络不可用。AAAttributionErrorCodeNetworkError = 1,// 没有返回 token,发生了内部错误。AAAttributionErrorCodeInternalError = 2,// 没有返回 token,操作系统平台不支持。AAAttributionErrorCodePlatformNotSupported = 3
} AAAttributionErrorCode;
Request attribution 获取归因数据
您可以将 token 提供给 MMP,或者使用该 token 在 24 小时内进行 POST API 调用,以获取归因数据。在请求正文中带上 token:
POST https://api-adservices.apple.com/api/v1/
yourtokenyourtoken
Response Codes 返回状态码
Response状态码 | Description描述 |
200 | 成功。API 找到了匹配的归因记录,返回值包含 attribution=true。如果API没有找到对应的归因记录,返回值为 attribution=false。在这种情况,状态码 200 仅表示服务器有数据返回。 |
400 | token 无效。 |
404 | 没有找到。API 无法获取到请求的归因记录。Tokens 只有 24 小时的有效期。如果请求超过了 24 小时,苹果会返回 404 状态码。如果 token 是有效的,一个最佳实践:最多重试 3 次,每次间隔 5 秒钟。 |
500 | 服务器暂时关闭或无法访问。请求可能是有效的,但是你需要选择合适的时间点进行重试。 |
代码示例:
- #import <AdServices/AdServices.h>
(void) methodToGetToken {if (@available(iOS 14.3, *)) {NSError *error;NSString *token = [AAAttribution attributionTokenWithError:&error];if (token != nil) {// 发送POST请求归因数据}} else {// Fallback on earlier versions}
}
- (void) attributionWithToken:(NSString *)token {NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];NSURL *url = [NSURL URLWithString:@"https://api-adservices.apple.com/api/v1/"];NSMutableURLRequest
request = [NSMutableURLRequest requestWithURL:urlcachePolicy:NSURLRequestUseProtocolCachePolicytimeoutInterval:60.0];[request addValue:@"application/json" forHTTPHeaderField:@"Content-Type"];[request setHTTPMethod:@"POST"];
* NSData*postData = [token dataUsingEncoding:NSUTF8StringEncoding];[request setHTTPBody:postData];NSURLSessionDataTask *postDataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {NSError *resError;NSMutableDictionary *resDic = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:&resError];}];[postDataTask resume];
}
字段 | 类型 | 说明 |
attribution | Boolean | 如果用户在应用下载前 30 天点击了 App Store、Apple News 以及 Stocks,则其值为 true。如果 API 找不到匹配的归因记录,则为false。 |
orgId | Integer | 广告系列所属的账户 ID。 |
campaignId | Integer | 广告系列 ID。 |
conversionType | String | 表明是否首次下载。"Redownload" 说明用户在本设备下载/卸载过,或者用同一账户在其他设备下载过。 |
clickDate | Date/time string | 用户点击相应广告的日期和时间。此字段仅出现在详细归因数据包中。 |
adGroupId | Integer | 广告组 ID。 |
countryOrRegion | String | 国家或地区。 |
keywordId | Integer | 关键字的 ID。 |
creativeSetId | Integer | 广告素材集的 ID。 |
这里只要是attribution=true,就意味着用户是通过点击广告下载的app。反之会返回false。原则是用户在下载app之前,30天内是否有广告点击的记录。所以客户如果只想判断用户是不是通过广告带来的,只看这个字段就可以,=true的情况下click date一定是在30天内的。
conversiontype是用来判断用户是首次安装还是重新安装,对于判断用户是否来自广告投放没有太大的关系。
客户端调用AdService框架
-
AdServices framework发起调用请求生成 Token 。
-
AdServices framework生成Token 返回(Token有效期24小时)。更多详请,请参阅 attributionToken
-
使⽤Token向Attribution API请求结果
-
Attribution API返回true or false,判断该客户是否点击过苹果搜索广告
-
True: ⽤户在过去30天内曾经点击过苹果广告,记录最近一次用户点击广告
-
False: ⽤户在过去30天没有点击过苹果广告(用户为IOS14.3以下版本请求Token就会报错)
-
-
MMP 或开发⼈员使⽤ Token 发起 RESTful API 获取归因记录请求,苹果的归因服务器响应请求。更多详请,请参阅Attribution Payload。
-
API 返回的归因数据中的键值与Apple Ads ⼴告系列管理 API 中的⼴告系列的字段相对应。更多详情,请参阅 Attribution Payload Descriptions。
*苹果官网参考文档:https://developer.apple.com/doc
三、ManagementAPI接⼊具体流程
授权广告主账户对应广告系列组“API只读”
-
需要广告主提供一个开发者账号AppleID(即邮箱)
-
授权后需要甲方到邮箱查收邀请邮件,并点击邮件中链接确认授权
开发者生成公钥+私钥,准备上传到ads.apple.com后台
-
生成公钥私钥:
-
如果您使用的是MacOS或类似UNIX的操作系统,OpenSSL可以原生运行。如果您使用的是Windows平台,则需要下载OpenSSL。
-
openssl ecparam -genkey -name prime256v1 -noout -out private-key.pemopenssl ec -in private-key.pem -pubout -out public-key.pem
上传公钥私钥,生成clientid、keyid、teamid
-
登录被授权的账号,查看右上⻆设置
-
粘贴前⾯⽣成的公钥进去,点击保存按钮;并记录⽣成的clientid、keyid、teamid
-
使⽤新⽣成的clientid、keyid、teamid,⽣成client_secret
-
生成出来的ID示例:
clientId SEARCHADS.aeb3ef5f-0c5a-4f2a-99c8-fca83f25a9
teamId SEARCHADS.hgw3ef3p-0w7a-8a2n-77c8-scv83f25a7
keyId a273d0d3-4d9e-458c-a173-0db8619ca7d7
-
使⽤3个id⽣成
client_secret
的示例
import jwt
import datetime as dt
client_id = 'SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577'
team_id = 'SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577'
key_id = 'bacaebda-e219-41ee-a907-e2c25b24d1b2'
audience = 'https://appleid.apple.com'
alg = 'ES256'
Define issue timestamp.
issued_at_timestamp = int(dt.datetime.utcnow().timestamp())
Define expiration timestamp. May not exceed 180 days from issue timestamp.
expiration_timestamp = issued_at_timestamp + 86400*180
Define JWT headers.
headers = dict()
headers['alg'] = alg
headers['kid'] = key_id
Define JWT payload.
payload = dict()
payload['sub'] = client_id
payload['aud'] = audience
payload['iat'] = issued_at_timestamp
payload['exp'] = expiration_timestamp
payload['iss'] = team_id
Path to signed private key.
KEY_FILE = 'private-key.pem'
with open(KEY_FILE,'r') as key_file:key = ''.join(key_file.readlines())
client_secret = jwt.encode(
payload=payload,
headers=headers,
algorithm=alg,
key=key
)
with open('client_secret.txt', 'w') as output:output.write(client_secret.decode("utf-8"))
-
JWT请求头和请求data示例
Header
{
"alg": "ES256",
"kid": "bacaebda-e219-41ee-a907-e2c25b24d1b2"
}
Payload
{
"iss": "SEARCHADS.hgw3ef3p-0w7a-8a2n-77c8-scv83f25a7", # 您上一步获得的teamId
"iat": 2234567891, # 创建客户端密钥时的 UNIX UTC 时间戳
"exp": 2234567900, # 客户端密码过期的 UNIX UTC 时间戳。该值必须大于当前时间
"aud": "https://appleid.apple.com",
"sub": "SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577" # 您上一步获得的client_secret
}
-
请求⽣成的
client_secret
示例
eyJraWQiOiJiYWNhZWJkYS1lMjE5LTQxZWUtYTkwNy1lMmMyNWIyNGQxYjIiLCJhbGciOiJFUzI1NiJ9.
eyJpc3MiOiJEcmVhbWNvbXBhbnkiLCJhdWQiOiJBdXRoZW50aWNhdG9yIiwiZXhwIjoxNTcxNjcwNjIx
LCJuYmYiOjE1NzE2NjcwMjEsInN1YiI6Im11c3RlciIsImNsaWVudF9pZCI6ImFiY2QxMjM0IiwiYWRt
aW4iOiJ0cnVlIn0.s4C3p9kVNFeRAB5tChatC3ldQX07v9mG7thL7FeEO6cClfNuiaLSgq8f8ymbfO3O
QYW_KuwaA1KYRuoy1JmKk 4DBbYLcz6aoABe0pzI5Z_6wgMzAyqz8pQtwDAcd4Idoi8JdRbtzZce9o-0
nZiFA4hVAXqYwpEYC4UU8ZmJO_z8tY4juHPTV3nDugdtqyNnmAiBoLryOfGNngQZccdY1_QvkXS1y0bg1
a0k8cVVtnq- _93fYJIt9Z64CTvlH3uOeh7uaEv3nIxpXhvhkTySpUmY8e04TO09oTyZijiloByv3KFQ9
2OOJ8L 5N5_CeEc5p9LWjT1pcX8ATamOycZz2Q
使⽤client_secret,请求苹果接⼝⽣成access_token
access_token
存在过期的情况,通常为1小时,需要定时维护
-
请求获取 access_token 示例
curl -X POST \
-H 'Host: appleid.apple.com' \
-H 'Content-Type: application/x-www-form-urlencoded' \
'https://appleid.apple.com/auth/oauth2/token?grant_type=client_credentials&
client_id=SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577&client_secret=eyJ0
eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.zI1NiIsImprdSI6Imh0dHBzOi8vYXV0aC5kZXYuYXB
pLnJpY29oL3YxL2Rpc2NvdmVyeS9rZXlzIiwia2lkIjoiMmIyZTgyMTA2NzkxZGM4ZmFkNzgxNW
Q3ZmI1NDRhNjJmNzJjMTZmYSJ9.eyJpc3MiOiJodHRwczovL2F1dGguZGV2LmFwaS5yaWNvaC8iL
CJhdWQiOiJodHRwczovL2lwcy5kZXYuYXBpLnJpY29oLyIsImlhdCI6MTQ5MDg1Mjc0MSwiZXhwI
joxNDkwODU2MzQxLCJjbGllbnRfaWQiOiI4ODQwMWU1MS05MzliLTQ3NzktYjdmNy03YzlmNGIzZj
kyYzAiLCJzY29wZSI6Imh0dHBzOi8vaXBzLmRldi5hcGkucmljb2gvdjEiLCJyaWNvaF9tc3Mi
OnsibWVkaWEiOnsicXVvdGEiOjEwLCJ0aHJvdHRsZSI6eyJ2YWx1ZSI6MCwid2luZG93IjowfX1
9fQ.jVq_c_cTzgsLipkJKBjAHzm8KDehW4rFA1Yg0EQRmqWmBDlEKtpRpDHZeF6ZSQfNH2OlrBW
FBiVDV9Th091QFEYrZETZ1IE1koAO14oj4kf8TCmhiG_CtJagvctvloW1wAdgMB1_Eubz9a8oim
cODqL7_uTmA5jKFx3ez9uoqQrEKZ51g665jSI6NlyeLtj4LrxpI9jZ4zTx1yqqjQx0doYQjBPhOB
06Z5bdiVyhJDRpE8ksRCC3kDPS2nsvDAal28sMgyeP8sPvfKvp5sa2UsH78WJmTzeZWcJfX2C2ba3
xwRMB5LaaVrQZlhj9xjum0MfDpIS1hJI6p5CHZ8w&scope=searchadsorg'
-
请求接⼝的字段说明
Header
headers = {'Content-Type': 'application/x-www-form-urlencoded','Host': 'appleid.apple.com'
}
Payload
grant_type:固定值client_credentials;
client_id:前面获取的client_id;
client_secret:前面获取的client_secret;
scope:固定值searchadsorg
-
响应示例
{"access_token":"eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2lkIjpudWxsfQ..lXm332TFi0u2E9YZ.bVVBvsjcavoQbBnQVeDiqEzmUIlaH9zLKY6rl36A_TD8wvgvWxpyBXMQuhs-qWG_dxQ5nfuJEIxOp8bIndfLE_4a3AiYtW0BsppO3vkWxMe0HWnzglkFbKUHU3PaJbLHpimmnLvQr44wUAeNcv1LmUPaSWT4pfaBzv3dMe3PNHJJCLVLfzNlWTmPxViIivQt3xyiQ9laBO6qIQiKs9zX7KE3holGpJ-Wvo39U6ZmGs7uK9BoNBPaFtd_q914mb9ChHAKcQaxF3Gadtu_Z5rYFg.vD0iQuRwHGYVnDy27qexCw","token_type": "Bearer","expires_in": 3600,"scope": "searchadsorg"
}
-
响应字段说明
access_token:返回的 access_token,用于请求苹果广告相关数据接口 https://developer.apple.com/documentation/apple_search_ads/calling_the_apple_search_ads_api
token_type:固定值Bearer
expires_in:token有效期,3600秒即1小时
scope:访问权限
使⽤access_token获取⼴告账户下的⼴告orgid
官方文档:Get User ACL | Apple Developer Documentation
-
请求示例
curl --location --request GET 'https://api.searchads.apple.com/api/v4/acls'
--header 'Authorization: Bearer eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwia2
lkIjpudWxsfQ..FGvVpX2vBYfyC91X.FKDdsLoAZwsfE8lgd5Ts5vSTY-lScKSp2myVIP9Eh7H
wc-7vsXr63aHcvNaDec9p7MwFHYnCC8Zphuf-BoqhAgyEu3hg_oHuqNEhbyS7iZexDfdgScTop
zGD5IP7Kiag71n0bbKb8o68MlJV8P-faGcsZR1-FBuISuyIrP6ZdUwXjoNygNCr4RkOLLNLjng
ShnnFUICJ3Q7dvrMQk0kUI_OFP3OcfZrsbHOIPWgDxA0W6GYI000Ua9x3TPT46kW2RQ67Pk5RS
2Ft_sqPgVh8V-A.7Rs0pIsQR09nRccM8'响应示例:{"orgName": "org name example","orgId": 40669820,"currency": "USD","timeZone": "America/Los_Angeles","paymentModel": "PAYG","roleNames": ["Admin"],"parentOrgId": "27154130","displayName": "display name example"
}
使⽤ access_token 获取⼴告账户投放数据
7.1 请求头参数说明
-
Authorization 设定值时组合⽅式为:Bearer {access_token},即Bearer在前,后⾯加
access_token
值 -
X-AP-Context 的值为orgId={orgId},
orgId=
不能漏掉 -
orgId 即广告系列组ID,上面第6部分接口中获取到的
orgId
7.2 请求示例 - 获取广告系列组下的广告组数据
-
以下仅为1个示例,实际对接时可查看苹果官⽅Management API⽂档,根据业务需要确认请求接⼝
-
下面示例的官方文档参考: Get an Ad Group | Apple Developer Documentation
7.2.1 请求说明
URL
https://api.searchads.apple.com/api/v4/campaigns/{campaignId}/adgroups/{adgroupId}请求方式
GET请求头参数
Authorization
X-AP-ContextURL中参数
campaignId #广告系列ID
adgroupId #广告组ID
7.2.2 响应状态码
200 请求成功
401 无权限,请检查请求参数
403 拒绝请求,请检查请求参数
404 请求资源为找到,请检查请求参数
500 苹果服务器异常,稍后重试
7.2.3 响应示例
{"id": 542370539,"campaignId": 56543219,"name": " ad group name example","cpaGoal": {"amount": "100","currency": "USD"},"startTime": "2021-04-08T12:00:22.788","endTime": "2021-04-09T12:00:22.788","automatedKeywordsOptIn": false,"defaultBidAmount": {"amount": "100","currency": "USD"},"pricingModel": "CPC","targetingDimensions": {"age": {"included": [{"minAge": 20,"maxAge": 25},{"minAge": 25,"maxAge": 55}]},"gender": {"included": ["M","F"]},"country": null,"adminArea": null,"locality": null,"deviceClass": {"included": ["IPAD","IPHONE"]},"daypart": {"userTime": {"included": [1,3,22]}},"appDownloaders": null},"orgId": 40669820,"modificationTime": "2020-04-08T19:00:24.105","status": "ENABLED","servingStatus": "RUNNING","servingStateReasons": null,"displayStatus": "RUNNING","deleted": false
}
示例代码:
import jwt
import datetime as dt
import requests
import json
import time
client_id = 'SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577'
team_id = 'SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577'
key_id = 'bacaebda-e219-41ee-a907-e2c25b24d1b2'
audience = 'https://appleid.apple.com'
alg = 'ES256'
Define issue timestamp.
issued_at_timestamp = int(dt.datetime.utcnow().timestamp())
issued_at_timestamp = int(time.time())
Define expiration timestamp. May not exceed 180 days from issue timestamp.
expiration_timestamp = issued_at_timestamp + 600
Define JWT headers.
headers = dict()
headers['alg'] = alg
headers['kid'] = key_id
Define JWT payload.
payload = dict()
payload['sub'] = client_id
payload['aud'] = audience
payload['iat'] = issued_at_timestamp
payload['exp'] = expiration_timestamp
payload['iss'] = team_id
Path to signed private key.
KEY_FILE = 'private-key.pem'
with open(KEY_FILE,'r') as key_file:
key = ''.join(key_file.readlines())
key = '''-----BEGIN EC PRIVATE KEY-----
MHcCAQEEINEGa2CfhaseOXzsHoya/UW4kgQsij9ZW6j3+GQS2zEwoAoGCCqGSM49
AwEHoUQDQgAENEUUVYnQ+hWQRT3+YEwT3m2VGS5jlO6lanvZgLDHkWfOXhBiUfF8
Cyz/X3bzbkP8pOdJZ901qGdyeW73RV1RmA==
-----END EC PRIVATE KEY-----'''
获取 client_secret
client_secret = jwt.encode(
payload=payload,
headers=headers,
algorithm=alg,
key=key
)
client_secret = client_secret.decode('utf-8')
print('-------------------- client_secret --------------------')
print(client_secret)
获取 access_token
url = 'https://appleid.apple.com/auth/oauth2/token?grant_type=client_credentials&client_id=' + client_id + '&client_secret=' + client_secret + '&scope=searchadsorg'
token = requests.post(url=url)
token_json = json.loads(token.content.decode())
access_token = token_json['access_token']
print('-------------------- access_token --------------------')
print(access_token)
acls
url = "https://api.searchads.apple.com/api/v4/acls"
headers = {'Authorization': 'Bearer %s' % (access_token),'Content-Type': 'application/json'
}
acl = requests.get(url=url, headers=headers)
print('-------------------- acl --------------------')
print(acl.text)
org_id = 123456
campaign_id = 1234567890
headers = {'Authorization': 'Bearer %s' % (access_token),'Content-Type': 'application/json','X-AP-Context': 'orgId=%d' % (org_id),
}
获取 campaigns
url = 'https://api.searchads.apple.com/api/v4/campaigns'
campaigns = requests.get(url=url, headers=headers)
print('-------------------- campaign --------------------')
print(campaigns.text)
获取 campaign report
url = 'https://api.searchads.apple.com/api/v4/reports/campaigns'
data = {# "startTime": str(dt.date.today()),# "endTime": str(dt.date.today()),"startTime": "2022-09-01","endTime": "2022-09-01","selector": {"orderBy": [{"field": "localSpend","sortOrder": "ASCENDING"}],"conditions": [{"field": "campaignId","operator": "EQUALS","values": [campaign_id],"ignoreCase": False},{"field": "deleted","operator": "IN","values": ["false","true"]}],"pagination": {"offset": 0,"limit": 1000}},"returnRowTotals": True,"granularity": "DAILY","timeZone": "ORTZ","returnGrandTotals": True,"returnRecordsWithNoMetrics": True
}
data = json.dumps(data)
reports = requests.post(url=url, data=data, headers=headers)
print('-------------------- campaign reports --------------------')
print(reports.text)
package main
import ("fmt""io/ioutil""net/http""strings""time""github.com/dgrijalva/jwt-go/v4"
)
func main() {client_id := "SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577"team_id := "SEARCHADS.27478e71-3bb0-4588-998c-182e2b405577"key_id := "bacaebda-e219-41ee-a907-e2c25b24d1b2"audience := "https://appleid.apple.com"alg := "ES256"issued_at_timestamp := time.Now().Unix()expiration_timestamp := issued_at_timestamp + 600claim := jwt.MapClaims{"sub": client_id,"aud": audience,"iat": issued_at_timestamp,"exp": expiration_timestamp,"iss": team_id,}private_pem := `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEINEGa2CfhaseOXzsHoya/UW4kgQsij9ZW6j3+GQS2zEwoAoGCCqGSM49
AwEHoUQDQgAENEUUVYnQ+hWQRT3+YEwT3m2VGS5jlO6lanvZgLDHkWfOXhBiUfF8
Cyz/X3bzbkP8pOdJZ901qGdyeW73RV1RmA==
-----END EC PRIVATE KEY-----`private_key, _ := jwt.ParseECPrivateKeyFromPEM([]byte(private_pem))token := jwt.NewWithClaims(jwt.SigningMethodES256, claim)token.Header = map[string]interface{}{"alg": alg,"kid": key_id,}client_secret, _ := token.SignedString(private_key)fmt.Println("-------------------- client_secret --------------------")fmt.Println(client_secret)// 获取 access_tokenurl := "https://appleid.apple.com/auth/oauth2/token"res_token, err := http.Post(url, "application/x-www-form-urlencoded", strings.NewReader("grant_type=client_credentials&client_id="+client_id+"&client_secret="+client_secret+"&scope=searchadsorg"))if err != nil {fmt.Println(err.Error())}defer res_token.Body.Close()access_token, _ := ioutil.ReadAll(res_token.Body)fmt.Println("-------------------- access_token --------------------")fmt.Println(string(access_token))
}
ASAToken.java
- package com.appsa.asa;
import com.alibaba.fastjson.JSONObject;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import org.apache.commons.io.IOUtils;
import java.io.DataOutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.interfaces.ECKey;
import java.util.Date;
import java.util.HashMap;
/**
apple ads java demo
*/
public class ASAToken {private static String client_id = "xxx";private static String team_id = "xxx";private static String key_id = "xxx";private static String aud = "https://appleid.apple.com";private static String alg = "ES256";// 生成私钥// openssl ecparam -genkey -name prime256v1 -noout -out private-key.pem// 使用私钥生成公钥// openssl ec -in private-key.pem -pubout -out public-key.pem// 将PKCS1私钥转换为PKCS8(该格式一般Java调用)// openssl pkcs8 -topk8 -inform pem -in private-key.pem -outform pem -nocrypt -out private-key-new.pemprivate static final String PRIVATE_KEY_FILE_256 = "/Users/xxx/Downloads/private-key-new.pem";public static void main(String[] args) {System.out.println("client_id:" + client_id);System.out.println(" team_id:" + team_id);System.out.println(" key_id:" + key_id);try {String clientSecret = createClientSecret(PRIVATE_KEY_FILE_256);System.out.println("clientSecret 建议保存,有效期可设置最长 180 天");System.out.println(clientSecret);String url = "https://appleid.apple.com/auth/oauth2/token";String urlParameters = "grant_type=client_credentials&scope=searchadsorg&client_id=" + client_id + "&client_secret=" + clientSecret;byte[] postData = urlParameters.getBytes(StandardCharsets.UTF_8);int postDataLength = postData.length;URL obj = new URL(url);HttpURLConnection con = (HttpURLConnection) obj.openConnection();con.setDoOutput(true);con.setRequestMethod("POST");con.setRequestProperty("charset", "utf-8");con.setRequestProperty("Content-Length", Integer.toString(postDataLength));con.setRequestProperty("Host", "appleid.apple.com");con.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");con.setUseCaches(false);try (DataOutputStream wr = new DataOutputStream(con.getOutputStream())) {wr.write(postData);}if (con.getResponseCode() == 200) {String result = IOUtils.toString(con.getInputStream(), StandardCharsets.UTF_8);JSONObject jsonObject = JSONObject.parseObject(result);System.out.println("access_token 有效期1个小时");System.out.println(jsonObject.getString("access_token"));}} catch (Exception e) {System.err.println("error");}}/*** Create a Client Secret** @return client secret*/public static String createClientSecret(String privateKeyPath) throws Exception {Algorithm algorithm = Algorithm.ECDSA256((ECKey) PemUtils.readPrivateKeyFromFile(privateKeyPath, "EC"));return JWT.create().withIssuer(team_id).withAudience(aud).withHeader(new HashMap() {{put("alg", alg);put("kid", key_id);}}).withSubject(client_id).withIssuedAt(new Date()).withExpiresAt(new Date(System.currentTimeMillis() + 86400 * 180 * 1000L)).sign(algorithm);}
}
PemUtils.java
package com.appsa.asa;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.EncodedKeySpec;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
public class PemUtils {private static byte[] parsePEMFile(File pemFile) throws IOException {if (!pemFile.isFile() || !pemFile.exists()) {throw new FileNotFoundException(String.format("The file '%s' doesn't exist.", pemFile.getAbsolutePath()));}PemReader reader = new PemReader(new FileReader(pemFile));PemObject pemObject = reader.readPemObject();byte[] content = pemObject.getContent();reader.close();return content;}private static PublicKey getPublicKey(byte[] keyBytes, String algorithm) {PublicKey publicKey = null;try {KeyFactory kf = KeyFactory.getInstance(algorithm);EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);publicKey = kf.generatePublic(keySpec);} catch (NoSuchAlgorithmException e) {System.out.println("Could not reconstruct the public key, the given algorithm could not be found.");} catch (InvalidKeySpecException e) {System.out.println("Could not reconstruct the public key");}return publicKey;}private static PrivateKey getPrivateKey(byte[] keyBytes, String algorithm) {PrivateKey privateKey = null;try {KeyFactory kf = KeyFactory.getInstance(algorithm);EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);privateKey = kf.generatePrivate(keySpec);} catch (NoSuchAlgorithmException e) {System.out.println("Could not reconstruct the private key, the given algorithm could not be found.");} catch (InvalidKeySpecException e) {System.out.println("Could not reconstruct the private key.");}return privateKey;}public static PublicKey readPublicKeyFromFile(String filepath, String algorithm) throws IOException {byte[] bytes = PemUtils.parsePEMFile(new File(filepath));return PemUtils.getPublicKey(bytes, algorithm);}public static PrivateKey readPrivateKeyFromFile(String filepath, String algorithm) throws IOException {byte[] bytes = PemUtils.parsePEMFile(new File(filepath));return PemUtils.getPrivateKey(bytes, algorithm);}
}
四、注意事项总结
1. 框架须知
-
AdServices.framework 这个框架是用于 ASA 归因的,不受 ATT 约束,就是无论用户是否允许跟踪,都可以归因,区别为是否能获取点击时间,仅支持 14.3 及更高版本系统,需 XCode 12.3 及更高版本支持,设置为 optional。
-
AppTrackingTransparency.framework 在 iOS 14 及更高版本用于征求用户跟踪许可的框架,就是弹窗询问用户是否同意跟踪;在 iOS 14.5 上苹果将强制要求开发者实施,也是获取 IDFA 的前提。
2.token相关注意事项
-
AdServices 获取的 token 不可作为唯一设备标识
-
获取 token 需要设备联网,并且要做超时及容错处理
-
获取token后需等待五秒再发送请求给归因API
-
获取 token 的步骤,可以借助日志系统收集相关信息,用于排查问题和代码优化
-
APP首次激活时获取token,建议获取网络权限后,等待500ms-1000ms再做token请求
-
如果 App 退出前台,在下一次打开进行 token 请求重试
-
对token请求成功率进行监测,建议成功率下降>=5%应触发预警机制,并根据报错调查原因
-
token 有效期为 24 小时
-
当 token 无效时,接口响应码为 400
-
当 token 过期(有效期为 24 小时)时,接口响应码为 404
3.数据归因相关注意事项
-
AdServices 的 restful api 请求,失败后 每隔 5 秒重试,建议最多 3 次 ,也可根据需求增加重试次数,如重试之后仍然没有正常,统计返回的报错日志
-
有数据差合理,如进行反馈需提供 APP Id、发生问题的时间、API返回结果
-
Apple Search Ads归因窗口期为30天,用户30天以前点击的广告我们无法归因
-
Apple Search Ads采用末次归因,用户如果30天或一天内惦记了搜索标签,又搜了关键词,归因返回的是用户最后一次点击广告的信息
-
目前苹果搜索广告归因仅支持Adservice,并且Adservice仅支持14.3及以上的用户,14.3以下的用户不会被归因到,这些用户会被计入自然量
-
14.3以下的用户还可请求API,返回结果为attribution = false
-
目前14.3以下的用户占比非常低,过去4年推出的ipnone 90%使用ios16,81%所有设备使用ios16
4.归因字段缺失原因
-
AdServices 归因在 ATT弹框之前,不返回字段clickDate
-
ATT弹框后,用户不允许跟踪,不返回字段clickDate
-
AdServices 归因建议在 ATT 弹窗之后,用户如允许跟踪,则可以获得 clickDate
-
开启 Search Match(搜索匹配)的广告组带来的激活,不返回字段 keywordId
-
默认素材带来的激活,不返回字段 adId
5.客户端处理逻辑参考
-
由于各种原因导致的获取归因包失败时,需要做容错处理,及时进行重试(必需)
-
重试多次仍然失败的,应用在下次启动时再进行获取(必需)
-
当归因包返回的 attribution 为 false,7 天后再请求归因(建议)
-
iOS 14.3 以下,设置项为限制跟踪时,7 天后再次请求归因(建议)
-
当归因包返回的 attribution 为 true 时,30 天后再请求归因(建议)
五、参考文档
Apple Ads 归因 API
Apple Ads 归因 API 文档 (PDF)
ad_services
Apple Search Ads