有一个接口能返回某位客户在 App Store 的全部退款记录,本文说清楚它到底交还了什么
Apple 的 Get Refund History 接口会以签名交易的形式,返回某位客户在 App Store 的完整退款记录。本文讲清每一个字段、revision 令牌如何翻页、为什么它是按客户而非按 App 维度,以及你漏掉一笔退款要付出的代价。

要点
- Get Refund History 是 App Store Server API 的一个接口,它会把某位客户在你 App 内已退款的应用内购买,以签名交易列表的形式返回。这样即便通知从未送达,你也能对账退款并撤销访问权限。
- 你用该客户的任意一笔交易 id 调用 GET /inApps/v2/refund/lookup/{transactionId},Apple 会返回该客户在你 App 内所有购买类型的退款,而不仅是你查询的那一笔。
- 响应包含三个字段:signedTransactions,每页最多 20 笔 JWS 交易,按退款时间从旧到新排序;外加一个 revision 令牌和一个用于翻页的 hasMore 布尔值。
- 保存最后一个 revision 令牌。下次把它传回去,Apple 只返回比该时间点更新的退款,这样每次运行就把整段历史拉取,变成一小份新增记录。
- 每笔解码后的交易都带有 revocationDate 和 revocationReason。revocationReason 为 1 表示客户因你 App 中一个实际或感知到的问题而退款,为 0 则表示其他原因,例如误购。
- 该接口是按客户维度,而非按 App 维度。没有哪个调用能列出你整个 App 的所有退款,所以你要从一笔交易 id 开始按账户对账,或读取你的 REFUND 通知流来获得全 App 视图。
- 接入它的理由是钱。一笔你从未捕捉到的退款会让账户继续存活,而你还在为一个 App Store 早已补偿过的客户,持续支付算力、模型 API 调用、存储和分成。
对于你的 App,Apple 会为每位客户账户上已批准的每一笔退款保留一份可查询的记录,一次调用即可返回。这个接口就是 Get Refund History,属于 App Store Server API,它会以签名交易列表的形式,交还该客户在 App Store 的完整退款记录。你传入一笔交易 id,就能拿回 Apple 退了哪些款,再拿它与你仍在开启的权限做对账。
为什么值得费这个劲。一笔你从未看见的退款,就是一笔你还在为之付费的退款。钱已经没了,但账户还活着,只要它活着,你每小时都在继续支出算力、模型 API 调用、存储,以及任何与该客户绑定的分成。你的退款通知本该在退款发生的那一刻就捕捉到它。Get Refund History 则是通知失灵时的兜底,在一次宕机之后、一次弄丢了 webhook 的部署之后,或者在一桩需要用一次调用看清全貌的客服工单里。
App Store 退款记录接口会返回什么
你对 App Store Server API 调用 GET /inApps/v2/refund/lookup/{transactionId},用你调用其他所有接口时相同的 JWT 签名。路径中的交易 id 可以是该客户的任意一笔交易。Apple 把它当作身份标识,而不是过滤条件,会返回该客户在你整个 App 内已退款的购买:消耗型、非消耗型、自动续期与非续期订阅一并在内。此接口更旧的 V1 版本在单次响应中最多返回 50 笔退款,现已弃用。当前版本会翻页,因此面对历史很长的客户,你也无需处理一个巨大的负载。
响应就是三个字段
| Field | 含义 |
|---|---|
| signedTransactions | 该客户最多 20 笔已退款交易,每笔都是你需验证并解码的签名 JWS。按 revocationDate 从最早退款起排序。空数组表示该客户在你 App 内没有退款 |
| revision | 分页令牌。传回去以获取下一页,并保留最后一个,以便下次只拉取新增退款 |
| hasMore | 当 Apple 持有的已退款交易多于本页返回的数量时为 True,此时你用 revision 再次调用 |
一笔已退款交易能告诉你什么
signedTransactions 中的每一项都是一个 JWS。用 Apple 的证书链验证它、解码它,你就得到一个普通的交易负载,其中填好了退款字段。下面这些是这里真正要紧的。
| Field | 含义 |
|---|---|
| transactionId | 已退款交易的 id,你回连到已记录购买的关联键 |
| originalTransactionId | 交易链中第一笔购买的 id,你把一个订阅的多次续期串起来的依据 |
| productId | 被退款的产品,让你只撤销正确的权益,别的一概不动 |
| revocationDate | Apple 退款该交易的 UNIX 时间,单位为毫秒 |
| revocationReason | Apple 退款的原因。1 表示你 App 中一个实际或感知到的问题,0 表示其他原因,例如误购 |
| price, currency | 金额(单位为 milliunits)及其 ISO 4217 货币代码,便于你合计退回的金额 |
| appAccountToken | 你在购买时附加的 UUID,把退款映射回你自己用户最干净的办法 |
revision 令牌就是你不必反复重读整份列表的办法
使用这个接口最笨的方法,是每次都查一位客户并把每一页都走一遍。这能用,但对一位有五十笔退款的客户来说,那就是五十行你早已知道的记录,外加新增的那一行。revision 令牌的存在就是为了消除这种浪费。每个响应都带一个 revision。当 hasMore 为 true 时,你把它传回去以获取下一页。当你走到末尾时,就保留你看到的最后一个 revision。
这个接口不会做什么
在你据此动手之前,有一个预期要先放下。Get Refund History 是按客户维度,而非按 App 维度。你无法向它索要你 App 上周收到的每一笔退款。它只回答一个问题,即这个账户有哪些退款,而且你必须带着该账户的一笔交易 id 才能发问。开发者们不断撞上这堵墙,然后去找一个根本不存在的全 App 退款接口。
全 App 视图在别处。你的 App Store Server Notifications 流会在 Apple 批准每一笔退款的那一刻发出一条 REFUND 通知,而 Get Notification History 让你在一个日期范围内、按退款类型过滤地重放那条流。所以分工很清晰。通知及其历史给你全 App 的实时流。Get Refund History 按需给你一个账户的权威列表,这正是你在客服台或宕机之后想要的。

