StoreKit 2 退款偵測歸結於交易上的一個屬性,也就是 revocationDate
當 Apple 為你的某位客戶退款時,在你的伺服器工作執行之前,這筆退款其實已經透過交易的 revocationDate 出現在你的 app 內部了。本文說明 StoreKit 2 退款偵測在裝置上出現的位置,revocationDate 和 revocationReason 告訴你什麼,以及為什麼客戶端負責速度、伺服器負責真相。

重點摘要
- 在 StoreKit 2 中,已退款的購買會在其 Transaction 上帶有非 nil 的 revocationDate,因此你的 app 無需呼叫伺服器即可自行偵測到退款。
- revocationDate 會在 App Store 為某筆交易退款、或客戶透過 Family Sharing 失去該購買時被設定,所以非 nil 的日期並不總是代表退款。
- revocationReason 告訴你原因:developerIssue 表示客戶提到了你 app 中的問題,other 則涵蓋其餘所有退款原因。
- Transaction.currentEntitlements 已經排除了已退款和已撤銷的購買,所以最乾淨的客戶端存取判斷,就是看某個產品是否仍出現在其中。
- 只有當你在啟動時就開始監聽,Transaction.updates 才會送達在 app 關閉期間發生的退款,因此少了一個 Task 就代表漏掉一筆退款。
- 客戶端偵測只在 app 開啟時觸發,這正是 App Store Server Notifications V2 的 REFUND 仍然是權威訊號的原因,它能阻止你繼續為已退款使用者付費提供服務。
- 退款可以被撤回,一旦撤回,交易上的撤銷欄位會被移除,你需要恢復先前切斷的存取權限。
大多數 app 是從自己的伺服器、透過一則 App Store Server Notification 才得知 Apple 退款的,卻從未注意到同一筆退款其實早已存在於 app 內部。它就在交易上,位於一個名為 revocationDate 的屬性裡,讀取它可以讓你的 app 在已退款客戶下次開啟時就切斷其存取權限,而不必等待後端工作。StoreKit 2 退款偵測是一個大多數團隊會略過的客戶端訊號。以下就說明退款究竟在裝置上的哪裡浮現,它告訴你什麼、不告訴你什麼,以及為什麼它應當與你的伺服器通知並存,而不是取而代之。
StoreKit 2 中退款出現的位置
StoreKit 2 以簽名值的形式把交易交給你,退款並不會刪除交易,而是給它做上標記。Transaction 上有兩個屬性承載這個標記,在一筆健康購買的整個生命週期裡,它們都保持 nil。一旦其中之一變為非 nil,就代表 App Store 已經收回了這筆購買。
revocationDate 是會翻轉的那個欄位
revocationDate 是一個可選的 Date。Apple 自己的描述很精確:它是 App Store 為該交易退款、或將其從 Family Sharing 中撤銷的日期。對於仍然有效的購買,它是 nil。退款一旦被處理,它就保存那次退款的時間戳。這一項檢查,revocationDate 是否非 nil,就是客戶端退款偵測的全部。其餘的一切,都是在它之上疊加的細節。
revocationReason 告訴你 Apple 為何收回它
revocationReason 就在日期旁邊,解釋其原因。StoreKit 為退款給出兩個有意義的值。developerIssue 表示客戶告訴 Apple,退款是由於你 app 中實際存在或被認為存在的問題。other 涵蓋其餘所有原因。第三個值 upgradedToBundle 根本不是退款;它標記的是 App Store 因客戶轉到某個訂閱組合包而撤銷的交易。在採取行動前先讀取原因,因為 developerIssue 才是值得統計的那個:一批這樣的原因,就是你自己的產品在告訴你它在哪裡出了問題。
| 屬性 | 型別 | 非 nil 值代表什麼 |
|---|---|---|
revocationDate | Date? | App Store 在這一天為此交易退款,或透過 Family Sharing 撤銷了它 |
revocationReason 為 developerIssue | 原因 | 客戶提到了你 app 中實際存在或被認為存在的問題 |
revocationReason 為 other | 原因 | 退款出於 Apple 未逐項列出的其他原因 |
revocationReason 為 upgradedToBundle | 原因 | 不是退款;該交易因客戶切換到訂閱組合包而被撤銷 |
currentEntitlements 已經會剔除已退款的購買
你並不總是需要自己去讀取那些撤銷欄位。Transaction.currentEntitlements 是客戶此刻仍然有權享用的購買序列,Apple 在建構它時就會剔除那些你不應再兌現的項目。被 App Store 退款或撤銷的產品不會出現在其中。已過期的訂閱、以及一旦用完就消失的消耗型商品也不會出現。
這使得 currentEntitlements 成為最乾淨的存取判斷。問它客戶擁有什麼,就恰好授予那些,退款會替你移除相應權益,而無需任何一次 revocationDate 檢查。撤銷欄位用於你想要細節的時候,也就是日期和原因,用來記錄事件或對其作出反應。權益清單用於回答那個樸素的問題:是否繼續為其亮燈。
實務中的 StoreKit 2 退款偵測:啟動時與執行中
你的 app 在裝置上有兩個時刻可以捕捉退款,它們需要不同的程式碼。一個是在 app 開啟時,退款即時發生或在另一台裝置上發生。另一個是在啟動時,補上關閉期間發生的所有變化。漏掉第二個,你的 StoreKit 2 退款偵測就會在大多數退款所落之處正好留下一個漏洞,因為客戶在申請退款時很少還開著你的 app。
在啟動時就開始監聽,否則你會漏掉關閉期間發生的退款
Transaction.updates 是一個非同步序列,每當系統在你的 app 之外或另一台裝置上建立或更新一筆交易時,它就會發出這筆交易,退款也在其中。Apple 的指示很直接:在你的 app 一啟動時就開一個 Task 去迭代它,否則你可能會漏掉那些只在啟動時送達一次的交易。一筆在夜間落下的退款,會在 app 下次開啟時透過 updates 送達,但前提是已經有一個監聽器在執行以接收它。沒有監聽器,就沒有事件,退款會一直不可見,直到別的東西來對帳。
同一台裝置上的購買不會透過 updates 送達
有一個陷阱會絆倒那些手動測試退款的人。在同一台裝置上完成的普通購買不會透過 updates 到達;StoreKit 會直接從購買呼叫的結果中回傳它。updates 用於那些帶外變化:退款、Ask to Buy 的核准、兌換碼的兌換,以及在別處完成的購買。所以要圍繞 updates 和 currentEntitlements 來建構你的退款處理,而不是圍繞購買流程,因為退款永遠不會沿著銷售所走的那條路徑退回來。

