iOS 内购发货与 App Store Server API 排查笔记:JWS 验签、通知 V2 与常见漏检
适用范围与前提
这套 API 只覆盖 iOS App Store 内购 / 自动续订订阅。公众号、微信小程序的虚拟支付是另一套(wx.requestVirtualPayment),不要混进来。
前置条件:
- App Store Connect 里需要 Admin 角色,才能创建 In-App Purchase Key。
- 服务端能拿到
.p8私钥,并安全保存,不进代码库。 - 本地预置 Apple 根证书,用来做 JWS 信任锚。
下面涉及的错误码与具体库版本行为,以 Apple 当前文档和官方库源码为准。接入前应在沙盒用测试通知实际跑一遍,未实测部分不要直接照搬到生产。
verifyReceipt 为什么该换
WWDC23 已宣布 verifyReceipt 与 App Store Server Notifications V1 停用,不再接收功能更新。替代方案是 App Store Server API + Notifications V2。
旧流程大概是这样:
- 客户端拿到 App 收据(base64)。
- 服务端 POST 到
https://buy.itunes.apple.com/verifyReceipt,沙盒是https://sandbox.itunes.apple.com/verifyReceipt。 - 返回 JSON 收据,里面有
in_app、latest_receipt_info、pending_renewal_info。
旧办法的麻烦很具体:收据体积大、包含全 App 所有交易、拿不到增量;而且如果沙盒收据打到了生产端点,会返回 21007,服务端得先请求生产,再退回沙盒重试。这个重试分支本身就是事故来源。
新流程把签名和校验都放到服务端本地:服务端持有 App Store Connect 的 In-App Purchase 私钥(.p8),自签 JWT 调 App Store Server API;交易、订阅、通知全部以 JWS(JSON Web Signature,RFC 7515)返回。服务端本地就能解码与验签,不必每次回源 Apple。
参考:
- App Store Server API 文档
- App Store Server Notifications
- 通过 App Store 验证收据(verifyReceipt 弃用说明)
- WWDC23 新增功能
- WWDC25 深入探索 App Store Server API
新版调用链:JWT 鉴权加三个端点
建 In-App Purchase Key
App Store Connect → Users and Access → Integrations → In-App Purchase。创建 Key,下载 .p8,记下 keyId;issuerId 同页可查。密钥不要提交进代码库;JWT 有有效期,需要定期重签。
JWT claim
用 ES256 签 JWT,claim 至少包含:
{
"kid": "",
"iss": "",
"iat": 1710000000,
"aud": "appstoreconnect-v1",
"bid": "com.example.app"
}
请求头里带 Authorization: Bearer 。
三个端点
生产域名:https://api.storekit.itunes.apple.com
沙盒域名:https://api.storekit-sandbox.itunes.apple.com
# 生产:查单笔交易,返回 signedTransactionInfo(JWS)
curl -i -H "Authorization: Bearer $JWT" \
https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transactionId}
# 交易历史
curl -i -H "Authorization: Bearer $JWT" \
https://api.storekit.itunes.apple.com/inApps/v1/history/{transactionId}
# 订阅状态:status 字段,1 = 订阅有效、应授予权益
curl -i -H "Authorization: Bearer $JWT" \
https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}
/inApps/v1/subscriptions/{originalTransactionId} 返回里会有 signedTransactionInfo、signedRenewalInfo。查单兜底时用 transactionId 主动查 /inApps/v1/transactions/{transactionId} 补单。另有 Request a Test Notification 端点,可以让 Apple 主动发一条 V2 测试通知,接入期很省事。
JWS 验签四步与信任锚陷阱
signedPayload 是三段 base64url 用点号分隔:header.payload.signature。
- header base64url 解码后:
alg必须是ES256;x5c是 X.509 证书链,顺序 leaf → 中间证书 → 根。 - payload base64url 解码后就是交易 / 通知的 JSON。
验签要做的事:
- 信任锚只能用本地预置的 Apple 根证书。从 Apple PKI 根证书 下载,按 SHA-256 指纹固定。绝不能把
x5c里自带的根证书当作信任锚,否则对方自带一条自签链就能过。 - 校链:leaf 由中间证书签发,中间证书由受信根签发;leaf 必须带 Apple 收据签名扩展 OID
1.2.840.113635.100.6.11.1;中间证书应带 WWDR 扩展 OID1.2.840.113635.100.6.2.1。x5c链长度通常为 3,Apple 官方 Node 库直接校验chain.length != 3会报INVALID_CHAIN_LENGTH。 - 校时间与吊销:证书会过期也会被吊销,不要硬编码或长期缓存证书。验签要用证书有效期内的有效时间;官方库用 payload 里的
signedDate或receiptCreationDate,开了在线校验(OCSP)则用当前时间。 - 用 leaf 的公钥按 ES256 验
payload.signature。
Apple 官方开源库已经实现了上述全部步骤,包括证书链校验与 OCSP:
- Node.js:apple/app-store-server-library-node
- Java:apple/app-store-server-library-java
- Python:apple/app-store-server-library-python
- Swift:apple/app-store-server-library-swift
库里的入口是 SignedDataVerifier(rootCertificates, bundleId, appAppleId, environment, enableOnlineChecks),方法有 verifyAndDecodeNotification(signedPayload)、verifyAndDecodeTransaction、verifyAndDecodeRenewalInfo。
注意一个分支:官方库对 environment = XCODE 或 LOCAL_TESTING 会直接跳过验签,因为这类数据不由 App Store 签名。生产环境绝不能走这个分支。
失败表现:验签失败应直接拒绝,不要继续解码取字段,更不要落库发货。通知场景下交给 Apple 重投。
通知 V2 分层解码:外层验完还要验内层
请求体长这样:
{ "signedPayload": "eyJ..." }
解码层级:
- 验签解码
signedPayload→responseBodyV2DecodedPayload。外层含notificationType、subtype、notificationUUID、data/summary。 data里还有signedTransactionInfo(JWSTransaction),部分通知类型才有signedRenewalInfo(JWSRenewalInfo)。这两个各自又是一段 JWS,要再验一次签。只验最外层signedPayload就取内部字段,等于没验。- 常用字段:
transactionId、originalTransactionId、appAccountToken、productId、purchaseDate、expiresDate、type、inAppOwnershipType(PURCHASED/FAMILY_SHARED)、revocationDate、revocationReason、offerType、offerIdentifier、environment、bundleId、appAppleId。 - 字段格式变化:旧收据里的字符串数字 / 布尔,在新 JWS 里是数字与布尔;日期统一为毫秒时间戳;
cancellation_date/cancellation_reason更名为revocationDate/revocationReason。
上线必核清单
bundleId / appAppleId
必须核对 bundleId,生产环境请求还要核对 appAppleId,确认这条交易属于你自己的 App。官方文档明确:验签之外仍需确认 App 与产品标识符,才能给正确的 App / 服务授权。
订阅 status
权限判断只看订阅状态。GET /inApps/v1/subscriptions 返回 status=1 才代表有效、应授予;不要拿 expiresDate 自己算时区。
退款与撤销
通知类型 REFUND,以及 REVOKE 类,要撤销权益。靠 revocationDate / revocationReason 判断,不能只处理购买成功。
幂等
同一条通知 Apple 会重投;续订时 originalTransactionId 不变、transactionId 变。用 transactionId 做唯一键,重复投递直接返回 200,不重复发货。
appAccountToken
客户端购买时传入(UUID 格式),Apple 会在之后每条交易里回传,用来把交易绑到你的用户 ID。忘传这个字段,服务端只能靠客户端上报的用户 ID,容易被伪造关联。
环境区分
生产域名与沙盒域名分开;测试通知走的是沙盒环境。通知里的 environment 字段要能区分,别把沙盒交易当生产订单发货。
网络与重试
Apple 通知是 at-least-once,服务端快速回 200 后异步处理;处理失败要能被查单兜底,用 transactionId 主动查 /inApps/v1/transactions/{transactionId} 补单。
常见坑与自查顺序
按这个顺序排查,基本能覆盖大多数发货异常:
- 401 / 403:先看 JWT 的
aud是否appstoreconnect-v1、bid是否 bundleId、kid/iss/iat是否正确,密钥是否过期。 - 还在用旧端点思维处理
21007:新 API 下生产与沙盒域名已经分开,不要再写「生产失败退沙盒」的重试。 - 验签失败:检查信任锚是不是本地根证书、链长是否为 3、OID 是否匹配、环境是否误走
XCODE/LOCAL_TESTING的跳过分支。 - 只验了外层
signedPayload:内层signedTransactionInfo/signedRenewalInfo没验,等于没验。 - 用
expiresDate自己算订阅是否有效:应看/inApps/v1/subscriptions的status=1。 - 只处理购买成功,不处理
REFUND/REVOKE。 - 重投导致重复发货:唯一键没用
transactionId。 appAccountToken没传,用户和交易对不上。- 沙盒交易当生产订单发货:
environment没判。 - 通知处理太慢:应快速回 200,再异步处理,并留查单兜底。
链接汇总: