PAYATHON 2026

iOS AutoRenewable Subscriptions 续费 Webhook 通知

支付老李

结论

会。自动续期订阅续费成功、有效期延长后,App Store 会向已配置的 App Store Server Notifications 地址发送通知。

不同版本对应的通知类型如下:

  • App Store Server Notifications V1:notification_type 为 RENEWAL
  • App Store Server Notifications V2:notificationType 为 DID_RENEW

V1 的 RENEWAL 表示订阅已成功续期,但不能简单地把它当作支付网关的“扣款成功 Webhook”。业务系统仍需校验通知内容,再根据新的到期时间和交易信息更新订阅状态。

为什么不能只判断 RENEWAL

App Store Server Notification 通知的是订阅状态变化,并不是客户端订单回调。通知可能延迟或重复发送,其送达顺序也不一定与业务发生顺序完全一致。

收到续费通知后,需要检查:

  • product_id 是否为预期的订阅商品
  • original_transaction_id 对应哪个用户或订阅
  • 新交易的 transaction_id 是否已经处理
  • expires_date_ms 或 V2 中的 expiresDate 是否晚于当前记录
  • 通知是否通过 Apple 的校验机制
  • 当前环境是 Sandbox 还是 Production

判断订阅是否已经延长,应以验证后的交易数据中的到期时间为准,不能只看通知类型。

推荐处理流程

1. 配置服务端通知地址

在 App Store Connect 中为应用配置 App Store Server Notifications URL。生产环境和 Sandbox 测试环境应按实际接入方式分别处理,以免测试交易进入生产订阅数据。

2. 接收并验证通知

V1 通知通常包含 latest_receipt_info 等字段。V2 请求体包含 signedPayload,这是一个 JWS。使用其中的数据前,需要验证签名、证书链和载荷信息。

新接入通常可以优先使用 V2,其通知结构和签名验证方式更明确。

3. 幂等处理续费交易

不能只根据 original_transaction_id 判断交易是否已经处理。自动续期产生的多笔交易会共用一个 original_transaction_id,但每次续费都有新的 transaction_id。

建议按以下方式处理:

  1. 用 transaction_id 作为交易幂等键。
  2. 用 original_transaction_id 关联整个订阅生命周期。
  3. 仅当新到期时间晚于数据库中的到期时间时,才更新订阅有效期。
  4. 收到重复通知时直接返回成功,避免 Apple 持续重试。

4. 必要时主动查询订阅状态

如果通知字段缺失、状态冲突,或者业务要求更强的一致性,可以通过 App Store Server API 查询交易历史或订阅状态,不必完全依赖单次通知。

V1 RENEWAL 处理示例

下面的示例只展示业务判断结构。生产环境还需要加入请求鉴权、收据验证、日志脱敏和数据库事务。

app.post("/apple/subscriptions/notifications", async (req, res) => {
  const notification = req.body;

  if (notification.notification_type !== "RENEWAL") {
    res.sendStatus(200);
    return;
  }

  const transaction = notification.latest_receipt_info;

  if (!transaction) {
    res.sendStatus(200);
    return;
  }

  const transactionId = transaction.transaction_id;
  const originalTransactionId = transaction.original_transaction_id;
  const expiresDateMs = Number(transaction.expires_date_ms);

  if (
    !transactionId ||
    !originalTransactionId ||
    !Number.isFinite(expiresDateMs)
  ) {
    res.sendStatus(200);
    return;
  }

  // 此处应先通过 Apple 提供的方式验证收据或交易数据。
  const alreadyProcessed = await hasProcessedTransaction(transactionId);

  if (!alreadyProcessed) {
    await updateSubscriptionInTransaction({
      transactionId,
      originalTransactionId,
      expiresAt: new Date(expiresDateMs),
      productId: transaction.product_id
    });
  }

  res.sendStatus(200);
});

V2 DID_RENEW 处理示例

V2 请求体不能直接作为可信 JSON 使用,必须先验证 signedPayload。解码后的通知还包含 signedTransactionInfo 和 signedRenewalInfo,这两个字段也需要验证。

app.post("/apple/subscriptions/notifications/v2", async (req, res) => {
  const { signedPayload } = req.body;

  if (!signedPayload) {
    res.sendStatus(400);
    return;
  }

  // verifyAndDecodeJWS 必须完成签名和证书链验证,
  // 不能只做 Base64 解码。
  const notification = await verifyAndDecodeJWS(signedPayload);

  if (notification.notificationType !== "DID_RENEW") {
    res.sendStatus(200);
    return;
  }

  const signedTransactionInfo =
    notification.data?.signedTransactionInfo;

  if (!signedTransactionInfo) {
    res.sendStatus(200);
    return;
  }

  const transaction = await verifyAndDecodeJWS(
    signedTransactionInfo
  );

  const {
    transactionId,
    originalTransactionId,
    productId,
    expiresDate
  } = transaction;

  const alreadyProcessed = await hasProcessedTransaction(
    transactionId
  );

  if (!alreadyProcessed) {
    await updateSubscriptionInTransaction({
      transactionId,
      originalTransactionId,
      productId,
      expiresAt: new Date(expiresDate)
    });
  }

  res.sendStatus(200);
});

注意事项

  • RENEWAL 是 V1 的名称,V2 对应 DID_RENEW,不要混用字段名。
  • 续费成功与进入账单重试不是一回事。续费失败、宽限期、账单重试和最终过期会触发其他类型的通知。
  • 用户关闭自动续期后,当前订阅不会立即失效。权益应保留至经过验证的到期时间。
  • Sandbox 的续期周期明显短于生产环境,不能用 Sandbox 的时间间隔推断生产环境中的行为。
  • 服务端需要接受重复通知,并确保同一个 transaction_id 只入账一次。
  • 不要根据通知到达时间计算新的有效期,应使用 Apple 返回且验证通过的 expires_date_ms 或 expiresDate。
  • Webhook 是状态同步入口。用户打开应用时,仍建议结合当前交易或服务端订阅状态进行校准。

备注:内容仅供参考。