Apple も Google も同じ返金を複数回あなたのサーバーへ届けることがあり、そのたびに処理してしまえば重複した返金通知はあなたにコストを負わせます
Apple は返金通知を最大で五回リトライし、Google Play は Pub/Sub の少なくとも一回の配信に乗っているため、同じ返金が複数回あなたのサーバーへ届きえます。ここでは、残高を二重に差し引いたり API クォータを二重に消費したりせずに重複した返金通知を処理する方法を説明します。

要点
- あなたのサーバーが 200 から 206 の間の HTTP ステータスで応答しないかぎり、Apple は App Store Server Notification V2 を五回リトライします。前回の試行から 1、12、24、48、72 時間後です。最初の試行を数えると、一件の返金は最大で六回届きえます。
- 本当の Apple のリトライはすべて同じ notificationUUID を運ぶため、そのフィールドこそが、トランザクション id ではなく、あなたの重複排除キーです。
- Google Play の Real-time Developer Notifications は Cloud Pub/Sub に乗っており、それは少なくとも一回の配信を保証し順序は保証しないため、同じメッセージが二回、あるいは順不同で届きえます。Google は、何かを処理する前に messageId の一意性を確認せよと告げています。
- Apple の周期的な CONSUMPTION_REQUEST 通知はリトライではありません。Apple は開いている返金ウィンドウの間、新しいものを送り続け、それぞれが異なる notificationUUID を持つため、notificationUUID による重複排除はそのすべてを正しく残します。
- 同じ Apple の transactionId は複数の決定を運びえます。たとえば REFUND_DECLINED があり、後に REFUND が続くといった具合で、トランザクション id だけで重複排除すると、あなたが必要としていた別個のイベントを捨ててしまいます。
- 4xx や 5xx を返して重複を拒否しても、ストアがそれをリトライするだけです。あなた自身のデータベースの内側で重複排除し、常に成功ステータスを返してください。
- 冪等でない返金ハンドラは二回目の配信で二重に処理します。残高を二回差し引き、支払いを二回巻き戻し、あるいはすでに閉じた返金を再確認して、課金対象の Play Developer と App Store Server API のクォータを消費します。
あなたのサーバーは同じ返金イベントを複数回受け取ることになり、両ストアともそれをわざとそう設計しました。あなたのエンドポイントがきれいに応答しないとき、Apple は App Store Server Notification を最大で五回リトライします。Google Play はその Real-time Developer Notifications を Cloud Pub/Sub 経由で配信し、それは少なくとも一回の配信を約束し、順序については何も約束しません。ですから問いは、重複が届くかどうかでは決してありません。問いは、あなたのコードが同じ返金を二度目に見たときに何をするか、です。それを誤ると、残高を二回差し引き、支払いを二回巻き戻し、あるいはすでに閉じた返金を再確認して課金対象の API クォータを消費します。ここでは、重複した返金通知が実際にどのようにあなたへ届くのか、どの反復が本当の重複でどれがそう見えるだけなのか、そして二度目の配信がタダになるようにそれらをどう処理するかを説明します。
返金通知は少なくとも一回配信されますが、それはちょうど一回と同じではありません
両ストアとも、配信された通知を、撃ちっぱなしで忘れる一発ではなく、届けようと試み続ける約束として扱います。それは信頼性にとって良いことです。なぜなら、デプロイ中に取りこぼした通知も後であなたへ届くからです。しかしそれは正しさにとって罠です。なぜなら、最終的にイベントを受け取れることを保証するその仕組みが、ときには二回受け取ることも保証するからです。あなたのハンドラは冪等でなければなりません。つまり、一件の返金の二度目と三度目の配信は、一度目がまだ変えていないものを何も変えない、ということです。
Apple は三日間で五回リトライします
Apple が App Store Server Notification V2 を送るとき、それはあなたのサーバーが 200 から 206 の範囲の HTTP ステータスで応答することを期待します。それ以外の何か、4xx でも 5xx でも、それは Apple に配信の失敗を告げ、Apple はリトライします。そのスケジュールは固定です。五回のリトライ、前回の試行から 1、12、24、48、72 時間後です。最初の試行を数えると、一件の返金イベントは最大で六回、おおよそ一週間にわたって届きえます。それらのリトライの一つ一つが同じ notificationUUID を運びます。そのフィールドがあなたの重複排除キーです。もしすでにある notificationUUID を記録済みなら、あなたが手にしているその配信は反復であり、正しい応答は新しいものを何も保存せず、それでも 200 を返すことです。
Google Play は Pub/Sub に乗っており、少なくとも一回を約束し順序については何も言いません
Google Play の Real-time Developer Notifications は Cloud Pub/Sub のトピックに発行されます。Pub/Sub の配信保証は少なくとも一回であり、順序の保証はまったくありません。それはつまり、同じメッセージがあなたのエンドポイントへ複数回配信されえ、同じ購入についての二つのメッセージが順不同で届きえる、ということです。Google 自身のガイダンスは明快です。base64 の data フィールドを解き、messageId を読み、何かを処理する前にそれを以前見ていないことを確認せよ、と。重複した messageId はあなたがスキップする反復です。同じ購入についての二つの異なる通知は、それでも同じレコードに着地すべきなので、保存する状態を purchaseToken にもキーづけし、後のイベントが先のイベントの作った行を更新できるようにしてください。
| プラットフォーム | 配信モデル | 重複排除キー | 成功シグナル | 確認しない場合 |
|---|---|---|---|---|
| App Store Server Notifications V2 | 最大 6 回の試行:最初の一回に加え、1、12、24、48、72 時間での 5 回のリトライ | notificationUUID | HTTP 200 から 206 | Apple は固定スケジュールでリトライし、その後停止 |
| Google Play RTDN、Pub/Sub 経由 | 少なくとも一回、順序保証なし | Pub/Sub messageId、purchaseToken でエンティティキーづけ | push に対して HTTP 200、または明示的な ack | ack の期限が切れると Pub/Sub が再送 |
重複ではない反復
見たことのあるもののように見える通知が、すべてリトライだとはかぎりません。Apple の二つの挙動は、一件の購入を共有しつつも各々が処理されなければならない、正真正銘の新しいイベントを送ります。それらを素朴な重複排除でつぶすと、あなたが必要としていた情報を捨ててしまいます。
Apple は新しい CONSUMPTION_REQUEST を送るのであって、リトライではありません
消費型商品について開いている返金リクエストの間、Apple は一つの CONSUMPTION_REQUEST を送って待つのではありません。返金が閉じるまで、開いている返金ウィンドウ全体にわたって周期的に新しいものを送ります。Apple のスタッフはこれらがリトライではないことを確認しており、その手がかりはあなたが重複排除に使うフィールドにあります。新しい CONSUMPTION_REQUEST はそれぞれ異なる notificationUUID を運びます。ですから notificationUUID にキーづけした重複排除は自動的に正しいことをします。本当のリトライをつぶし、別個のプロンプトはすべて残します。あなたが決してしてはならないのは、トランザクション id と通知タイプで重複排除することです。それは最初の一件より後の CONSUMPTION_REQUEST をすべて黙らせ、取りこぼしたものについて 12 時間の証拠ウィンドウをあなたに失わせるからです。
一件のトランザクションは複数の決定を運びえます
単一の transactionId はその生涯にわたって複数の返金結果を生みえます。Apple は REFUND_DECLINED を送り、後に同じトランザクションについて REFUND を送りえ、開発者は一つのトランザクション id について返金関連の通知を三つ以上受け取ったと報告しています。それぞれが自身の notificationUUID を持つ別個のイベントです。もしあなたの重複排除キーがトランザクション id なら、二つ目の決定は一つ目の重複のように見え、返金が最終的に認められたことをあなたは決して知りません。トランザクション id はイベントをグループ化します。それらを識別はしません。

