App Store Server Notifications V2 对接笔记:沙箱只投一次,生产重试 5 次,漏的怎么补
本文依据 revenuehog.dev《App Store Server Notifications V2: Every Type + Payload》(2026-08-11 对照 Apple 文档)、Apple WWDC23《App Store 服务器 API 的新增功能》以及 Apple 关于退款通知处理的文档整理,重点记录沙箱与正式环境在投递能力上的差异,以及漏掉通知后的对账方式。
协议:每个事件一个签名 JWS
V2 是当前协议。每个事件对应一个 signedPayload,采用 JWS compact serialization,header 声明 alg: ES256 并携带 x5c 证书链。payload 结构:
notificationType
notificationUUID
version: "2.0"
signedDate
data {
appAppleId
bundleId
environment
signedTransactionInfo
signedRenewalInfo
}
V1 与 verifyReceipt 均已弃用。
端点硬性要求
- 公网 HTTPS,浏览器可信证书:不能自签,不能使用内网主机名
- TLS 1.2 及以上
- 端口 443 或 >= 1024
- 若配置了 IP 白名单,需放行
17.0.0.0/8(沙箱与生产同网段) - 返回 200–206 视为成功;4xx/5xx 视为需要重试;其他状态码一律算投递失败
appAppleId:正式必填,沙箱必须不传
appAppleId 在 Production 环境必填,在 Sandbox 环境必须不传,否则该环境下每条通知都验不过。构造 verifier 时要按 payload 里的 environment 选择对应配置。
环境只能从未验证解码的 payload 里读,这一步是安全的:verifier 会独立校验环境是否匹配。常见的对接事故是这套配置写反或写死——结果是「一个环境全过,另一个环境全挂」,而且失败发生在验签层,日志里往往看不出业务痕迹。
重试:沙箱一次,生产五次
| 环境 | 投递策略 |
|---|---|
| Sandbox | 每条通知只尝试一次,完全不重试 |
| Production | 失败后按 1、12、24、48、72 小时重试,共 5 次 |
沙箱不重试这一点对本地联调影响很大:端点只要抖一下、返回一个 500,这条通知就永远看不到了,不能靠「等它再发一次」来验证。
漏了怎么补:Get Notification History
生产环境重试 5 次之后仍未送达的,可以用 Get Notification History 回放最近 180 天的通知;沙箱窗口是 30 天。
WWDC23 起该接口新增请求字段 onlyFailures,只返回没有成功到达服务器的通知,其中包含仍在重试中的。响应里 sendAttempts 最多 6 条,即 1 次初始投递 + 最多 5 次重试。如果后续某次重试成功,这条通知就不再出现在 onlyFailures 的结果里。
对账做法:拿一个时间窗口调 Get Notification History,把返回的 notificationUUID 与本地已落库的记录比对,差集就是需要补处理的。不要按订单号或交易 ID 去猜,UUID 才是投递维度上的唯一标识。
幂等只看 notificationUUID
去重键只有一个:notificationUUID。重试机制加上手动回放,同一条通知被处理多次是常态,处理逻辑必须幂等。
业务口径上的几个坑
DID_CHANGE_RENEWAL_STATUS的AUTO_RENEW_DISABLED只表示用户关闭了续订意图,不等于流失;真正流失看EXPIRED。- 免费试用转付费不会单独发一种「转化」事件,它就是一条普通的
DID_RENEW。试用期本身在SUBSCRIBED的INITIAL_BUY/RESUBSCRIBE里,看signedTransactionInfo的offerDiscountType=FREE_TRIAL。
23 种 notificationType:
SUBSCRIBED ONE_TIME_CHARGE DID_RENEW
DID_CHANGE_RENEWAL_STATUS DID_CHANGE_RENEWAL_PREF
DID_FAIL_TO_RENEW EXPIRED GRACE_PERIOD_EXPIRED
OFFER_REDEEMED REFUND REFUND_DECLINED
REFUND_REVERSED REVOKE PRICE_INCREASE
RENEWAL_EXTENDED RENEWAL_EXTENSION CONSUMPTION_REQUEST
EXTERNAL_PURCHASE_TOKEN RESCIND_CONSENT
METADATA_UPDATE MIGRATION PRICE_CHANGE
TEST
几个关键 subtype:
SUBSCRIBED:INITIAL_BUY/RESUBSCRIBEDID_RENEW:BILLING_RECOVERYDID_CHANGE_RENEWAL_PREF:UPGRADE立即生效并按比例退差,DOWNGRADE下期生效DID_FAIL_TO_RENEW:带GRACE_PERIOD表示有宽限期、可继续服务;无 subtype 表示没有宽限期EXPIRED:VOLUNTARY/BILLING_RETRY/PRICE_INCREASE/PRODUCT_NOT_FOR_SALEPRICE_INCREASE:PENDING/ACCEPTEDRENEWAL_EXTENSION:SUMMARY/FAILUREMETADATA_UPDATE/MIGRATION/PRICE_CHANGE属于 Advanced Commerce
载荷形态并不统一:多数事件带 data,但 RESCIND_CONSENT 带的是 appData,RENEWAL_EXTENSION(SUMMARY) 带 summary,EXTERNAL_PURCHASE_TOKEN 带 externalPurchaseToken。如果代码里无条件按 payload.data.bundleId 取值,遇到这几种会直接抛异常。未知类型应记日志并返回 200,不要因为解析失败让 Apple 反复重投。
Go 结构:先落库,再返回 200
type Notification struct {
NotificationType string `json:"notificationType"`
Subtype string `json:"subtype"`
NotificationUUID string `json:"notificationUUID"`
Version string `json:"version"`
SignedDate int64 `json:"signedDate"`
Data *struct {
AppAppleID int64 `json:"appAppleId"`
BundleID string `json:"bundleId"`
Environment string `json:"environment"`
SignedTransactionInfo string `json:"signedTransactionInfo"`
SignedRenewalInfo string `json:"signedRenewalInfo"`
} `json:"data"`
}
func handleNotification(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read failed", http.StatusBadRequest)
return
}
// 1. 未验证解码,只取 environment 用来选 verifier
// sandbox: appAppleId 不传;production: 必须传
raw := decodeUnverified(body)
v := newVerifier(raw.Data.Environment)
// 2. 完整校验:x5c 证书链、ES256 签名、bundleId、
// environment 匹配、signedDate 与证书有效期、本机时钟
n, err := v.Verify(body)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// 3. 落库,notificationUUID 建唯一索引;重复则视为已处理
if err := store.InsertIfAbsent(ctx, n.NotificationUUID, body); err != nil {
// 落库失败返回 5xx,让 Apple 重试
http.Error(w, "store failed", http.StatusInternalServerError)
return
}
// 4. 业务逻辑异步处理,不在请求内做重活
go process(n)
w.WriteHeader(http.StatusOK)
}
第 3 步落库失败要返回 5xx,不能吞掉——这正是生产环境 5 次重试存在的意义。
排查清单
- 响应慢或返回非 2xx:Apple 侧会记为投递失败
appAppleId配错,或按错误环境去验证- TLS 问题:自签、证书过期、重定向、端点要求额外鉴权
- 本机时钟偏移,导致证书有效期与
signedDate校验失败 - 忘记做幂等,重试与回放造成重复扣费、重复发货
- 同一个 app 只能配置一个生产 URL,多下游需要自己转发
TEST通知即便 URL 配的是 V1,也会以 V2 格式发送
测试建议
沙箱订阅按加速时钟续期,「一个月」实际只有几分钟。开一个沙箱账号走完订阅,一小时内就能看完 SUBSCRIBED → DID_RENEW → EXPIRED 整条链路。因为沙箱不重试,测试时要把端点日志打到最详细,一次没收到就得靠回放窗口(30 天)去捞。