编程 App Store Server Notifications V2 对接笔记:沙箱只投一次,生产重试 5 次,漏的怎么补

2026-11-13 21:07:43

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 / RESUBSCRIBE
  • DID_RENEW:BILLING_RECOVERY
  • DID_CHANGE_RENEWAL_PREF:UPGRADE 立即生效并按比例退差,DOWNGRADE 下期生效
  • DID_FAIL_TO_RENEW:带 GRACE_PERIOD 表示有宽限期、可继续服务;无 subtype 表示没有宽限期
  • EXPIRED:VOLUNTARY / BILLING_RETRY / PRICE_INCREASE / PRODUCT_NOT_FOR_SALE
  • PRICE_INCREASE:PENDING / ACCEPTED
  • RENEWAL_EXTENSION:SUMMARY / FAILURE
  • METADATA_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 天)去捞。

复制全文 生成海报 支付 苹果订阅 对账 接口对接

推荐文章

程序员茄子在线接单