重複が実際にあなたに負わせるコスト
返金通知はステータスランプではありません。それは実際の行動を引き起こします。アクセスを取り消し、消費型残高を差し引き、クリエイターへの支払いを巻き戻し、状態を確かめるために App Store Server API または Play Developer API を呼ぶのです。それらのどれかを重複に対して二度目に実行すれば、そのコストは本物です。
お金の流れをたどりましょう。アクセスを二回取り消すのは無害です。アクセスはすでに消えているからです。残高を二回差し引くのはそうではありません。コインのパックを買って返金したユーザーは、あなたのサポートチームがのちに手作業で巻き戻すことになるマイナス残高まで押し下げられえます。支払いを二回巻き戻すのは、あなたがすでに一度返したお金を取り戻すことであり、いまやあなたはクリエイターに謝罪と訂正を負っています。そしてストア API に対して再処理する重複の一つ一つが、Google が明示的に守れと警告するクォータを費やすので、障害中の一斉の Pub/Sub 再送は、あなたが最も余裕のないまさにその日に、あなたをレート制限へ押しやりえます。
返金の証拠ウィンドウは、Apple 側で賭け金を吊り上げます。素朴な重複排除が、Apple が開いている返金ウィンドウの間に送る繰り返しの CONSUMPTION_REQUEST を黙らせるなら、あなたが応答する必要のあった一件を取りこぼしえ、12 時間以内に応答しない CONSUMPTION_REQUEST は、Apple がしばしば既定で認める返金です。それは二重課金ではありません。それは失われた売上に加え、あなたがその購入を届けるためにすでに費やした計算、API 呼び出し、ストレージ、支払いであり、そのどれ一つとして返金は返しません。
| 失敗モード | 何がまずくなるか | 何のコストがかかるか |
|---|---|---|
| 重複した REFUND で残高を再度差し引く | ユーザーの消費型残高がマイナスになる | 突き合わせのための手作業のサポート時間と、悪い顧客体験 |
| 支払いを二回巻き戻す | あなたはすでに一度返したお金を取り戻す | クリエイターへの訂正と会計の後始末 |
| ストア API に対して再処理する | 重複した呼び出しが Play Developer または App Store Server API のクォータを費やす | 再送を引き起こした障害の最中のレート制限 |
| CONSUMPTION_REQUEST を過剰に重複排除する | 別個の返金プロンプトを偽の重複として捨てる | 12 時間のウィンドウを逃し、Apple が既定で返金を認める |
二重処理せずに重複した返金通知を処理する方法
パターンは両ストアで同じで、キーだけが異なります。配信を記録し、処理する前にキーを確認し、一度だけ処理し、そして常にストアに受け取ったと告げます。
- トランザクションではなく、ストアの配信 id で重複排除してください。Apple には
notificationUUID、Google Play には Pub/Sub のmessageIdを使います。並行する重複が二重処理する代わりに競合で負けるよう、一意制約とともに保存してください。 - 下流の行動をそれ自身の条件で冪等にしてください。配信 id にキーづけすれば再処理は止まりますが、取り消し、差し引き、巻き戻しがまず現在の状態を確認し、二回実行しても安全なように、効果そのものも書いてください。
- まず永続化し、それから確認してください。200 を返すか Pub/Sub メッセージを ack する前に、イベントをあなたのデータベースへ書き込みます。もし先に ack して書き込みが失敗すれば、ストアはそのメッセージを配信済みとみなして二度と送らず、いまやあなたはそれを完全に失っています。
- 常に成功ステータスを返してください。重複に対してもです。Apple には 200 から 206、Google には Pub/Sub の push に対して 200 です。反復をエラーで拒否しても、ストアがそれをリトライするだけです。
- エンティティでグループ化し、イベントで識別してください。保存する購入状態を
purchaseTokenまたはoriginalTransactionIdにキーづけして順不同の配信が一つの行を更新するようにしつつ、各notificationUUIDやmessageIdをそれ自身のイベントとして扱ってください。一件の購入は正当に複数を生むからです。
あなたの返金 webhook を信頼する前の短いチェックリスト
- Apple の配信は
notificationUUIDで重複排除され、反復は新しいものを何も書かず、それでも 200 を返す。 - Google Play の配信は Pub/Sub の
messageIdで重複排除され、いかなる処理よりも前に確認される。 - 購入状態は
purchaseTokenまたはoriginalTransactionIdにキーづけされ、順不同のイベントが一つのレコードに着地する。 - あらゆる返金の副作用、取り消し、差し引き、巻き戻しは、複数回実行しても安全である。
- あなたのハンドラは確認する前にイベントを書き込み、決して後ではない。
- 繰り返される CONSUMPTION_REQUEST は重複ではなく別個のプロンプトとして扱われ、開いている返金ウィンドウが一つも捨てられない。
重複をわざとあなた自身の webhook に通し、それが二度目に何も変えないのを見届けてください。それがテストのすべてです。二回叩かれても安全な返金ハンドラは、ストアが六回叩くと決めたその瞬間から、あなたが気に病むのをやめられる類のものです。
よくある質問
- なぜ私のサーバーは同じ App Store の返金通知を複数回受け取るのですか?
- あなたのサーバーが 200 から 206 の間の HTTP ステータスで応答しないかぎり、Apple が App Store Server Notification V2 を最大で五回、前回の試行から 1、12、24、48、72 時間後にリトライするからです。どのリトライも同じ notificationUUID を運ぶため、それを認識してスキップできます。
- App Store Server Notifications の重複排除にはどのフィールドを使うべきですか?
- notificationUUID を使ってください。本当のリトライは常に同じ notificationUUID を繰り返す一方、正真正銘の新しいイベントは、新しい CONSUMPTION_REQUEST の一つ一つを含め、異なるものを得るため、notificationUUID で重複排除すれば別個のイベントを捨てずに反復をスキップできます。
- 繰り返される CONSUMPTION_REQUEST 通知は、私が無視すべき重複ですか?
- いいえ。Apple は開いている返金ウィンドウの間、新しい CONSUMPTION_REQUEST 通知を周期的に送り、Apple のスタッフはこれらがリトライではないと確認しています。それぞれ自身の notificationUUID を持つので、一つ残らず処理してください。捨てると、Apple が応答のために与える 12 時間のウィンドウを逃す恐れがあります。
- Google Play の Real-time Developer Notifications はどう重複排除しますか?
- 各通知から Pub/Sub の messageId を読み、処理する前にすでに処理済みのものと突き合わせて確認してください。Pub/Sub は少なくとも一回配信し、同じメッセージを複数回送りえるからです。Google は、重複処理と API クォータの浪費を避けるためにこれを明示的に推奨しています。
- 重複した返金通知を拒否するためにエラーを返すべきですか?
- いいえ。4xx や 5xx を返すとストアに配信の失敗を告げるため、いずれにせよリトライします。あなた自身のデータベースの内側で重複排除し、常に成功ステータスを返してください。Apple には HTTP 200 から 206、Google Play の push には 200 の確認です。
出典と参考資料
- Apple Developer: App Store Server Notifications V2
- Apple Developer: Responding to App Store Server Notifications
- Apple Developer: notificationUUID
- Apple Developer Forums: App Store Server Notifications V2 retry schedule and CONSUMPTION_REQUEST clarification
- Android Developers: Real-time developer notifications reference
- Android Developers: Purchase lifecycle and RTDNs
- Google Cloud: Pub/Sub subscriber and at-least-once delivery
RefundHalt
App Store と Google Play の返金を自動処理
続きを読む
Family Sharing の返金は 1 件の支払いを取り消すだけですが、ほかの 5 人はアプリを使い続けられます。その人たちを止められるのはあなたのサーバーだけです
Family Sharing の返金は 1 件の支払いを取り消しますが、最大 5 人の家族が有料機能を使い続けられる場合があります。Apple は REVOKE を送信し、そのアクセスを終了させるのはあなたのサーバーだと想定しています。ここでは家族共有の返金の仕組みと、その 1 件がいくらかかるのかを説明します。
返金処理は静かに壊れる。だから本物の顧客が先に踏む前に、sandbox でアプリ内課金の返金をテストしよう
返金処理は顧客がすでに去った後にしか走らないため、そこに潜むバグは実際に金銭的損失を出すまで見えないままです。どちらのストアも、まずテスト環境で返金を発火させることを許しています。ここでは、返金が本物になる前に App Store と Google Play でアプリ内課金の返金をテストする方法を説明します。