# Google Play refund or chargeback notifications on your phone > Get a push notification when a Google Play purchase is refunded, charged back or canceled, and a High-priority one when a chargeback needs your review. - Company: Google Play (https://www.justpush.io/recipes/google-play) - Event: `voidedPurchaseNotification` (Get notifications for subscriptions and all voided purchases) - Tags: Mobile apps, Failed payments & disputes, Refunds - Install: https://studio.justpush.io/recipes/google-play/voided-purchase - Web page: https://www.justpush.io/recipes/google-play/voided-purchase ## Setup 1. Click Install in Studio and sign in. The recipe is added to your account and you get a personal webhook URL. 2. In Google Cloud, create a Pub/Sub topic and give google-play-developer-notifications@system.gserviceaccount.com the Pub/Sub Publisher role on it. 3. Add a push subscription to that topic with your webhook URL (shown in Studio after install) as the endpoint URL. 4. In Play Console, open your app's Monetize > Monetization setup, enable real-time notifications, enter the full topic name and choose Get notifications for subscriptions and all voided purchases. 5. Click Send test message; you should get a quiet Google Play connected push. 6. Google Play doesn't call webhooks directly. It publishes **real-time developer notifications** (RTDN) to a Google Cloud Pub/Sub topic, and a Pub/Sub **push subscription** forwards each one to JustPush. 7. **1. Create the topic and let Google Play publish to it.** In the [Google Cloud console](https://console.cloud.google.com/cloudpubsub/topic/list), open **Pub/Sub → Topics → Create topic**. Open the topic's **Permissions**, click **Add principal**, enter `google-play-developer-notifications@system.gserviceaccount.com` and give it the **Pub/Sub Publisher** role. (If your organization restricts sharing by domain, it needs an exception for that account.) 8. **2. Push it to JustPush.** On the topic, click **Create subscription**, set **Delivery type** to **Push** and paste this as the **Endpoint URL**: 9. ``` 10. your webhook URL (shown in Studio after install) 11. ``` 12. Leave **payload unwrapping** and authentication off. Or do steps 1 and 2 with `gcloud`: 13. ``` 14. gcloud pubsub topics create play-rtdn 15. gcloud pubsub topics add-iam-policy-binding play-rtdn \ 16. --member=serviceAccount:google-play-developer-notifications@system.gserviceaccount.com \ 17. --role=roles/pubsub.publisher 18. gcloud pubsub subscriptions create play-rtdn-justpush \ 19. --topic=play-rtdn --push-endpoint="your webhook URL (shown in Studio after install)" 20. ``` 21. **3. Point Play Console at the topic.** In [Play Console](https://play.google.com/console), select your app and open **Monetize → Monetization setup**. Under **Real-time developer notifications**, tick **Enable real-time notifications**, enter the full topic name (`projects//topics/play-rtdn`), choose either notification option (both include voided purchases) and click **Save changes**. Then click **Send test message**: you'll get a quiet **🔔 Google Play connected** push. 22. Each app is set up separately, but several apps can share one topic. Each Google Play recipe is its own integration with its own push subscription on the same topic. 23. **The pushes are thin, on purpose.** RTDNs only say *that* something changed: the app's package name, the notification type and a purchase token. There's no price, no customer and no country; getting those means calling the Play Developer API, which a recipe can't do. 24. Pub/Sub retries a push until JustPush accepts it. Its push requests can carry a signed Google token, but JustPush doesn't check it (`verify: none`), so keep your endpoint URL private. 25. This recipe also handles **chargeback reviews** (`pendingRefundReviewNotification`): when a user files a chargeback that you can respond to, you get a High-priority push, because you have 24 hours to suggest a resolution with the ReviewRefund API. ## Code Studio calls `handleRequest(request)` with the incoming webhook (`{ method, headers, body, query, raw }`) and sends the message object it returns. Returning `null` sends nothing. ```js // Google Play → Real-time developer notifications: voidedPurchaseNotification and // pendingRefundReviewNotification // Fires when a purchase is voided (refunded, charged back or canceled), and when a chargeback // request needs your review within 24 hours. Delivered through a Cloud Pub/Sub push subscription. // Docs: https://developer.android.com/google/play/billing/rtdn-reference const PRODUCT_TYPES = { 1: "A subscription purchase", 2: "A one-time purchase", } // ---- Decoding the Pub/Sub push ------------------------------------------------------------- // Pub/Sub wraps the notification: {"message": {"data": "", …}, "subscription": …}. // Studio may not have atob or Buffer, so there's a small base64 + UTF-8 decoder as a fallback. const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" // Base64 (standard or URL-safe, with or without padding) to an array of bytes, or null. function base64Bytes(input) { const clean = input.replace(/\s+/g, "").replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/") if (!/^[A-Za-z0-9+/]*$/.test(clean)) return null if (typeof atob === "function") { try { const binary = atob(clean + "===".slice((clean.length + 3) % 4)) return Array.from(binary, (c) => c.charCodeAt(0)) } catch { // fall through to the pure-JS decoder } } const bytes = [] let buffer = 0 let bits = 0 for (const char of clean) { buffer = ((buffer << 6) | B64.indexOf(char)) & 0xffffff bits += 6 if (bits >= 8) { bits -= 8 bytes.push((buffer >> bits) & 0xff) } } return bytes } // UTF-8 bytes to a string (so app names or SKUs with accents or emoji survive). function utf8(bytes) { let out = "" for (let i = 0; i < bytes.length; ) { const b = bytes[i++] if (b < 0x80) { out += String.fromCharCode(b) } else if (b >= 0xc0 && b < 0xe0) { out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f)) } else if (b >= 0xe0 && b < 0xf0) { out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)) } else if (b >= 0xf0 && b < 0xf8) { const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f) out += cp <= 0x10ffff ? String.fromCodePoint(cp) : "�" } else { out += "�" } } return out } function decodeBase64(input) { if (typeof Buffer === "function" && typeof Buffer.from === "function") { try { return Buffer.from(input, "base64").toString("utf8") } catch { // fall through } } const bytes = base64Bytes(input) return bytes ? utf8(bytes) : null } // The Play DeveloperNotification from the request, or null. function notification(request) { const body = request.body && typeof request.body === "object" ? request.body : {} // A Pub/Sub subscription with "payload unwrapping" turned on sends the JSON as-is. if (typeof body.packageName === "string") return body const data = body.message?.data if (typeof data !== "string" || !data) return null try { const text = decodeBase64(data) const parsed = text ? JSON.parse(text) : null return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null } catch { return null } } // ---- Small helpers ------------------------------------------------------------------------- // Table lookup that ignores inherited names such as "toString". function pick(table, key, fallback) { return key != null && Object.prototype.hasOwnProperty.call(table, String(key)) ? table[String(key)] : fallback } // Only link to real http(s) URLs. function link(url, cta) { return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : [] } // Short, safe identifiers (package names, product IDs, order IDs) only. function clean(value) { return typeof value === "string" && /^[A-Za-z0-9_.:-]{1,150}$/.test(value) ? value : null } function flag(query, name, values) { return values.includes(String(query?.[name] ?? "").toLowerCase()) } // Play Console's "Send test message"; confirm the connection quietly. function connected(dev) { return { title: "🔔 Google Play connected", message: `Test notification received${clean(dev.packageName) ? ` for ${dev.packageName}` : ""}`, topic: "Google Play", priority: -1, } } function handleRequest(request) { const dev = notification(request) if (dev?.testNotification) return connected(dev) const app = clean(dev?.packageName) ?? "your app" // A chargeback you can respond to: suggest a resolution with the ReviewRefund API within 24 hours. const review = dev?.pendingRefundReviewNotification if (review && typeof review === "object") { const order = clean(review.orderId) return { title: "🧾 Chargeback needs your review", message: `${order ? `Order ${order}` : "An order"} in ${app} has a chargeback request. Respond with the ReviewRefund API within 24 hours.`, topic: "Google Play", priority: 1, // High: there's a 24-hour deadline buttons: link("https://play.google.com/console", "Open Play Console"), } } // Guard: only voided purchases (and the review above), so other notifications stay quiet here. const voided = dev?.voidedPurchaseNotification if (!voided || typeof voided !== "object") { console.log("Ignoring Google Play notification: not a voided purchase") return null } const what = pick(PRODUCT_TYPES, voided.productType, "A purchase") const order = clean(voided.orderId) const partial = Number(voided.refundType) === 2 return { title: partial ? "↩️ Purchase partly refunded" : "↩️ Purchase voided", message: what + (order ? ` (${order})` : "") + ` in ${app} ` + (partial ? "was partly refunded by quantity." : "was refunded, charged back or canceled."), topic: "Google Play", priority: 0, // Normal: money going out that you may not have started yourself buttons: link("https://play.google.com/console", "Open Play Console"), } } ``` ## Google Play voidedPurchaseNotification webhook payload (sample) ```json { "message": { "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzkwNjY5MjAwNTEyIiwidm9pZGVkUHVyY2hhc2VOb3RpZmljYXRpb24iOnsicHVyY2hhc2VUb2tlbiI6IlBVUkNIQVNFX1RPS0VOIiwib3JkZXJJZCI6IkdQQS4xMjM0LTU2NzgtOTAxMi0zNDU2NyIsInByb2R1Y3RUeXBlIjoxLCJyZWZ1bmRUeXBlIjoxfX0=", "messageId": "136969346947", "attributes": [], "message_id": "136969346947", "publishTime": "2026-09-29T08:06:40.512Z", "publish_time": "2026-09-29T08:06:40.512Z" }, "subscription": "projects/my-project/subscriptions/play-rtdn-justpush" } ``` ## FAQ ### Does this work on iPhone and Android? Yes. Install the JustPush app from the App Store or Google Play and sign in. Every phone signed in to your account gets the notification. ### Do I need to write code? No. Install the recipe in Studio, paste your webhook URL into Google Play and you are done. The code is there if you want to change the text, the sound or the buttons. ### Can I change what the notification says? Yes. After install the recipe's code is yours. Edit it in Studio and test it against the sample payload before you save. ### What does it cost? JustPush is free for 30 days. After that it's $19.99 a year, or $39.99 once. There is no extra charge for recipes.