一笔漏掉的退款要花你多少钱
接口是管道。账单才是你铺这根管子的理由。那份列表里的每一笔退款都是已经退回的钱,而唯一还在你掌控里的变量,就是你还要为一个不再付费的账户继续支出多久。
你在继续付费,去服务一个已退款的账户
Apple 批准退款的那一刻,购买款项就没了。还在运转的是交付成本。对于一个真正为每位用户干活的 App,那就是算力、模型 API 调用、存储,以及任何与其用量绑定的创作者或合作方分成。一个你从未切断访问的已退款客户,就是一份你自掏腰包资助的订阅。以 Get Refund History 做对账,并根据你查到的结果撤销权限,就是在通知漏过去时把这个计费表关掉的办法。
退款原因为 1 是一份乔装的缺陷报告
如果你忽视 revocationReason,它会让你付两次代价。第一次代价是退款本身。第二次是同一原因带来的每一笔未来退款。当一款产品不断带着 revocationReason 1 回来,也就是你 App 中一个实际或感知到的问题,Apple 就是在把一份带标签的样本递给你,告诉你到底是什么让客户来要回他们的钱。按产品做趋势分析,你就能把这个漏洞补上,而不是一笔一笔地把它赔出去。
晚一点抓到,也强过压根没抓到
拒付在银行那边是终局,而且在另一家商店,如今还带着一笔由开发者承担的手续费。App Store 退款不是那样。它已经结清,但权益归你,你一旦知道就能撤销。所以即便是一笔你通过这个接口晚几天才发现的退款,也值得去发现。你要不回那笔钱,但你能止住它背后仍在跑着的支出。
这如何与通知、以及与 Google 契合
把这些部件当作一个系统来看。REFUND 通知是实时信号,随着 Apple 的裁定推送到你的服务器。Get Refund History 是针对单个客户、基于拉取的事实来源,是当推送失败、或当有人需要把完整账户摆在面前时你会发起的调用。在 Google Play 那边,形态是同一个思路、换了名字:一个 VoidedPurchaseNotification 实时推送,而 Voided Purchases API 是你拉取的那份列表。两家商店都给你一条流和一本账。错误在于只信那条流,因为流会丢。
用 RefundHalt 的方式把它接起来
一旦每个部件都就位,这个循环就很小。以一条 REFUND 通知作为触发。用 Get Refund History 做对账,好让一个丢掉的 webhook 永远不会留下一个还活着的已退款账户。解码每一笔交易,用 appAccountToken 或 transactionId 把它回连到你的用户,读 revocationReason,好让一笔缺陷退款被标记出来而不只是被归档,并撤销确切的那份权益,而不是整个账户。用 revision 令牌翻页,这样你读到的是新增退款,而不是旧的。
这一部分就是 RefundHalt 替你运行的。它监听退款通知,在需要权威列表时回落到 Get Refund History,验证每一笔签名交易,撤销那笔精确的购买,并保存 revision,让每一次遍历都只读发生了变化的部分。你能在数秒内切断访问,并得到一份干净的记录,说清谁被退了款、退的是什么、原因为何,而无需自己搭起轮询和 JWS 验证。
常见问题解答
- 我怎么才能看到整个 App 的每一笔退款,而不只是某一位客户的?
- 用 Get Refund History 做不到,因为它是按客户维度,且需要你要查询账户的一笔交易 id。要拿全 App 视图,请用你的 App Store Server Notifications 流,它会在 Apple 批准每一笔退款时发出一条 REFUND 通知;再用 Get Notification History,在一个日期范围内按退款类型过滤地重放那条流。
- Get Refund History 接口一次返回多少笔退款?
- 当前版本每页最多返回 20 笔已退款交易,按最早退款在前排序,并在 hasMore 为 true 时用 revision 令牌翻页读取其余部分。已弃用的 V1 接口在单次响应中最多返回 50 笔。总数没有上限,所以历史很长的客户只是会跨更多页而已。
- revision 令牌是做什么用的?
- 它既是你翻页的办法,也是你避免每次都重读某位客户整段历史的办法。每个响应都包含一个 revision。你把它传回去以获取下一页,并保存最后一个,好让你下次查询只返回比该时间点更新的退款。这就把一次定时对账限制在一小份新增记录之内。
- 已退款交易里的 revocationReason 是什么意思?
- 它是 Apple 退款该交易的原因。值为 1 表示客户是因你 App 内一个实际或感知到的问题而退款,0 则表示其他原因,例如误购。revocationDate 告诉你退款发生的时间,以 UNIX 毫秒为单位。读 revocationReason 能让你把产品缺陷与一次性的后悔退款区分开。
- 如果我已经处理 REFUND 通知,还需要它吗?
- 需要,作为兜底。通知是实时信号,但一次推送可能在宕机、一次糟糕的部署或一次 webhook 变更期间未能送达,而一笔漏掉的退款会留下一个还活着、还在花你钱的已退款账户。Get Refund History 是你据以对账、基于拉取的事实来源,好让任何被 Apple 退过款的东西都不会还开着。
来源和延伸阅读
- Apple Developer: Get Refund History (App Store Server API)
- Apple Developer: RefundHistoryResponse
- Apple Developer: JWSTransactionDecodedPayload (revocationDate, revocationReason)
- Apple Developer: Get Refund History V1 (deprecated)
- Apple Developer: App Store Server Notifications V2 notificationType (REFUND)
- Apple Developer: Support customers and handle refunds (WWDC21)
RefundHalt
App Store 和 Google Play 退款自动驾驶
继续阅读
你的 App 可以直接弹出应用内退款申请表,客户点击提交后 Apple 会这样处理
Apple 的应用内退款申请让客户无需离开你的 App 就能申请退款,表单由 Apple 构建并审核。本文讲清 beginRefundRequest 返回什么、它会在你的服务器上启动哪些 CONSUMPTION_REQUEST 和 48 小时计时,以及这个按钮是否值得上线。
当 Google Play 的一笔购买被退款或发生拒付时,Voided Purchases API 就是你得知此事的途径
当一笔购买被退款或拒付时,Google Play 会悄悄地将其作废。Voided Purchases API 就是这些订单的清单,好让你可以撤销访问权限。这里讲清每个字段、30 天的时间窗口、会隐藏订单的撤销选项,以及它的成本。