All articles
Deep dive8 min read

Verify App Store server notifications before you act on a refund, because anyone can post to your webhook URL

A refund notification is just an HTTPS request until you check its signature. Here's how to verify App Store server notifications and Google Play RTDN messages, what each store signs, and what a forged or wrongly rejected refund costs you.

A brass wax seal stamp and a sealed envelope beside a laptop on a desk in warm lamp light, a picture of why you verify App Store server notifications before trusting them

Key takeaways

  • Apple sends every version 2 App Store server notification as a signedPayload, a JSON Web Signature with three Base64URL parts: a header, a payload and a signature.
  • The JWS header of an App Store server notification carries an x5c certificate chain of three certificates: a signing leaf, an Apple intermediate with the Apple Worldwide Developer Relations extension 1.2.840.113635.100.6.2.1, and an Apple root.
  • Apple's App Store Server Library verifies notifications with a SignedDataVerifier that checks the certificate chain, the bundle ID, the environment and, in production, the appAppleId, and can check certificate revocation online.
  • Google Play Real-time developer notifications are not signed by Google Play. On a push subscription, Pub/Sub can attach a signed OpenID Connect token in the Authorization header, and your server checks its signature, audience and email claims.
  • Google says an RTDN only tells you that a purchase state changed, so the safe response to any refund notification is to confirm it with the Google Play Developer API before revoking access.
  • Apple sends App Store server notifications from the 17.0.0.0/8 subnet, but an IP allow list only narrows who can reach your endpoint. The signature is what proves the message came from Apple.
  • A verifier that wrongly rejects genuine messages is as costly as one that accepts forgeries, because a CONSUMPTION_REQUEST you drop has a 12-hour response window and a Google Play chargeback review has 24 hours.

Anyone who learns your webhook URL can send it a refund. That's the uncomfortable fact behind every App Store server notification and every Google Play Real-time developer notification: until you verify it, the message is just an HTTPS POST with some JSON in it. If your server revokes a subscription, claws back credits or bans an account because a request said REFUND, you've handed that power to whoever can write a curl command.

Both stores give you a way to check. Apple signs the notification itself, so you can verify App Store server notifications with nothing but Apple's root certificate. Google Play takes a different route. The message isn't signed, but the Pub/Sub delivery can be, and Google tells you to confirm every change with its API anyway. Here's what each one proves, and what it costs when you get it wrong in either direction.

What each store actually signs

QuestionApp Store Server Notifications V2Google Play RTDN
Is the message itself signed?Yes, signedPayload is a JWSNo, the data field is base64-encoded JSON
What proves the senderx5c certificate chain to an Apple rootPub/Sub OIDC token on push, or your own authenticated pull
Nested signed objectssignedTransactionInfo and signedRenewalInfo, each its own JWSNone
Fields to match to your appbundleId, appAppleId, environmentpackageName
Source network17.0.0.0/8, sandbox and productionGoogle Cloud Pub/Sub
Confirm before actingOptional, the signed data is the recordRequired, Google says to call the Developer API
TransportHTTPS, TLS 1.2 or laterHTTPS push, or client library pull

How to verify App Store server notifications

Apple's documentation describes the signedPayload as a string of three Base64URL-encoded components separated by periods: a header, a payload and a signature, signed according to the JSON Web Signature specification. Decoding the payload is easy. Trusting it takes four checks.

  • Read the x5c array from the decoded header. Apple documents the order: first the certificate with the signing key, then an Apple intermediate certificate, then an Apple root certificate.
  • Validate that chain against an Apple root you downloaded yourself from Apple's PKI site, in practice Apple Root CA - G3. Never trust the root that arrived inside the message, because a forger can put any certificate there.
  • Confirm the intermediate carries the Apple Worldwide Developer Relations extension, 1.2.840.113635.100.6.2.1, then verify the JWS signature with the leaf certificate's public key.
  • Check the decoded data against your app: bundleId, appAppleId in production, and environment. A valid Apple signature on another developer's app, or on a sandbox purchase hitting your production endpoint, still isn't your refund.

Then do it again for what's inside. The data object holds signedTransactionInfo and signedRenewalInfo, and each is a separate JWS with its own x5c chain. A REFUND handler that verifies the envelope but reads the transaction without verifying it has left a gap in the middle.

Use Apple's library instead of writing it yourself

Apple publishes the App Store Server Library for Swift, Java, Python and Node. Its SignedDataVerifier takes your root certificates, an online-checks flag, the environment, your bundle ID and, for production, your appAppleId. The Node source shows what it enforces: exactly three certificates in the chain, the Apple leaf extension 1.2.840.113635.100.6.11.1 and the intermediate extension, certificate validity dates, and a matching bundle ID, app ID and environment. With online checks on, it also asks the OCSP responder whether the leaf and intermediate certificates were revoked.

A jeweler's loupe held over a paper certificate with an embossed seal on a wooden desk, the kind of close inspection you give a refund message before trusting it

Is an IP allow list enough?

No. Apple says that if your server needs an allow list, you should add the 17.0.0.0/8 subnet, and it applies to both sandbox and production. That cuts noise. It doesn't prove a message is genuine, and it breaks the moment Apple's network changes in a way you didn't hear about. Keep the signature check as the real gate.

