# Google Play subscription events notifications on your phone > Get a push notification when a Google Play subscription is purchased, renewed, canceled, put on hold, enters a grace period, recovers, is revoked or expires. - Company: Google Play (https://www.justpush.io/recipes/google-play) - Event: `subscriptionNotification` (Get notifications for subscriptions and all voided purchases) - Tags: Mobile apps, Failed payments & disputes, Subscriptions - Install: https://studio.justpush.io/recipes/google-play/subscription-events - Web page: https://www.justpush.io/recipes/google-play/subscription-events ## 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 **Get notifications for subscriptions and all voided purchases** (or **all notifications**) 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. Renewals can be frequent. To skip them, add `?renewals=0` to the push endpoint URL: `your webhook URL (shown in Studio after install)?renewals=0` ## 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: subscriptionNotification // Fires when a subscription's state changes: purchased, renewed, canceled, on hold, in grace // period, recovered, paused, revoked, expired, … Delivered through a Cloud Pub/Sub push subscription. // Docs: https://developer.android.com/google/play/billing/rtdn-reference // // RTDNs are deliberately thin: the app, the notification type and a purchase token. There's no // price and no customer; you'd need the Play Developer API for those. // Every documented notificationType, in words, with how loud to be. const TYPES = { 1: { title: "βœ… Subscription recovered", text: "a subscription was recovered from account hold or resumed from pause", priority: -1 }, 2: { title: "πŸ” Subscription renewed", text: "a subscription renewed", priority: -1, renewal: true }, 3: { title: "πŸ‘‹ Subscription canceled", text: "a subscription was canceled", priority: 0 }, 4: { title: "πŸŽ‰ New subscription", text: "a new subscription was purchased", priority: 0, money: true }, 5: { title: "β›” Subscription on hold", text: "a subscription went on account hold after a failed payment, so the user has lost access", priority: 1 }, 6: { title: "⚠️ Subscription in grace period", text: "a renewal payment failed and the subscription is in its grace period", priority: 0 }, 7: { title: "↩️ Subscription restored", text: "a user restored their canceled subscription before it expired", priority: -1 }, 8: { title: "πŸ’Ά Price change confirmed", text: "a user confirmed a subscription price change", priority: -1 }, 9: { title: "πŸ“… Renewal deferred", text: "a subscription's next renewal was pushed back", priority: -1 }, 10: { title: "⏸️ Subscription paused", text: "a subscription was paused", priority: -1 }, 11: { title: "⏸️ Pause schedule changed", text: "a subscription's pause schedule was changed", priority: -1 }, 12: { title: "🚫 Subscription revoked", text: "a subscription was revoked before it expired", priority: 0 }, 13: { title: "βŒ› Subscription expired", text: "a subscription expired", priority: -1 }, 17: { title: "πŸ”€ Subscription items changed", text: "an item in a subscription bundle was changed", priority: -1 }, 18: { title: "πŸ—“οΈ Cancellation scheduled", text: "an installment subscription was set to cancel at the end of its commitment", priority: 0 }, 19: { title: "πŸ’Ά Price change updated", text: "a subscription item's price change details were updated", priority: -1 }, 20: { title: "🚫 Pending purchase canceled", text: "a pending subscription purchase was canceled", priority: -1 }, 22: { title: "πŸ’Ά Price step-up consent updated", text: "a price step-up consent period began, or the user consented", priority: -1 }, } // Priorities: High (1) only for account hold, when a paying user has lost access. Normal (0) for // new subscribers, cancellations, grace periods and revocations. Low (-1) for routine changes. // ---- 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) // Guard: only subscription notifications, so one-time and voided purchases stay quiet here. const sub = dev?.subscriptionNotification if (!sub || typeof sub !== "object") { console.log("Ignoring Google Play notification: not a subscription notification") return null } const type = Number(sub.notificationType) const known = pick(TYPES, Number.isInteger(type) ? type : null, null) const info = known ?? { title: "πŸ”” Subscription update", text: `subscription notification type ${Number.isInteger(type) ? type : "unknown"}`, priority: -1 } // ?renewals=0 on the push endpoint skips routine renewals. if (info.renewal && flag(request.query, "renewals", ["0", "false", "no"])) { console.log("Skipping renewal (renewals=0)") return null } const app = clean(dev.packageName) ?? "your app" // subscriptionId was in older versions of the notification; newer ones may leave it out. const product = clean(sub.subscriptionId) return { title: info.title, message: `${app}${product ? ` Β· ${product}` : ""}: ${info.text}.`, topic: "Google Play", priority: info.priority, ...(info.money ? { sound: "cashregister" } : {}), buttons: link("https://play.google.com/console", "Open Play Console"), } } ``` ## Google Play subscriptionNotification webhook payload (sample) ```json { "message": { "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzkwNjY5MjAwNTEyIiwic3Vic2NyaXB0aW9uTm90aWZpY2F0aW9uIjp7InZlcnNpb24iOiIxLjAiLCJub3RpZmljYXRpb25UeXBlIjo1LCJwdXJjaGFzZVRva2VuIjoiUFVSQ0hBU0VfVE9LRU4ifX0=", "messageId": "136969346945", "attributes": [], "message_id": "136969346945", "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.