客戶端偵測無法為你做到什麼
在裝置上讀取退款既快又免費,但它有一個上限,假裝它沒有上限,正是收入流失的方式。裝置只知道 StoreKit 告訴它的東西,而 StoreKit 只在你的 app 執行時才說話。一位拿到退款後再也不開啟你 app 的客戶,就是你的客戶端檢查永遠看不到的客戶。
revocationDate 並不總是代表退款
同一個欄位也會因 Family Sharing 而翻轉。當客戶失去對某筆共享購買的存取權,因為組織者移除了他、或共享結束了,那筆交易同樣會得到一個 revocationDate。所以非 nil 的日期代表客戶不再擁有這筆購買,這正是你做存取控制所需要的,但它並不總是代表有錢退了出去。如果你在為收入統計退款,先把 Family Sharing 的撤銷和真正的退款分開,再去相信那個數字。
退款可以被撤回
退款並不總是最終的。Apple 可以撤回它,一旦撤回,交易上的撤銷欄位會被移除,購買重新有效。如果你在退款時切斷了存取,你需要在撤回時把它恢復。在裝置上,這會表現為又一個 updates 事件,帶著一筆乾淨的交易;在你的伺服器上,它是一則獨立的 REFUND_REVERSED 通知。只處理退款,你就會把一位付費客戶晾在那裡,沒有存取權,卻握著一張有效的收據。
一次遲來的撤銷究竟要花多少錢
退款很少只是售價從你帳戶裡離開。等它結清時,你通常已經為那筆購買實際花掉了真金白銀,而這筆支出並不會回來。生成的圖片消耗了 GPU 分鐘。聊天回答消耗了按 token 計費的模型 API 呼叫。上傳消耗了你至今仍在付費保存的儲存空間。如果這筆購買資助了給某位創作者的付款,那筆錢早已出門。這些都不會隨退款而回。
客戶端偵測會在你仍能控制的那一部分上收窄視窗,也就是未來的支出。你越早知道一筆購買被退款,就越早停止為它提供服務。但裝置只在 app 開啟時才告訴你,所以一位再也不回來的已退款使用者,會保留你在伺服器端授予的一切存取權,每當一個背景工作或一台已同步的裝置代表他行動時,都在悄悄讓你付出代價。客戶端讓撤銷變快,卻不能讓它有保障。
用客戶端求速度,用伺服器求真相
乾淨的設計會讓兩種訊號各展所長。在裝置上,Transaction.updates 和 currentEntitlements 在已退款客戶開啟 app 的那一刻給你一個即時的本地反應,適合處理 UI、也適合在無需往返伺服器的情況下完成權益變更。在伺服器上,App Store Server Notifications V2 會發出一則 REFUND 訊息,無論 app 是否會再被開啟它都會到達,這是唯一能可靠阻止你的後端在已退款帳戶上繼續花錢的訊號。
| 訊號 | 它存在於何處 | 何時觸發 | 信任它來 |
|---|---|---|---|
交易上的 revocationDate | 裝置,StoreKit 2 | 你的 app 讀取該交易 | 告訴你某筆具體購買被退款或撤銷 |
Transaction.updates | 裝置,StoreKit 2 | 退款在 app 執行時落下,或在啟動時(如果你監聽) | 為在場的客戶即時作出反應 |
currentEntitlements | 裝置,StoreKit 2 | 你檢查客戶此刻擁有什麼 | 無需自己追蹤退款即可管控存取 |
REFUND 通知 | 你的伺服器,App Store Server Notifications V2 | Apple 處理退款,無論 app 是否開啟 | 停止在一位再也不回來的客戶身上的伺服器端支出 |
為拿著手機的客戶接入裝置訊號,為不在場的那位接入伺服器通知。退款出現在兩個地方是有意為之。只讀取其中之一,正是一個已退款帳戶在銷售早已消失後仍持續讓你花錢的方式。
常見問題解答
- 我如何在 StoreKit 2 中偵測退款?
- 檢查交易的 revocationDate。對於有效購買它是 nil,一旦 App Store 為該交易退款它就保存一個日期,所以非 nil 的 revocationDate 就是某筆購買被退款或撤銷的訊號。
- revocationDate 和 revocationReason 有什麼區別?
- revocationDate 是 App Store 收回購買的時間,revocationReason 是原因。當客戶提到你 app 中的問題時原因是 developerIssue,其他情況則是 other。
- 已退款的購買還會出現在 currentEntitlements 中嗎?
- 不會。Transaction.currentEntitlements 會排除被 App Store 退款或撤銷的購買,所以已退款的產品會自行從客戶的權益中掉出,這使它成為一個安全的存取判斷。
- StoreKit 會把 app 關閉期間發生的退款告訴我的 app 嗎?
- 只有當你從啟動時就監聽才會。Transaction.updates 會在啟動時把那些變化送達一次,所以你必須在 app 啟動時就開一個 Task 去迭代它,否則退款會被漏掉,直到別的東西來對帳。
- 僅靠客戶端退款偵測就夠了嗎?
- 不夠。裝置只在你的 app 執行時才得知退款,所以一位再也不重新開啟 app 的客戶對它是不可見的。App Store Server Notifications V2 的 REFUND 才是無論如何都會到達你這裡的訊號。
- revocationDate 是否總是代表客戶被退款了?
- 不是。當客戶透過 Family Sharing 失去某筆購買時也會設定 revocationDate,所以非 nil 的日期代表他們不再擁有該購買,但並不總是代表錢被退了回來。
資料來源與延伸閱讀
- Apple Developer: Transaction.revocationDate (StoreKit)
- Apple Developer: Transaction.RevocationReason (StoreKit)
- Apple Developer: Transaction.currentEntitlements (StoreKit)
- Apple Developer: Transaction.updates (StoreKit)
- Apple Developer: App Store Server Notifications V2 notificationType (REFUND, REFUND_REVERSED)
- Apple Developer Tech Talk: Support customers with StoreKit 2 and App Store Server API
RefundHalt
App Store 與 Google Play 的退款自動駕駛
繼續閱讀
每一個 App Store 與 Google Play 的退款視窗都是倒數計時,這裡列出每一個給你多少小時
App Store 與 Google Play 的每一筆退款都會啟動一個時鐘,而其中大多數不需要你就自行走完。Apple 最短的退款視窗是 12 小時,Google 的退單視窗是 24 小時,而從 2026 年 8 月 3 日起,錯過退單視窗就是一筆帳單,而不只是流失一筆銷售。這裡列出每一個會影響你帳戶的期限。
已作廢購買通知讓您的 Google Play 伺服器在退款發生的當下立即撤銷存取權限
Google Play 可以在購買被退款、發生退單或作廢的當下,將已作廢購買通知推送到您的伺服器。它攜帶 purchaseToken、orderId、productType 和 refundType,而它只代表一件事,撤銷存取權限。以下說明如何讀取它並完成串接。