代码 iOS 内购发货与 App Store Server API 排查:JWS 验签、通知 V2 与常见漏检

2026-09-16 09:01:17

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。

旧流程大概是这样:

  1. 客户端拿到 App 收据(base64)。
  2. 服务端 POST 到 https://buy.itunes.apple.com/verifyReceipt,沙盒是 https://sandbox.itunes.apple.com/verifyReceipt
  3. 返回 JSON 收据,里面有 in_applatest_receipt_infopending_renewal_info

旧办法的麻烦很具体:收据体积大、包含全 App 所有交易、拿不到增量;而且如果沙盒收据打到了生产端点,会返回 21007,服务端得先请求生产,再退回沙盒重试。这个重试分支本身就是事故来源。

新流程把签名和校验都放到服务端本地:服务端持有 App Store Connect 的 In-App Purchase 私钥(.p8),自签 JWT 调 App Store Server API;交易、订阅、通知全部以 JWS(JSON Web Signature,RFC 7515)返回。服务端本地就能解码与验签,不必每次回源 Apple。

参考:

新版调用链:JWT 鉴权加三个端点

建 In-App Purchase Key

App Store Connect → Users and Access → Integrations → In-App Purchase。创建 Key,下载 .p8,记下 keyIdissuerId 同页可查。密钥不要提交进代码库;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} 返回里会有 signedTransactionInfosignedRenewalInfo。查单兜底时用 transactionId 主动查 /inApps/v1/transactions/{transactionId} 补单。另有 Request a Test Notification 端点,可以让 Apple 主动发一条 V2 测试通知,接入期很省事。

JWS 验签四步与信任锚陷阱

signedPayload 是三段 base64url 用点号分隔:header.payload.signature

  • header base64url 解码后:alg 必须是 ES256x5c 是 X.509 证书链,顺序 leaf → 中间证书 → 根。
  • payload base64url 解码后就是交易 / 通知的 JSON。

验签要做的事:

  1. 信任锚只能用本地预置的 Apple 根证书。从 Apple PKI 根证书 下载,按 SHA-256 指纹固定。绝不能把 x5c 里自带的根证书当作信任锚,否则对方自带一条自签链就能过。
  2. 校链:leaf 由中间证书签发,中间证书由受信根签发;leaf 必须带 Apple 收据签名扩展 OID 1.2.840.113635.100.6.11.1;中间证书应带 WWDR 扩展 OID 1.2.840.113635.100.6.2.1x5c 链长度通常为 3,Apple 官方 Node 库直接校验 chain.length != 3 会报 INVALID_CHAIN_LENGTH
  3. 校时间与吊销:证书会过期也会被吊销,不要硬编码或长期缓存证书。验签要用证书有效期内的有效时间;官方库用 payload 里的 signedDatereceiptCreationDate,开了在线校验(OCSP)则用当前时间。
  4. 用 leaf 的公钥按 ES256 验 payload.signature

Apple 官方开源库已经实现了上述全部步骤,包括证书链校验与 OCSP:

库里的入口是 SignedDataVerifier(rootCertificates, bundleId, appAppleId, environment, enableOnlineChecks),方法有 verifyAndDecodeNotification(signedPayload)verifyAndDecodeTransactionverifyAndDecodeRenewalInfo

注意一个分支:官方库对 environment = XCODELOCAL_TESTING 会直接跳过验签,因为这类数据不由 App Store 签名。生产环境绝不能走这个分支。

失败表现:验签失败应直接拒绝,不要继续解码取字段,更不要落库发货。通知场景下交给 Apple 重投。

通知 V2 分层解码:外层验完还要验内层

请求体长这样:

{ "signedPayload": "eyJ..." }

解码层级:

  1. 验签解码 signedPayloadresponseBodyV2DecodedPayload。外层含 notificationTypesubtypenotificationUUIDdata / summary
  2. data 里还有 signedTransactionInfo(JWSTransaction),部分通知类型才有 signedRenewalInfo(JWSRenewalInfo)。这两个各自又是一段 JWS,要再验一次签。只验最外层 signedPayload 就取内部字段,等于没验。
  3. 常用字段:transactionIdoriginalTransactionIdappAccountTokenproductIdpurchaseDateexpiresDatetypeinAppOwnershipTypePURCHASED / FAMILY_SHARED)、revocationDaterevocationReasonofferTypeofferIdentifierenvironmentbundleIdappAppleId
  4. 字段格式变化:旧收据里的字符串数字 / 布尔,在新 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} 补单。

常见坑与自查顺序

按这个顺序排查,基本能覆盖大多数发货异常:

  1. 401 / 403:先看 JWT 的 aud 是否 appstoreconnect-v1bid 是否 bundleId、kid / iss / iat 是否正确,密钥是否过期。
  2. 还在用旧端点思维处理 21007:新 API 下生产与沙盒域名已经分开,不要再写「生产失败退沙盒」的重试。
  3. 验签失败:检查信任锚是不是本地根证书、链长是否为 3、OID 是否匹配、环境是否误走 XCODE / LOCAL_TESTING 的跳过分支。
  4. 只验了外层 signedPayload:内层 signedTransactionInfo / signedRenewalInfo 没验,等于没验。
  5. expiresDate 自己算订阅是否有效:应看 /inApps/v1/subscriptionsstatus=1
  6. 只处理购买成功,不处理 REFUND / REVOKE
  7. 重投导致重复发货:唯一键没用 transactionId
  8. appAccountToken 没传,用户和交易对不上。
  9. 沙盒交易当生产订单发货:environment 没判。
  10. 通知处理太慢:应快速回 200,再异步处理,并留查单兜底。

链接汇总:

推荐文章

程序员茄子在线接单