所有文章
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。