退款後撤銷存取權限:Apple 和 Google 不會替你完成的那一步
Apple 和 Google 都可能核准退款,但顧客手上的購買項目卻原封不動。這篇文章會精確說明什麼情況下系統會自動撤銷存取權限,什麼情況下要靠你的伺服器自己處理,以及那個幾乎所有整合都會漏接的通知。

重點摘要
- 在 Google Play 上,退款一筆訂單本身並不會撤銷使用者的存取權限。只有在 Play Console 中勾選「Remove entitlement」,或是 API 呼叫帶入 revoke=true 時,存取權限才會被撤銷,而這並不是預設值。
- 沒有勾選 revoke 選項的退款,完全不會出現在 Voided Purchases API 裡。只讀取這個資料來源的撤銷作業,會無聲無息地漏掉每一筆這樣的訂單。
- Apple 可以自動撤銷 non-consumable 項目和訂閱的授權,但 consumable 一旦被使用,就會被視為已經交付完成,因此 StoreKit 沒有機制可以 revoke 它。你的伺服器必須自行扣除餘額。
- Apple 也可能撤回自己核准的退款。REFUND_REVERSED 通知代表先前核准的退款被撤回了,revocationDate 會回到 null,而你先前移除的任何存取權限或餘額都必須恢復。
- Google 的 voidedReason 欄位共有九種數值,從 remorse 和 accidental_purchase,到 fraud、friendly_fraud 和 unacknowledged_purchase 都有。同一份資料來源除了告訴你該 revoke 存取權限之外,也告訴你哪些撤銷是濫用行為,哪些只是你自己整合上的錯誤。
- 退款項目每多保留存取權限一天,你就多為背後的運算、儲存和 API 呼叫多付一天的錢,而這筆錢的收入你早就已經退還給顧客了。
核准退款和移除存取權限是兩個獨立的步驟,而且只有其中一個是自動發生的。認為退款一旦處理完成就等於購買項目已被撤銷的開發者,在兩大應用商店上都想錯了,而且錯的方式和原因各不相同。要在退款後 revoke 存取權限,而不是幾個月後才發現這件事根本沒發生,關鍵就在於清楚知道自動化到底在哪裡停下。
為什麼退款預設不會 revoke 存取權限
Apple 和 Google 都把金流和存取權限當成兩件不同的事來處理。金錢的部分是商店政策,退款一旦核准,就是最終結果,帳目會立刻更新。存取權限的部分則是選擇性的,或是開發者自己的責任,或兩者皆是,視平台和購買類型而定。兩家商店都不會假設你希望退款一結清,存取權限就馬上消失,因為有很多正當的情況,例如部分退款、善意抵免、客服的體貼作法,本來就不該把產品收回。
Google Play:沒人注意到的核取方塊
在 Play Console 中,退款一筆訂單是一份分兩部分的表單,先選擇退款金額,再另外決定是否勾選「Remove entitlement」。大多數客服人員預設不勾選,因為大部分退款請求都是善意的處理方式,讓顧客保留他們買的東西。問題是退款確認畫面上完全沒有任何提示告訴你,這也代表著顧客在一筆你原本以為已經結案的退款之後,仍然能保留訂閱或應用程式內項目。
透過 API 的路徑也有同樣的陷阱
程式碼裡也存在同樣的選擇。orders.refund 端點接受一個可選的 revoke 查詢參數,當它是 true 時,該項目的存取權限會立即終止,對於定期訂閱來說,未來每一筆付款也會一併取消。如果省略這個參數,預設值就是 false,退款會照常送出,購買紀錄會更新,而使用者則完全照舊繼續使用該產品。
| 動作 | 存取權限是否被移除? | 是否出現在 Voided Purchases API 中? |
|---|---|---|
| 透過 Play Console 退款,未勾選「Remove entitlement」 | 否 | 否 |
| 透過 Play Console 退款,已勾選「Remove entitlement」 | 是,立即生效 | 是 |
| orders.refund API,省略 revoke 參數(預設為 false) | 否 | 否 |
| orders.refund API,revoke=true | 是,立即生效 | 是 |
問題就出在最後那一列,這正是最常讓整合出包的地方。Voided Purchases API 的用意是讓你不必手動輪詢 Play Console,就能建立撤銷作業的流程,但它只列出真正以撤銷方式作廢的訂單。沒有勾選核取方塊,或沒有設定 revoke=true 的退款,在這個資料來源裡是刻意看不到的,不是系統的錯誤。

