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。
建议按以下方式处理:
- 用
transaction_id作为交易幂等键。 - 用
original_transaction_id关联整个订阅生命周期。 - 仅当新到期时间晚于数据库中的到期时间时,才更新订阅有效期。
- 收到重复通知时直接返回成功,避免 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 是状态同步入口。用户打开应用时,仍建议结合当前交易或服务端订阅状态进行校准。
备注:内容仅供参考。
