Apple 和 Google 都可能把同一笔退款向你的服务器投递不止一次,如果你对每一次都执行操作,重复的退款通知就会让你付出代价
Apple 会把一条退款通知重试最多五次,而 Google Play 依托 Pub/Sub 的至少一次投递,因此同一笔退款可能不止一次到达你的服务器。以下讲解如何处理重复的退款通知,同时不会扣错余额或重复消耗 API 配额。

要点
- 只要你的服务器没有以 200 到 206 之间的 HTTP 状态作答,Apple 就会把一条 App Store Server Notification V2 重试五次,分别在上一次尝试之后的 1、12、24、48 和 72 小时。把第一次尝试算在内,一笔退款最多可到达六次。
- 每一次真正的 Apple 重试都携带相同的 notificationUUID,所以该字段而非交易 id 才是你的去重键。
- Google Play 的 Real-time Developer Notifications 依托 Cloud Pub/Sub,而它保证至少一次投递且不保证顺序,因此同一条消息可能到达两次或乱序到达。Google 告诉你,在处理任何东西之前先检查 messageId 的唯一性。
- Apple 周期性的 CONSUMPTION_REQUEST 通知不是重试。Apple 会在开放的退款窗口内持续发送全新的通知,每一条都带有不同的 notificationUUID,因此按 notificationUUID 去重会正确地保留其中的每一条。
- 同一个 Apple transactionId 可以携带不止一个决定,例如先是一条 REFUND_DECLINED,随后又是一条 REFUND,所以仅按交易 id 去重会丢掉一个你需要的独立事件。
- 通过返回 4xx 或 5xx 来拒绝重复只会让商店再次重试。请在你自己的数据库内部去重,并且始终返回成功状态。
- 一个不具幂等性的退款处理器会在第二次投递时重复执行操作。它会把余额扣两次、把付款反转两次,或者为一笔它已经关闭的退款重新核对,从而消耗计费的 Play Developer 和 App Store Server API 配额。
你的服务器会不止一次收到同一个退款事件,而且两家商店都是有意这样设计的。当你的端点没有干净地作答时,Apple 会把一条 App Store Server Notification 重试最多五次。Google Play 通过 Cloud Pub/Sub 投递它的 Real-time Developer Notifications,而它承诺至少一次投递,对顺序则不作任何承诺。所以问题从来不是重复是否会到达。问题是当你的代码第二次看到同一笔退款时它会做什么。这一点做错了,你就会把余额扣两次、把付款反转两次,或者为一笔你已经关闭的退款重新核对而消耗计费的 API 配额。以下讲解重复的退款通知实际上是如何到达你的,哪些重复是真正的重复而哪些只是看起来像,以及如何处理它们,好让第二次投递不花任何代价。
一条退款通知是至少投递一次,这和恰好一次并不相同
两家商店都把一条已投递的通知当作它们会不断尝试兑现的承诺,而不是发出即忘的一次性动作。这对可靠性是好事,因为你在部署期间错过的一条通知稍后仍会到达你。这对正确性却是陷阱,因为保证你最终能拿到事件的那套机制,也保证了你有时会拿到它两次。你的处理器必须具备幂等性,也就是说同一笔退款的第二次和第三次投递不会改变任何第一次尚未改变的东西。
Apple 会在三天内重试五次
当 Apple 发送一条 App Store Server Notification V2 时,它期望你的服务器以 200 到 206 范围内的 HTTP 状态作答。其他任何东西,无论是 4xx 还是 5xx,都告诉 Apple 投递失败,于是 Apple 就会重试。这个时间表是固定的:五次重试,分别在上一次尝试之后的 1、12、24、48 和 72 小时。把第一次尝试算在内,一个退款事件最多可到达六次,分布在大约一周里。这些重试中的每一次都携带相同的 notificationUUID。该字段就是你的去重键。如果你已经记录过某个 notificationUUID,你手上这次投递就是一次重复,正确的响应是不存任何新东西,并且仍然返回 200。
Google Play 依托 Pub/Sub,它承诺至少一次且对顺序不作任何说明
Google Play 的 Real-time Developer Notifications 被发布到一个 Cloud Pub/Sub 主题。Pub/Sub 的投递保证是至少一次,而且它根本不作任何顺序保证。这意味着同一条消息可能被向你的端点投递不止一次,而针对同一笔购买的两条消息也可能乱序到达。Google 自己的指引说得很明确:解包 base64 的 data 字段,读取 messageId,并在处理任何东西之前检查你此前没有见过它。重复的 messageId 是你要跳过的一次重复。关于同一笔购买的两条不同通知仍应落在同一条记录上,所以也要把你存储的状态键在 purchaseToken 上,并让较晚的事件更新较早的事件所创建的那一行。
| 平台 | 投递模型 | 去重依据 | 成功信号 | 若你不确认 |
|---|---|---|---|---|
| App Store Server Notifications V2 | 最多 6 次尝试:第一次,加上在 1、12、24、48、72 小时的 5 次重试 | notificationUUID | HTTP 200 到 206 | Apple 按固定时间表重试,然后停止 |
| Google Play RTDN 通过 Pub/Sub | 至少一次,不保证顺序 | Pub/Sub messageId,实体键在 purchaseToken 上 | 对 push 返回 HTTP 200,或一次显式的 ack | ack 截止时间过后 Pub/Sub 会重发 |
那些并非重复的重复
并非每一条看起来像你见过的通知都是一次重试。Apple 的两种行为会发送真正全新的事件,它们共享一笔购买但必须各自被处理,用一套天真的去重把它们折叠掉会丢掉你需要的信息。
Apple 发送的是全新的 CONSUMPTION_REQUEST,而非重试
在一个消耗型商品处于开放退款请求期间,Apple 不会只发一条 CONSUMPTION_REQUEST 然后等待。它会在整个开放退款窗口内周期性地发送新的,直到退款被关闭。Apple 员工已经确认这些不是重试,而线索就在你用来去重的那个字段上:每一条全新的 CONSUMPTION_REQUEST 都携带不同的 notificationUUID。所以一套键在 notificationUUID 上的去重会自动做对的事。它折叠掉真正的重试,并保留每一条独立的提示。你绝不能做的,是按交易 id 和通知类型去重,因为那会让第一条之后的每一条 CONSUMPTION_REQUEST 都被压掉,让你在被丢掉的那些上损失 12 小时的证据窗口。
一笔交易可以携带不止一个决定
单个 transactionId 在其生命周期内可以产生不止一个退款结果。Apple 可以发送一条 REFUND_DECLINED,随后再为同一笔交易发送一条 REFUND,而且有开发者反映为一个交易 id 收到三条或更多与退款相关的通知。每一条都是带有自身 notificationUUID 的独立事件。如果你的去重键是交易 id,第二个决定看起来就像第一个的重复,你就永远不会得知退款最终被批准了。交易 id 是把事件分组。它并不标识它们。

