iOS 订阅掉单:App Store 服务器通知 V2 的验签、两套环境和 EXPIRED 误判
做过微信、支付宝回调的团队,到了 iOS 内购这一步,常把服务器通知当成一个配置项来处理,结果集中出三类故障:
- 通知根本没到:日志里什么都没有,代码里也就没有任何分支被触发。
- 通知到了,但验签失败被自家代码丢掉:日志里只有一行
invalid signature。 - 验签通过,但权益被错误地收回或发放:用户投诉、退款。
下面按接入顺序记录这套通知的处理要点。
一、前提:IAP 服务端验证要准备什么
- App Store Connect → 用户和访问 → 集成 → In-App Purchase 密钥:下载
.p8(只能下载一次),记下 Key ID 和 Issuer ID。 - bundle id(JWT 的
bid)。 - App Store Server Notifications 只有两个 URL 输入框:Production Server URL、Sandbox Server URL。这里没有 secret、没有签名密钥,通知的真伪靠 JWS 证书链自证,所以并不存在「配密钥」这一步,也就没有「密钥错了」的报错。URL 填错、填成内网地址、证书链校验写错,表现都一样:静默无通知。App Store Connect 里没有「发送测试通知」按钮(据我所知没有;以官方文档为准)。
二、两套环境在三处,不在一个开关里
- 通知 URL 分 sandbox 与 production 两套,各填各的。
- 沙盒购买(TestFlight、App Review 审核、Sandbox 测试账号)只往 sandbox URL 发;生产购买往 production URL 发。开发期只填 production,测试时自然什么都收不到。
- 例外:上线早期有用户在生产环境拿到 sandbox 收据(老沙盒账号、Apple 审核号的购买)。所以解析 JWS 后的
data.environment字段(Sandbox/Production)要入库存着,不要写死。 - 主动查询的域名也分两套:生产
https://api.storekit.itunes.apple.com,沙盒https://api.storekit-sandbox.itunes.apple.com。JWT 结构相同,路径也相同:GET /inApps/v1/subscriptions/{transactionId 或 originalTransactionId}。
三、JWS 验签:x5c 三条证书 + ES256 raw 签名
请求体只有一个字段:{"signedPayload": ". ."}(base64url)。
- header 形如
{"alg":"ES256","x5c":["...","...","..."]}。x5c是 base64 编码的 DER 证书链,三张:叶子(JWS 签名证书)、中间(Apple Application Integration CA - G3 / Apple IST CA 等)、根(Apple Root CA - G3)。根证书从 Apple 证书授权 下载,公钥要在代码里固化,不能依赖系统信任库——用系统库意味着任何人都能生成一张链到公共 CA 的证书,把伪造的通知发进来。 - 校验证书链:leaf → intermediate → 固定的 Apple Root CA - G3,检查有效期、Basic Constraints(
CA:TRUE只在中间和根)、Extended Key Usage。 - 校验叶子证书的 OU/CN 是否属于 App Store Server 签发(Apple 文档给出了预期 OU 值,以文档为准)。
- 用叶子证书公钥验 signature。
这里有个坑:JWS ES256 的签名是固定 64 字节的 raw R||S(base64url 解出来正好 64 字节),而 openssl dgst -verify 要的是 DER 编码的 ECDSA-Sig。直接喂 raw 会一直报 verification failure。要么用库(jose/jwt 都已处理),要么自己把 R||S 包成 DER:
import base64, sys
sig = base64.urlsafe_b64decode(s + '==')
r, s2 = sig[:32], sig[32:]
from Crypto.Util.asn1 import DerSequence
der = DerSequence([int.from_bytes(r, 'big'), int.from_bytes(s2, 'big')]).encode()
把 x5c 三张证书写成 der 文件后,可以手动复现:
openssl x509 -inform der -in leaf.der -pubkey -noout > leaf.pub
# 拼接 signing input: header.payload(含点,ASCII)
printf '%s' "$INPUT" > msg.txt
openssl dgst -sha256 -verify leaf.pub -signature sig.der msg.txt
signedDate只做记录,不要用它拒绝「旧」通知:Apple 会重试、也可能乱序,判断以通知内容里的expiresDate为准。
四、notificationType 与 subtype:不要单靠它改权益
SUBSCRIBED:首订或重新订阅(含从宽限期恢复)。DID_RENEW:自动续订成功,延长权益。DID_FAIL_TO_RENEW:续订失败。subtype为GRACE_PERIOD表示进入宽限期,用户仍有权益,不能停。GRACE_PERIOD_EXPIRED:宽限期结束,此时才应降级。EXPIRED:过期,subtype区分VOLUNTARY(用户关续订到期)/BILLING_RETRY(重试耗尽)/PRICE_INCREASE(未同意涨价)/PRODUCT_NOT_FOR_SALE。REFUND:退款,带revocationDate,权益按此时间点回滚。REVOKE:撤销(家庭共享成员被移除等)。DID_CHANGE_RENEWAL_STATUS:只表示自动续订开关(subtype为AUTO_RENEW_DISABLED/AUTO_RENEW_ENABLED),关掉不等于到期,权益必须给到expiresDate。
结论:权益状态 = f(expiresDate, revocationDate, 宽限期),不是 f(notificationType)。通知只当「提示去核对」。主键用 originalTransactionId(同一订阅组的续期链共享它),transactionId 每期不同。
五、幂等与乱序
同一事件会重复投递;用 notificationUUID 做唯一键(transactionId 不够,因为一笔交易会有多种事件)。
乱序方面:DID_RENEW 可能早于 EXPIRED 到达,用 signedDate / expiresDate 做 last-write-wins,别按到达顺序回写状态。
应答:处理完返回 200(2xx)即可,重活放到队列里做;非 2xx Apple 会重试(次数与间隔以官方文档为准,不要假设「3 次」)。
六、兜底:主动查询 + 对账
GET /inApps/v1/subscriptions/{originalTransactionId},返回 data[].lastTransactions[].status:
| status | 含义 |
|---|---|
| 1 | 有效 |
| 2 | 过期 |
| 3 | 账单重试 |
| 4 | 宽限期 |
| 5 | 已撤销 |
JWT 认证:alg=ES256,iss=Issuer ID,kid=Key ID,bid=bundle id,aud=appstoreconnect-v1,exp 建议 5–20 分钟,token 不能复用太久。
每天用定时任务扫 expiresDate 在未来 48 小时内、且近 24 小时没有收到任何通知的订阅,去 API 核一次。这类「通知静默」在 URL 配错、自家 WAF 拦 POST、反代 413/502 时最常见。
七、和微信/支付宝体系的差异
- 微信 v3:4 个验签头 + 5 秒应答,配错 APIv3 密钥会直接报错,有
WECHATPAY/SIGNTEST探测流量可判断链路通不通。 - 支付宝:应答字符串
success(7 字符),证书模式要算app_cert_sn。 - Stripe:webhook 有签名密钥、事件可重放、Dashboard 能看投递日志。
- IAP:没有密钥、没有投递日志、没有测试按钮。
所以日志必须自己打全:原始 body(脱敏)、x5c 叶子证书序列号、notificationUUID、notificationType / subtype、appAccountToken、解析结果。
八、未实测声明
路径与字段名依据 Apple 官方文档(App Store Server Notifications / App Store Server API);openssl 复现步骤在本机 macOS + OpenSSL 3 上跑通,其余环节未实测。Apple 会随时间调整字段和重试策略,上线前请以官方文档为准。