Both Apple and Google can deliver the same refund to your server more than once, and duplicate refund notifications cost you if you act on each one
Apple retries a refund notification up to five times and Google Play rides Pub/Sub at-least-once, so the same refund can reach your server more than once. Here is how to handle duplicate refund notifications without deducting a balance or burning API quota twice.

Key takeaways
- Apple retries an App Store Server Notification V2 five times, at 1, 12, 24, 48, and 72 hours after the last attempt, whenever your server does not answer with an HTTP status between 200 and 206. Counting the first attempt, one refund can arrive up to six times.
- Every true Apple retry carries the same notificationUUID, so that field, not the transaction id, is your deduplication key.
- Google Play's Real-time Developer Notifications ride on Cloud Pub/Sub, which guarantees at-least-once delivery and no ordering, so the same message can arrive twice or out of order. Google tells you to check the messageId for uniqueness before you process anything.
- Apple's periodic CONSUMPTION_REQUEST notifications are not retries. Apple keeps sending fresh ones over the open refund window, each with a different notificationUUID, so deduplicating on notificationUUID correctly keeps every one of them.
- The same Apple transactionId can carry more than one decision, for example a REFUND_DECLINED followed later by a REFUND, so deduplicating on transaction id alone throws away a distinct event you needed.
- Rejecting a duplicate by returning a 4xx or 5xx only makes the store retry it. Deduplicate inside your own database and always return a success status.
- A refund handler that is not idempotent double-acts on the second delivery. It deducts a balance twice, reverses a payout twice, or burns billable Play Developer and App Store Server API quota rechecking a refund it already closed.
Your server will receive the same refund event more than once, and both stores designed it that way on purpose. Apple retries an App Store Server Notification up to five times when your endpoint does not answer cleanly. Google Play delivers its Real-time Developer Notifications over Cloud Pub/Sub, which promises at-least-once delivery and nothing about order. So the question is never whether a duplicate arrives. It is what your code does the second time it sees the same refund. Get that wrong and you deduct a balance twice, reverse a payout twice, or burn billable API quota rechecking a refund you already closed. Here is how duplicate refund notifications actually reach you, which repeats are real duplicates and which only look like them, and how to handle them so the second delivery is free.
A refund notification is delivered at least once, which is not the same as exactly once
Both stores treat a delivered notification as a promise they keep trying to fulfill, not a single shot they fire and forget. That is good for reliability, because a notification you miss during a deploy still reaches you later. It is a trap for correctness, because the mechanism that guarantees you eventually get the event also guarantees you sometimes get it twice. Your handler has to be idempotent, meaning the second and third deliveries of one refund change nothing that the first did not already change.
Apple retries five times over three days
When Apple sends an App Store Server Notification V2, it expects your server to answer with an HTTP status in the 200 to 206 range. Anything else, a 4xx or a 5xx, tells Apple the delivery failed, and Apple retries. The schedule is fixed: five retries, at 1, 12, 24, 48, and 72 hours after the previous attempt. Counting the first attempt, one refund event can arrive up to six times, spread across roughly a week. Every one of those retries carries the same notificationUUID. That field is your deduplication key. If you have already recorded a notificationUUID, the delivery you are holding is a repeat, and the correct response is to store nothing new and still return 200.
Google Play rides Pub/Sub, which promises at least once and says nothing about order
Google Play's Real-time Developer Notifications are published to a Cloud Pub/Sub topic. Pub/Sub's delivery guarantee is at-least-once, and it makes no ordering guarantee at all. That means the same message can be delivered to your endpoint more than once, and two messages for the same purchase can arrive out of order. Google's own guidance is explicit: unpack the base64 data field, read the messageId, and check that you have not seen it before you process anything. A duplicate messageId is a repeat you skip. Two different notifications about one purchase should still land on the same record, so key your stored state on the purchaseToken as well, and let a later event update the row an earlier one created.
| Platform | Delivery model | Deduplicate on | Success signal | If you do not acknowledge |
|---|---|---|---|---|
| App Store Server Notifications V2 | Up to 6 attempts: the first, plus 5 retries at 1, 12, 24, 48, 72 hours | notificationUUID | HTTP 200 to 206 | Apple retries on the fixed schedule, then stops |
| Google Play RTDN over Pub/Sub | At-least-once, no ordering guarantee | Pub/Sub messageId, entity-keyed on purchaseToken | HTTP 200 to the push, or an explicit ack | Pub/Sub resends after the ack deadline expires |
The repeats that are not duplicates
Not every notification that looks like one you have seen is a retry. Two Apple behaviors send genuinely new events that share a purchase but must each be processed, and collapsing them with a naive dedupe throws away information you needed.
Apple sends fresh CONSUMPTION_REQUESTs, not retries
During an open refund request on a consumable, Apple does not send one CONSUMPTION_REQUEST and wait. It sends new ones periodically over the entire open refund window until the refund is closed. Apple staff have confirmed these are not retries, and the giveaway is the field you deduplicate on: each fresh CONSUMPTION_REQUEST carries a different notificationUUID. So a dedupe keyed on notificationUUID does the right thing automatically. It collapses true retries and keeps every distinct prompt. What you must not do is dedupe on transaction id and notification type, because that would silence every CONSUMPTION_REQUEST after the first and cost you the 12-hour evidence window on the ones you dropped.
One transaction can carry more than one decision
A single transactionId can produce more than one refund outcome over its life. Apple can send a REFUND_DECLINED and then, later, a REFUND for the same transaction, and developers report receiving three or more refund-related notifications for one transaction id. Each is a distinct event with its own notificationUUID. If your deduplication key is the transaction id, the second decision looks like a duplicate of the first and you never learn the refund was ultimately granted. The transaction id groups events. It does not identify them.

