所有文章
Deep dive阅读需 7 分钟

有一个接口能返回某位客户在 App Store 的全部退款记录,本文说清楚它到底交还了什么

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

俯拍的书桌场景,桌上有一部智能手机、一本纸质交易账簿和一个放大镜,示意如何从 Apple 的 Get Refund History 接口拉取某位客户在 App Store 的退款记录

要点

  • 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被退款的产品,让你只撤销正确的权益,别的一概不动
revocationDateApple 退款该交易的 UNIX 时间,单位为毫秒
revocationReasonApple 退款的原因。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 按需给你一个账户的权威列表,这正是你在客服台或宕机之后想要的。

一个放大镜悬停在纸质交易账簿中一行被高亮的记录上,旁边放着一部智能手机,示意在 App Store 退款记录中查询某位客户的退款

一笔漏掉的退款要花你多少钱

接口是管道。账单才是你铺这根管子的理由。那份列表里的每一笔退款都是已经退回的钱,而唯一还在你掌控里的变量,就是你还要为一个不再付费的账户继续支出多久。

你在继续付费,去服务一个已退款的账户

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 退过款的东西都不会还开着。

来源和延伸阅读

RefundHalt

App Store 和 Google Play 退款自动驾驶

继续阅读

下一项退款请求已经在路上。

读完又一封关于未能申辩退款的支持邮件所需的时间,足够您完成 RefundHalt 设置。