解讀 voidedReason 欄位 當一筆購買確實顯示為 voided 時,voidedReason 欄位會告訴你原因,總共有九種數值:other、remorse、not_received、defective、accidental_purchase、fraud、friendly_fraud、chargeback,以及 unacknowledged_purchase。這不只是記帳用的資訊。remorse 和 accidental_purchase 屬於一般退款。fraud、friendly_fraud 和 chargeback 則值得被導向詐欺審查佇列,而不是單純 revoke 之後就不管了,因為同一個帳號在統計上更有可能再犯。
如果你要直接串接這個資料來源,有一個維運細節很重要:purchases.voidedpurchases.list 的上限是每天 6,000 次查詢,以及任何 30 秒的區間內 30 次查詢。如果設計成緊密迴圈式的輪詢,很快就會撞到這個上限;正確的做法是搭配分頁權杖排程拉取資料,而不是持續不斷地輪詢。
Apple:App Store 可以 revoke 授權,但無法對 consumable 這麼做
Apple 的 App Store Server Notifications V2 會在退款完成的那一刻發出 REFUND 通知,無論是 consumable、non-consumable、自動續訂訂閱,還是非自動續訂訂閱都一樣。這則通知之後會發生什麼事,完全取決於購買類型,因為 StoreKit 本身的撤銷機制只涵蓋四種之中的兩種。
為什麼 consumable 是例外
non-consumable 或訂閱擁有一個持續存在的權利,Apple 的系統可以把它關掉。consumable 則不然,一旦被使用,就會被視為已經交付完成,StoreKit 裡再也沒有什麼可以拿來 revoke。如果顧客買了 500 枚代幣,已經用掉了,然後拿到退款,從 Apple 的角度來看,這些代幣早就沒了。唯一還留著這筆帳的,只有你自己的帳本。REFUND 通知是你唯一的訊號,讓你去找出對應的 original transaction ID,然後從自己的紀錄裡扣掉那筆餘額,Apple 那邊不會替你處理任何事。
| 通知 | 代表的意思 | 你的伺服器必須做的事 |
|---|---|---|
| REFUND | Apple 已對 consumable、non-consumable、自動續訂或非自動續訂的購買項目退款 | 立即 revoke non-consumable 和訂閱的存取權限;consumable 的餘額則自行扣除 |
| REFUND_DECLINED | Apple 拒絕了顧客的退款請求 | 不需要做任何事。存取權限從未受到影響 |
| REFUND_REVERSED | Apple 撤回了先前已核准的退款 | 恢復因 REFUND 通知而被你移除的任何存取權限或餘額 |
| CONSUMPTION_REQUEST | Apple 想在決定是否退款前取得你提供的證據 | 在 12 小時內回覆;這個通知會在 REFUND 之前發出,而不是之後 |
幾乎沒人處理的那個通知
REFUND_REVERSED 是大多數整合會完全跳過的通知,因為它描述的是 Apple 撤回自己先前的決定,而這是很少開發者在第一輪設計時就會考慮到的情況。當它送達時,交易上的 revocationDate 會回到 null,就 Apple 而言,這筆購買重新生效了。如果你的伺服器在原本的 REFUND 送達時刪除了某項權利、取消了訂閱紀錄,或把 consumable 的餘額歸零,那麼 REFUND_REVERSED 就是要你把這一切全部復原的訊號。
如何一步步補上這個缺口
- 在 Google Play 上,無論是透過 Play Console 還是 API,都應該讓每一條退款路徑預設使用 revoke=true,除非有特定的客服情境需要讓顧客保留存取權限。
- 以排程搭配分頁權杖的方式輪詢 purchases.voidedpurchases.list,而不是用緊密迴圈,並將次數控制在每天 6,000 次以下、每 30 秒 30 次以下。
- 把 voidedReason 為 fraud、friendly_fraud 和 chargeback 的情況導向審查佇列,而不是自動 revoke 後直接結案。
- 在 Apple 這一側,訂閱 App Store Server Notifications V2,並在 REFUND 通知指名某個項目的當下,立刻 revoke 該 non-consumable 或訂閱的存取權限。
- 為每一筆 consumable 購買都保存好 original transaction ID,這樣 REFUND 通知一來,就能精準找到該扣除的餘額,不必手動查找。
- 明確處理 REFUND_REVERSED,並針對每一次撤銷都記錄對應的 transaction ID 或 order ID,這樣一旦被撤回,就能精準恢復當初被移除的內容。
這個缺口實際上要付出多少代價
這一切都與對顧客是否公平無關,而是關於錢已經退回去之後,還有什麼東西持續在運作。一筆已退款的訂閱,如果還在持續呼叫你的 API、儲存資料、消耗運算資源,這不是道德問題,而是一筆實實在在的支出項目,你正在為一筆早已把收入退還回去的購買,支付基礎設施的帳單。商店不會替你追蹤這筆成本,因為在他們看來,退款一旦金流動了就算完成了。存取權限這一側的帳,完全要靠你自己來控管。
這正是為什麼 RefundHalt 把撤銷存取權限當成退款流程的一部分,而不是事後才想到的補救措施,REFUND 事件和存取權限的變更會被記錄為同一個動作,而 REFUND_REVERSED 事件則會自動恢復狀態,不必等到有人發現顧客明明被 Apple 歸還了東西,卻依然被鎖在外面。
常見問題解答
- 在 Google Play 上退款一筆購買,會自動 revoke 存取權限嗎?
- 不會。只有在 Play Console 中勾選「Remove entitlement」,或是 orders.refund API 呼叫中帶入 revoke=true,存取權限才會被移除。這兩者都不是預設值,所以退款可以照常送出,而顧客仍然能繼續使用該產品。
- 在 App Store 上,consumable 應用程式內購買項目被退款時會發生什麼事?
- Apple 會發出 REFUND 通知,但 StoreKit 沒有辦法 revoke consumable,因為它一旦被使用就被視為已經交付完成。開發者的伺服器必須自行找出原始交易,並手動扣除餘額。
- REFUND_REVERSED 通知是什麼,需要特別處理嗎?
- REFUND_REVERSED 代表 Apple 撤回了先前已核准的退款,revocationDate 會回到 null,這筆購買重新生效。原本的 REFUND 所移除的任何存取權限或餘額都必須恢復,而大多數整合根本從來不監聽這個通知。
- 為什麼一筆已退款的 Google Play 訂單不會出現在 Voided Purchases API 裡?
- 這個 API 只會列出以撤銷方式作廢的訂單。如果退款是在沒有選擇 revoke 選項的情況下處理的,這個端點就完全不會回傳那筆訂單,因此只依賴這個 API 的流程,會完全漏掉那些退款。
- 我可以多頻繁地輪詢 Google 的 Voided Purchases API?
- 這個端點的速率限制是每天 6,000 次查詢,以及任何 30 秒區間內 30 次查詢,因此它的設計用途是搭配分頁權杖排程拉取資料,而不是持續輪詢。
資料來源與延伸閱讀
- Apple: Handling refund notifications
- Apple: notificationType (App Store Server Notifications)
- Apple: revocationReason (App Store Server Notifications)
- Google: Voided Purchases API overview
- Google: purchases.voidedpurchases REST resource
- Google: orders.refund API reference (revoke parameter)
- Google Play Console Help: manage your app's orders and issue refunds
RefundHalt
App Store 與 Google Play 的退款自動駕駛
繼續閱讀
過多的退款申請,代價可能遠超退款本身,因為兩大 App 商店都把它視為品質訊號
Apple 審查準則 5.6.4 把過多的退款申請列為 App 未達品質標準的跡象,Google 的開發人員發布協議也允許它依退款率下架新開發人員的 App。本文整理兩家商店的原文、下架的實際成本,以及如何避開風險。
在處理退款之前先驗證 App Store 伺服器通知,因為任何人都能向你的 webhook URL 發送請求
在檢查簽章之前,一則退款通知只不過是一個 HTTPS 請求。本文說明如何驗證 App Store 伺服器通知和 Google Play RTDN 訊息、各商店簽署的內容,以及偽造的退款或遭錯誤拒絕的退款會讓你付出什麼代價。