What a duplicate actually costs you
A refund notification is not a status light. It triggers real actions: you revoke access, you deduct a consumable balance, you reverse a creator payout, you call the App Store Server API or the Play Developer API to confirm the state. Run any of those a second time on a duplicate and the cost is real.
Walk the money. Revoking access twice is harmless, because access is already gone. Deducting a balance twice is not: a user who bought a pack of coins and refunded it can be driven to a negative balance your support team then has to unwind by hand. Reversing a payout twice claws back money you already returned once, and now you owe a creator an apology and a correction. And every duplicate you re-process against a store API spends quota Google explicitly warns you to protect, so a burst of Pub/Sub redelivery during an outage can push you into rate limiting on the exact day you can least afford it.
The refund evidence windows raise the stakes on the Apple side. If a naive dedupe silences the repeated CONSUMPTION_REQUESTs Apple sends over the open refund window, you can miss the one you needed to answer, and a CONSUMPTION_REQUEST you do not answer inside 12 hours is a refund Apple often grants by default. That is not a double-charge. It is a lost sale plus the compute, API calls, storage, and payouts you already spent delivering the purchase, none of which the refund returns.
| Failure mode | What goes wrong | What it costs |
|---|---|---|
| Re-deduct a balance on a duplicate REFUND | The user's consumable balance goes negative | Manual support time to reconcile, and a bad customer experience |
| Reverse a payout twice | You claw back money you already returned once | A creator correction and an accounting cleanup |
| Re-process against a store API | Duplicate calls burn Play Developer or App Store Server API quota | Rate limiting during the outage that caused the redelivery |
| Over-dedupe CONSUMPTION_REQUESTs | You drop a distinct refund prompt as a false duplicate | A missed 12-hour window, so Apple grants the refund by default |
How to handle duplicate refund notifications without double-acting
The pattern is the same on both stores, with a different key. Record the delivery, check the key before you act, act once, and always tell the store you received it.
- Deduplicate on the store's delivery id, not the transaction. Use
notificationUUIDfor Apple and the Pub/SubmessageIdfor Google Play. Store it with a unique constraint so a concurrent duplicate loses the race instead of double-acting. - Make the downstream action idempotent on its own terms. Keying on the delivery id stops re-processing, but also write the effect so that revoking, deducting, or reversing checks current state first and is safe to run twice.
- Persist first, then acknowledge. Write the event to your database before you return 200 or ack the Pub/Sub message. If you ack first and the write fails, the store considers the message delivered and never sends it again, and now you have lost it for good.
- Always return a success status, even for a duplicate. A 200 to 206 for Apple, a 200 to the Pub/Sub push for Google. Rejecting a repeat with an error only makes the store retry it.
- Group on the entity, identify on the event. Key your stored purchase state on the
purchaseTokenororiginalTransactionIdso out-of-order deliveries update one row, but treat eachnotificationUUIDormessageIdas its own event, because one purchase legitimately produces several.
A short checklist before you trust your refund webhook
- Apple deliveries are deduplicated on
notificationUUID, and a repeat writes nothing new but still returns 200. - Google Play deliveries are deduplicated on the Pub/Sub
messageId, checked before any processing. - Purchase state is keyed on
purchaseTokenororiginalTransactionId, so out-of-order events land on one record. - Every refund side effect, revoke, deduct, or reverse, is safe to run more than once.
- Your handler writes the event before it acknowledges, never after.
- Repeated CONSUMPTION_REQUESTs are treated as distinct prompts, not duplicates, so no open refund window is dropped.
Run a duplicate through your own webhook on purpose and watch it change nothing the second time. That is the whole test. A refund handler that is safe to hit twice is one you can stop worrying about the moment a store decides to hit it six times.
Frequently asked questions
- Why does my server receive the same App Store refund notification more than once?
- Because Apple retries an App Store Server Notification V2 up to five times, at 1, 12, 24, 48, and 72 hours after the last attempt, whenever your server does not respond with an HTTP status between 200 and 206. Every retry carries the same notificationUUID, so you can recognize and skip it.
- What field should I use to deduplicate App Store Server Notifications?
- Use the notificationUUID. A true retry always repeats the same notificationUUID, while every genuinely new event, including each fresh CONSUMPTION_REQUEST, gets a different one, so deduplicating on notificationUUID skips repeats without dropping distinct events.
- Are repeated CONSUMPTION_REQUEST notifications duplicates I should ignore?
- No. Apple sends new CONSUMPTION_REQUEST notifications periodically over the open refund window, and Apple staff confirm these are not retries. Each has its own notificationUUID, so process every one. Dropping them risks missing the 12-hour window Apple gives you to respond.
- How do I deduplicate Google Play Real-time Developer Notifications?
- Read the Pub/Sub messageId from each notification and check it against the ones you have already processed before acting, because Pub/Sub delivers at-least-once and can send the same message more than once. Google recommends this explicitly to avoid duplicate processing and wasted API quota.
- Should I return an error to reject a duplicate refund notification?
- No. Returning a 4xx or 5xx tells the store the delivery failed, so it retries anyway. Deduplicate inside your own database and always return a success status, HTTP 200 to 206 for Apple or a 200 acknowledgement for the Google Play push.
Sources and further reading
- 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
The refund autopilot for the App Store and Google Play
Keep reading
A Family Sharing refund reverses one payment but can leave five other people still using your app, and only your server can cut them off
A Family Sharing refund reverses one payment but can leave up to five family members still on your paid features. Apple sends a REVOKE and expects your server to end the access. Here is how family-shared refunds work and what one costs.
Refund handling breaks in quiet ways, so test in-app purchase refunds in the sandbox before a real customer does
Your refund handling only runs after a customer is already gone, so a bug in it stays invisible until it costs real money. Both stores let you fire a refund in a test environment first. Here is how to test in-app purchase refunds on the App Store and Google Play before one is real.