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.

How it works

Three steps to your first push

STEP 01

Install the recipe

One click in Studio gives you a personal webhook URL.

STEP 02

Paste your URL into Google Play

Add it as a webhook for Get notifications for subscriptions and all voided purchases.

STEP 03

Get a push on your phone

With the text, sound and buttons from the recipe.

Setup

How to set up the Google Play get notifications for subscriptions and all voided purchases webhook

  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 [email protected] the Pub/Sub Publisher role on it.
  3. Add a push subscription to that topic with your webhook URL as the endpoint URL.https://••••••••/•••••••• your personal URL, shown after install
  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 `[email protected]` 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
  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:[email protected] \
  17. --role=roles/pubsub.publisher
  18. gcloud pubsub subscriptions create play-rtdn-justpush \
  19. --topic=play-rtdn --push-endpoint="your webhook URL"
  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/<project-id>/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?renewals=0`
The code

What runs when the webhook arrives

Studio calls handleRequest(request) with the incoming webhook and sends the message it returns. It's yours after install; change anything.

transform.js
1// Google Play → Real-time developer notifications: subscriptionNotification
2// Fires when a subscription's state changes: purchased, renewed, canceled, on hold, in grace
3// period, recovered, paused, revoked, expired, … Delivered through a Cloud Pub/Sub push subscription.
4// Docs: https://developer.android.com/google/play/billing/rtdn-reference
5//
6// RTDNs are deliberately thin: the app, the notification type and a purchase token. There's no
7// price and no customer; you'd need the Play Developer API for those.
8
9// Every documented notificationType, in words, with how loud to be.
10const TYPES = {
11 1: { title: "✅ Subscription recovered", text: "a subscription was recovered from account hold or resumed from pause", priority: -1 },
12 2: { title: "🔁 Subscription renewed", text: "a subscription renewed", priority: -1, renewal: true },
13 3: { title: "👋 Subscription canceled", text: "a subscription was canceled", priority: 0 },
14 4: { title: "🎉 New subscription", text: "a new subscription was purchased", priority: 0, money: true },
15 5: { title: "⛔ Subscription on hold", text: "a subscription went on account hold after a failed payment, so the user has lost access", priority: 1 },
16 6: { title: "⚠️ Subscription in grace period", text: "a renewal payment failed and the subscription is in its grace period", priority: 0 },
17 7: { title: "↩️ Subscription restored", text: "a user restored their canceled subscription before it expired", priority: -1 },
18 8: { title: "💶 Price change confirmed", text: "a user confirmed a subscription price change", priority: -1 },
19 9: { title: "📅 Renewal deferred", text: "a subscription's next renewal was pushed back", priority: -1 },
20 10: { title: "⏸️ Subscription paused", text: "a subscription was paused", priority: -1 },
21 11: { title: "⏸️ Pause schedule changed", text: "a subscription's pause schedule was changed", priority: -1 },
22 12: { title: "🚫 Subscription revoked", text: "a subscription was revoked before it expired", priority: 0 },
23 13: { title: "⌛ Subscription expired", text: "a subscription expired", priority: -1 },
24 17: { title: "🔀 Subscription items changed", text: "an item in a subscription bundle was changed", priority: -1 },
25 18: { title: "🗓️ Cancellation scheduled", text: "an installment subscription was set to cancel at the end of its commitment", priority: 0 },
26 19: { title: "💶 Price change updated", text: "a subscription item's price change details were updated", priority: -1 },
27 20: { title: "🚫 Pending purchase canceled", text: "a pending subscription purchase was canceled", priority: -1 },
28 22: { title: "💶 Price step-up consent updated", text: "a price step-up consent period began, or the user consented", priority: -1 },
29}
30// Priorities: High (1) only for account hold, when a paying user has lost access. Normal (0) for
31// new subscribers, cancellations, grace periods and revocations. Low (-1) for routine changes.
32
33// ---- Decoding the Pub/Sub push -------------------------------------------------------------
34// Pub/Sub wraps the notification: {"message": {"data": "<base64 JSON>", …}, "subscription": …}.
35// Studio may not have atob or Buffer, so there's a small base64 + UTF-8 decoder as a fallback.
36
37const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
38
39// Base64 (standard or URL-safe, with or without padding) to an array of bytes, or null.
40function base64Bytes(input) {
41 const clean = input.replace(/\s+/g, "").replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/")
42 if (!/^[A-Za-z0-9+/]*$/.test(clean)) return null
43
44 if (typeof atob === "function") {
45 try {
46 const binary = atob(clean + "===".slice((clean.length + 3) % 4))
47 return Array.from(binary, (c) => c.charCodeAt(0))
48 } catch {
49 // fall through to the pure-JS decoder
50 }
51 }
52
53 const bytes = []
54 let buffer = 0
55 let bits = 0
56 for (const char of clean) {
57 buffer = ((buffer << 6) | B64.indexOf(char)) & 0xffffff
58 bits += 6
59 if (bits >= 8) {
60 bits -= 8
61 bytes.push((buffer >> bits) & 0xff)
62 }
63 }
64 return bytes
65}
66
67// UTF-8 bytes to a string (so app names or SKUs with accents or emoji survive).
68function utf8(bytes) {
69 let out = ""
70 for (let i = 0; i < bytes.length; ) {
71 const b = bytes[i++]
72 if (b < 0x80) {
73 out += String.fromCharCode(b)
74 } else if (b >= 0xc0 && b < 0xe0) {
75 out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f))
76 } else if (b >= 0xe0 && b < 0xf0) {
77 out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f))
78 } else if (b >= 0xf0 && b < 0xf8) {
79 const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)
80 out += cp <= 0x10ffff ? String.fromCodePoint(cp) : "�"
81 } else {
82 out += "�"
83 }
84 }
85 return out
86}
87
88function decodeBase64(input) {
89 if (typeof Buffer === "function" && typeof Buffer.from === "function") {
90 try {
91 return Buffer.from(input, "base64").toString("utf8")
92 } catch {
93 // fall through
94 }
95 }
96 const bytes = base64Bytes(input)
97 return bytes ? utf8(bytes) : null
98}
99
100// The Play DeveloperNotification from the request, or null.
101function notification(request) {
102 const body = request.body && typeof request.body === "object" ? request.body : {}
103
104 // A Pub/Sub subscription with "payload unwrapping" turned on sends the JSON as-is.
105 if (typeof body.packageName === "string") return body
106
107 const data = body.message?.data
108 if (typeof data !== "string" || !data) return null
109
110 try {
111 const text = decodeBase64(data)
112 const parsed = text ? JSON.parse(text) : null
113 return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
114 } catch {
115 return null
116 }
117}
118
119// ---- Small helpers -------------------------------------------------------------------------
120
121// Table lookup that ignores inherited names such as "toString".
122function pick(table, key, fallback) {
123 return key != null && Object.prototype.hasOwnProperty.call(table, String(key)) ? table[String(key)] : fallback
124}
125
126// Only link to real http(s) URLs.
127function link(url, cta) {
128 return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : []
129}
130
131// Short, safe identifiers (package names, product IDs, order IDs) only.
132function clean(value) {
133 return typeof value === "string" && /^[A-Za-z0-9_.:-]{1,150}$/.test(value) ? value : null
134}
135
136function flag(query, name, values) {
137 return values.includes(String(query?.[name] ?? "").toLowerCase())
138}
139
140// Play Console's "Send test message"; confirm the connection quietly.
141function connected(dev) {
142 return {
143 title: "🔔 Google Play connected",
144 message: `Test notification received${clean(dev.packageName) ? ` for ${dev.packageName}` : ""}`,
145 topic: "Google Play",
146 priority: -1,
147 }
148}
149
150function handleRequest(request) {
151 const dev = notification(request)
152
153 if (dev?.testNotification) return connected(dev)
154
155 // Guard: only subscription notifications, so one-time and voided purchases stay quiet here.
156 const sub = dev?.subscriptionNotification
157 if (!sub || typeof sub !== "object") {
158 console.log("Ignoring Google Play notification: not a subscription notification")
159 return null
160 }
161
162 const type = Number(sub.notificationType)
163 const known = pick(TYPES, Number.isInteger(type) ? type : null, null)
164 const info = known ?? { title: "🔔 Subscription update", text: `subscription notification type ${Number.isInteger(type) ? type : "unknown"}`, priority: -1 }
165
166 // ?renewals=0 on the push endpoint skips routine renewals.
167 if (info.renewal && flag(request.query, "renewals", ["0", "false", "no"])) {
168 console.log("Skipping renewal (renewals=0)")
169 return null
170 }
171
172 const app = clean(dev.packageName) ?? "your app"
173 // subscriptionId was in older versions of the notification; newer ones may leave it out.
174 const product = clean(sub.subscriptionId)
175
176 return {
177 title: info.title,
178 message: `${app}${product ? ` · ${product}` : ""}: ${info.text}.`,
179 topic: "Google Play",
180 priority: info.priority,
181 ...(info.money ? { sound: "cashregister" } : {}),
182 buttons: link("https://play.google.com/console", "Open Play Console"),
183 }
184}
Payload

The Google Play subscriptionNotification webhook

This is what Google Play sends to your URL for get notifications for subscriptions and all voided purchases. It is a sample, trimmed to the fields recipes use.

subscriptionNotification · sample.json
1{
2 "message": {
3 "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzkwNjY5MjAwNTEyIiwic3Vic2NyaXB0aW9uTm90aWZpY2F0aW9uIjp7InZlcnNpb24iOiIxLjAiLCJub3RpZmljYXRpb25UeXBlIjo1LCJwdXJjaGFzZVRva2VuIjoiUFVSQ0hBU0VfVE9LRU4ifX0=",
4 "messageId": "136969346945",
5 "attributes": [],
6 "message_id": "136969346945",
7 "publishTime": "2026-09-29T08:06:40.512Z",
8 "publish_time": "2026-09-29T08:06:40.512Z"
9 },
10 "subscription": "projects/my-project/subscriptions/play-rtdn-justpush"
11}
FAQ

Google Play subscription events notifications: questions

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.

Ready when you are

Google Play on your phone in two minutes.

Install the recipe, paste one URL, done. Free for 30 days, no credit card required.