iOS内购订阅系统最让人头疼的问题之一,就是如何在服务器端准确掌握用户完整的订阅历史。客户端的restoreCompletedTransactions只能拉到当前Apple ID下的部分交易,App Store的沙盒环境与生产环境数据隔离,退款、家庭共享、跨设备登录等场景更会让客户端数据与真实账目产生偏差。要构建可靠的订阅体系,必须把数据源放到服务器侧,苹果为此提供了两套核心能力:App Store Server API的查询接口,以及App Store Server Notifications V2的实时推送。两者的数据都通过JWS(JSON Web Signature)格式签名传输,需要用JWT相关的算法完成验签,才能确认数据真实可信。

一、为什么客户端方案无法还原完整订阅历史
很多团队最初的方案是在客户端完成交易后,把transaction.transactionIdentifier或receipt上传给自己的服务器,再由服务器调用苹果的verifyReceipt接口校验。这个方案在StoreKit 2时代已经不推荐使用,苹果官方明确将其标记为legacy。verifyReceipt返回的是原始收据的解析结果,格式不稳定,且无法表达订阅的完整生命周期事件,比如升级、降级、宽限期、账单重试、退款等状态变化。
更关键的是,客户端上报存在天然的不可靠性。用户完成购买后立刻杀掉应用、断网、卸载重装,都会导致交易数据没有上报到服务器。用户跨设备恢复购买时,另一台设备产生的交易你未必能感知。而退款事件只在苹果侧发生,客户端没有任何回调可以捕获。因此,完整订阅历史必须以苹果服务器为唯一可信数据源,通过服务器到服务器的通道获取。
StoreKit 2在客户端提供了Transaction.all和Transaction.currentEntitlements等API,可以遍历当前Apple ID的历史交易,这对客户端体验没问题,但把它当作账务数据源是不安全的。正确的架构是:客户端只负责完成交易与展示权益,服务器通过通知与查询接口维护全量账本。
二、App Store Server Notifications V2的工作机制与配置
Notifications V2是苹果推送给你的服务器的HTTPS回调。当用户的订阅状态发生变化时,苹果会向你在App Store Connect中配置的URL发送一个POST请求,body是一个JSON,其中核心字段是signedPayload,内容是一个JWS格式的签名字符串,解码后包含notificationUUID、subtype、data.signedTransactionInfo、data.signedRenewalInfo等关键信息。
配置入口在App Store Connect的应用详情页,找到App信息中的App Store服务器通知部分,分别填写生产环境和沙盒环境的URL。苹果要求URL必须是HTTPS且使用443端口之外需显式声明,同时服务器需要能通过苹果的连通性测试。建议为每个通知做幂等处理:以notificationUUID为唯一键去重,因为苹果在收到200响应之前会按指数退避策略重试,最多重试5次,持续约半天时间。如果通知始终无法送达,苹果还提供了Get Notification History接口,可以按时间段补拉历史通知。
V2的通知类型(notificationType)覆盖了完整的订阅生命周期:SUBSCRIBED表示新订阅,DID_RENEW表示续订成功,DID_FAIL_TO_RENEW表示扣款失败进入宽限期,EXPIRED表示彻底过期,REFUND表示退款,DID_CHANGE_RENEWAL_STATUS表示用户取消或恢复了自动续订,GRACE_PERIOD_EXPIRED表示宽限期结束。每种类型配合subtype可以精确区分场景,例如SUBSCRIBED加RESUBSCRIBE表示老用户重新订阅。服务器应根据这些事件驱动订阅状态机,而不是每次都全量拉取。
三、用App Store Server API查询完整交易历史
除了被动接收通知,服务器还可以主动查询。App Store Server API提供了几个关键接口:Get Transaction History按时间正序返回某个原始交易ID下的所有交易记录,支持分页;Get All Subscription Statuses返回某个用户所有订阅的当前状态;Get Transaction Info查询单笔交易详情。这些接口弥补了通知丢失的场景,适合做每日对账任务。
调用这些接口需要先生成一个签名JWT作为Bearer Token。JWT的header中alg为ES256,kid填你在App Store Connect中创建的In-App Purchase密钥ID;payload包含iss(你的团队ID)、iat、exp、aud固定为appstoreconnect、bid为Bundle ID。签名私钥是以.p8文件下载的椭圆曲线私钥。以下用Python生成token的示例:
import time
import jwt # PyJWT库
def generate_apple_token(key_path, key_id, team_id, bundle_id):
with open(key_path, 'r') as f:
private_key = f.read()
headers = {"alg": "ES256", "kid": key_id}
payload = {
"iss": team_id,
"iat": int(time.time()),
"exp": int(time.time()) + 1200, # 最长不超过20分钟
"aud": "appstoreconnect",
"bid": bundle_id
}
return jwt.encode(payload, private_key, algorithm="ES256", headers=headers)
查询交易历史时,首次请求使用原始交易ID(originalTransactionId),响应中的hasMore为true时,取revision参数继续分页拉取。每条记录同样是JWS签名的signedTransactionInfo,需要解码验证后落库。注意沙盒与生产的域名不同,沙盒用api.storekit-sandbox.itunes.apple.com,生产用api.storekit.itunes.apple.com,两者数据完全隔离,测试时要确认连的是正确的环境。
四、JWT验证:确保通知与交易数据可信
无论是通知中的signedPayload还是查询结果中的交易信息,本质上都是JWS,格式为header.payload.signature三段Base64URL编码字符串。验签分两层:第一层验证外层JWS的签名,使用苹果根证书签发的X5c证书链,证书链最顶端的根证书需要与苹果官方发布的Apple Root CA G3比对;第二层解出payload后,如果其中还有嵌套的signedTransactionInfo,需要再做一次JWS验证,两层的证书链不同,不能混用。
验签的核心是从JWS header的x5c字段提取证书链,逐级验证证书签发关系,确认叶证书的公钥能解开JWS的signature,同时校验叶证书中的O字段为Apple Inc.且OU字段为App Store。以下是一个简化但可用的Java验权思路:
// 使用java-jwt与BouncyCastle的简化示例
public DecodedJWT verifyJws(String signedPayload) throws Exception {
DecodedJWT decoded = JWT.decode(signedPayload);
List<String> x5c = decoded.getHeaderClaim("x5c").asList(String.class);
// 1. 将x5c证书链逐个解析并验证签发关系
// 2. 校验根证书指纹是否为Apple Root CA G3
// 3. 校验叶证书的Subject: O=Apple Inc., OU=App Store, CN=Apple SubCA
// 4. 用叶证书公钥构造ECPublicKey验签
ECPublicKey publicKey = extractLeafPublicKey(x5c);
Algorithm algorithm = Algorithm.ECMonitor(); // 实际使用 Algorithm.ECDSA256(null, publicKey)
return JWT.require(algorithm).build().verify(signedPayload);
}
更省事的做法是使用苹果开源的App Store Server Library,它提供了Swift、Java、Python、Node.js和Go版本,封装了完整的证书链校验、证书吊销检查(CRL)以及payload解码,一行代码即可完成验证,比如Python版本的SignedDataVerifier。自研验签时最容易忽略的坑有三个:一是没有校验证书链的吊销状态,苹果会定期轮换中间证书;二是沙盒和生产通知的environment字段不同,验签时要把环境作为参数传入,否则会校验失败;三是不验证bundleId,理论上一个精心构造的跨应用通知可能被重放到你的接口,务必确认payload中的bundleId与你自己的应用一致。
五、落地架构与常见坑点总结
完整的订阅系统建议按事件溯源的方式设计:收到通知或查询到新交易后,先把原始JWS字符串原样存档,再解码出结构化数据写入事件表,订阅的当前状态由事件表推导或用一个状态表逐步更新。这样即使解码逻辑有bug,也可以从存档的原始数据重新回放,不会丢账。补单任务则通过Get Transaction History定期对账,比对事件表与苹果侧的差异。
几个高频踩坑点值得再次强调。第一,通知接口返回非200状态码会触发苹果重试,如果你的业务处理逻辑较慢,应该先入库返回200,再异步处理。第二,宽限期内的订阅在summary层面仍可能显示有效,需要结合gracePeriodExpiresDate字段判断,避免给已经停止付费的用户长期提供服务。第三,originalTransactionId是贯穿整个订阅生命周期的锚点,用户ID与订阅的绑定关系应建立在它之上,而不是会变化的transactionId。第四,测试时善用App Store Server API中的Extend Subscription Renewal Date接口,可以在沙盒中延长续期时间,方便模拟各种时间节点。
把客户端交易、服务器通知、主动查询三条链路整合起来,以JWS验签作为信任基础,以originalTransactionId作为关联键,你的订阅历史记录查询体系就能做到不漏账、不错账、可回溯,这也是所有订阅制iOS应用后端的标准形态。
iOS内购App Store Server Notifications V2JWT验证修改时间:2026-09-02 13:42:56