How to verify Google Play refund notifications

Google Play publishes RTDN messages to a Pub/Sub topic you own, and only the google-play-developer-notifications@system.gserviceaccount.com service account should hold the Pub/Sub Publisher role on it. Your subscription then delivers messages one of two ways, and each has its own proof.

  • Pull subscription: your server opens an authenticated connection to Pub/Sub and fetches messages. There's no public endpoint, so there's nothing for a stranger to post to.
  • Push subscription with authentication turned on: Pub/Sub signs a JSON Web Token and sends it in the Authorization header as a Bearer token. Google's guide says to validate the token's signature and check that its email and audience claims match the values you configured, and its sample code also requires email_verified to be true. The token can be up to an hour old.
  • Push subscription without authentication: anything that can reach the URL can send a message shaped like an RTDN.

Why Google wants you to confirm with the API

Even a perfectly authenticated RTDN doesn't carry the full story. Google's setup guide says plainly that these notifications tell you only that a purchase state changed and that you must call the Google Play Developer API to get the complete status. For a voidedPurchaseNotification, that means finding the purchase in the Voided Purchases API, or fetching its current state, before you revoke anything. The API call is authenticated with your own service account, so its answer is one a forger can't fake.

What a forged or rejected refund costs in money

A forged refund notification that you trust costs you a paying customer. You lock out someone whose money you still have, they open a support ticket, and some will go to the store and ask for the refund your system already pretended to grant. A forged purchase or renewal costs the other way: you deliver GPU compute, paid third-party API calls, storage and creator payouts against revenue that never existed.

The opposite failure is quieter and often bigger. A verifier that rejects genuine messages, because of a wrong appAppleId, a sandbox setting in production or an outdated root certificate, drops the two refund flows where your evidence counts. Apple asks for consumption data within 12 hours of a CONSUMPTION_REQUEST. Google gives you 24 hours to answer a chargeback review through orders.reviewrefund, and for orders placed after August 3, 2026, a lost Google Play chargeback costs you the purchase price less Play's service fee, plus the fee the buyer's bank charges.

FailureWhat happensWhat it costs
Forged REFUND acceptedA paying customer loses accessSupport time and a real refund request you invited
Forged purchase acceptedFree access to paid featuresCompute, API calls, storage and payouts with no revenue
Genuine CONSUMPTION_REQUEST rejectedApple decides without your dataThe sale, plus everything already spent delivering it
Genuine chargeback review rejectedGoogle decides without your evidencePurchase price and bank fees on orders after August 3, 2026

Don't let a verification bug eat a deadline

  • Store the raw request before you verify it, so a rejected message is never truly lost.
  • Alert on every verification failure. One forged message is noise. Every message failing at once is your own bug.
  • On Apple, you can replay what you rejected. Get Notification History returns the original signedPayload for the past 180 days, so fix the verifier and run the stored or recovered payloads through it again.
  • On Google, a pending chargeback review lives only in its notification, so a misconfigured push audience is a missed review. Test it with Send Test Message in Play Console after every change.

RefundHalt verifies both stores' notifications, confirms refunds with the store APIs before anything changes, and answers CONSUMPTION_REQUEST and chargeback reviews inside their windows, so a fake message can't cost you a customer and a real one can't slip past.

Frequently asked questions

How do I verify an App Store server notification?
Verify the signedPayload as a JSON Web Signature. Read the x5c certificate chain from its header, validate it against an Apple root certificate you downloaded from Apple's PKI site, verify the signature with the leaf certificate's key, then check that the bundleId, appAppleId and environment match your app. Apple's App Store Server Library does all of this in its SignedDataVerifier.
Which key do I use to validate App Store server notifications?
You don't need a key from App Store Connect. The public key is in the first certificate of the x5c chain inside the JWS header, and you trust it only after validating the chain up to an Apple root certificate you obtained yourself. Your In-App Purchase key is for calling Apple's APIs, not for checking notifications.
Are Google Play Real-time developer notifications signed?
No, Google Play doesn't sign the RTDN message itself. On an authenticated push subscription, Pub/Sub sends a signed OpenID Connect token in the Authorization header that you validate, and Google says to call the Google Play Developer API after every notification to get the purchase's complete status.
Should my server return an error when a notification fails verification?
Store the raw request first, then decide. Apple retries a version 2 notification after a 40x or 50x response, at 1, 12, 24, 48 and 72 hours after the previous attempt. If the failure is your own configuration, Get Notification History lets you recover the original signedPayload for 180 days and verify it again once fixed.
Do I still need to verify notifications if I allow only Apple's IP range?
Yes. Apple asks servers with an allow list to accept the 17.0.0.0/8 subnet, which limits who can connect but doesn't prove a message is genuine. The JWS signature is the check that ties a notification to Apple and to your app.

Sources and further reading

RefundHalt

The refund autopilot for the App Store and Google Play

Keep reading

The next refund request is already on its way.

Set up RefundHalt in the time it takes to read another support email about a refund you didn't get to contest.