一次重复实际上会让你付出什么
一条退款通知不是一盏状态灯。它会触发真实的操作:你撤销访问权限,你扣除消耗型余额,你反转一笔创作者付款,你调用 App Store Server API 或 Play Developer API 去确认状态。对一次重复再执行其中任何一项,代价都是真实的。
把钱的流向走一遍。撤销访问权限两次是无害的,因为访问权限已经没有了。扣两次余额则不然:一个买了一包金币又退款的用户可能被扣到负余额,然后你的支持团队不得不手工去纠正。反转两次付款会把你已经退还过一次的钱又追回来,现在你欠一位创作者一句道歉和一次更正。而你对某个商店 API 重新处理的每一次重复,都会花掉 Google 明确警告你要保护的配额,所以一场故障期间的一阵 Pub/Sub 重发,可能在你最承受不起的那一天把你推入限流。
退款证据窗口在 Apple 一侧把赌注抬高了。如果一套天真的去重把 Apple 在开放退款窗口内发送的那些重复的 CONSUMPTION_REQUEST 压掉了,你就可能错过你本需要作答的那一条,而一条你没有在 12 小时内作答的 CONSUMPTION_REQUEST,往往会被 Apple 默认批准退款。那不是一次重复扣款。那是一笔丢失的销售,外加你在交付这笔购买时已经花掉的算力、API 调用、存储和付款,而退款没有把这些中的任何一样还给你。
| 失败模式 | 出了什么问题 | 它让你付出什么 |
|---|---|---|
| 对一次重复的 REFUND 再扣一次余额 | 用户的消耗型余额变成负数 | 用于对账的人工支持时间,以及糟糕的客户体验 |
| 把付款反转两次 | 你把已经退还过一次的钱又追回来 | 一次创作者更正和一次账务清理 |
| 对某个商店 API 重新处理 | 重复的调用消耗 Play Developer 或 App Store Server API 配额 | 在引发重发的那场故障期间遭遇限流 |
| 对 CONSUMPTION_REQUEST 过度去重 | 你把一条独立的退款提示当作假重复丢掉 | 错过一个 12 小时窗口,于是 Apple 默认批准退款 |
如何在不重复执行操作的前提下处理重复的退款通知
两家商店的模式是相同的,只是键不同。记录这次投递,在执行操作之前检查键,只执行一次,并且始终告诉商店你收到了。
- 按商店的投递 id 去重,而非交易。Apple 用
notificationUUID,Google Play 用 Pub/Sub 的messageId。把它带着唯一约束存储,这样并发的重复就会在竞态中落败,而不会重复执行操作。 - 让下游操作在它自身的意义上具备幂等性。键在投递 id 上能阻止重新处理,但也要把效果写成:撤销、扣除或反转在执行前先检查当前状态,并且可以安全地运行两次。
- 先持久化,再确认。在你返回 200 或 ack 那条 Pub/Sub 消息之前,先把事件写入你的数据库。如果你先 ack 而写入失败了,商店会认为这条消息已投递并且永远不会再发送,现在你就把它彻底弄丢了。
- 始终返回成功状态,即使是对一次重复。对 Apple 返回 200 到 206,对 Google 对那条 Pub/Sub push 返回 200。用错误拒绝一次重复只会让商店再次重试它。
- 在实体上分组,在事件上标识。把你存储的购买状态键在
purchaseToken或originalTransactionId上,好让乱序的投递更新同一行,但把每个notificationUUID或messageId当作它自己的事件,因为一笔购买合理地会产生若干个。
在你信任你的退款 webhook 之前的一份简短清单
- Apple 的投递按
notificationUUID去重,一次重复不写任何新东西但仍返回 200。 - Google Play 的投递按 Pub/Sub 的
messageId去重,在任何处理之前进行检查。 - 购买状态键在
purchaseToken或originalTransactionId上,好让乱序的事件落在同一条记录上。 - 每一个退款副作用,撤销、扣除或反转,都可以安全地运行不止一次。
- 你的处理器在确认之前写入事件,而绝不在之后。
- 重复的 CONSUMPTION_REQUEST 被当作独立的提示,而非重复,这样就没有任何开放退款窗口被丢掉。
有意地把一个重复穿过你自己的 webhook,看着它在第二次什么都不改变。这就是全部的测试。一个可以安全地被打两次的退款处理器,就是你在商店决定打它六次的那一刻起可以不再操心的那种。
常见问题解答
- 为什么我的服务器会不止一次收到同一条 App Store 退款通知?
- 因为只要你的服务器没有以 200 到 206 之间的 HTTP 状态响应,Apple 就会把一条 App Store Server Notification V2 重试最多五次,分别在上一次尝试之后的 1、12、24、48 和 72 小时。每一次重试都携带相同的 notificationUUID,所以你可以识别并跳过它。
- 我应该用哪个字段来对 App Store Server Notifications 去重?
- 用 notificationUUID。一次真正的重试总是重复相同的 notificationUUID,而每一个真正全新的事件,包括每一条全新的 CONSUMPTION_REQUEST,都会拿到不同的一个,所以按 notificationUUID 去重能跳过重复而不丢掉独立的事件。
- 重复的 CONSUMPTION_REQUEST 通知是我应该忽略的重复吗?
- 不是。Apple 会在开放退款窗口内周期性地发送新的 CONSUMPTION_REQUEST 通知,而 Apple 员工确认这些不是重试。每一条都有自己的 notificationUUID,所以要处理每一条。丢掉它们有可能让你错过 Apple 给你作答的 12 小时窗口。
- 我该如何对 Google Play 的 Real-time Developer Notifications 去重?
- 从每条通知中读取 Pub/Sub 的 messageId,并在执行操作之前把它和你已经处理过的那些对照检查,因为 Pub/Sub 至少投递一次,可能把同一条消息发送不止一次。Google 明确推荐这样做,以避免重复处理和浪费 API 配额。
- 我应该返回一个错误来拒绝一条重复的退款通知吗?
- 不应该。返回 4xx 或 5xx 会告诉商店投递失败,于是它照样重试。请在你自己的数据库内部去重,并且始终返回成功状态,对 Apple 用 HTTP 200 到 206,对 Google Play 的 push 用一次 200 确认。
来源和延伸阅读
- Apple Developer: App Store Server Notifications V2
- Apple Developer: Responding to App Store Server Notifications
- Apple Developer: notificationUUID
- Apple Developer Forums: App Store Server Notifications V2 retry schedule and CONSUMPTION_REQUEST clarification
- Android Developers: Real-time developer notifications reference
- Android Developers: Purchase lifecycle and RTDNs
- Google Cloud: Pub/Sub subscriber and at-least-once delivery
RefundHalt
App Store 和 Google Play 退款自动驾驶
继续阅读
Family Sharing 退款只会撤销一笔付款,但可能让另外五个人继续使用你的应用,只有你的服务器才能切断他们的访问权限
一次 Family Sharing 退款只撤销一笔付款,但可能让多达五名家庭成员仍在使用你的付费功能。Apple 会发送一个 REVOKE,并期望你的服务器终止访问权限。下面讲讲家庭共享退款如何运作,以及一次退款的代价。
退款处理常常悄无声息地出错,所以要先在 sandbox 中测试应用内购买退款,别等真实客户先碰上
你的退款处理只有在客户已经离开之后才会运行,所以其中的缺陷会一直隐藏,直到它真的让你损失金钱。两大商店都允许你先在测试环境中触发一次退款。这里说明如何在退款成真之前,先在 App Store 和 Google Play 上测试应用内购买退款。