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.

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 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.
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: voidedPurchaseNotification and
2// pendingRefundReviewNotification
3// Fires when a purchase is voided (refunded, charged back or canceled), and when a chargeback
4// request needs your review within 24 hours. Delivered through a Cloud Pub/Sub push subscription.
5// Docs: https://developer.android.com/google/play/billing/rtdn-reference
6
7const PRODUCT_TYPES = {
8 1: "A subscription purchase",
9 2: "A one-time purchase",
10}
11
12// ---- Decoding the Pub/Sub push -------------------------------------------------------------
13// Pub/Sub wraps the notification: {"message": {"data": "<base64 JSON>", …}, "subscription": …}.
14// Studio may not have atob or Buffer, so there's a small base64 + UTF-8 decoder as a fallback.
15
16const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
17
18// Base64 (standard or URL-safe, with or without padding) to an array of bytes, or null.
19function base64Bytes(input) {
20 const clean = input.replace(/\s+/g, "").replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/")
21 if (!/^[A-Za-z0-9+/]*$/.test(clean)) return null
22
23 if (typeof atob === "function") {
24 try {
25 const binary = atob(clean + "===".slice((clean.length + 3) % 4))
26 return Array.from(binary, (c) => c.charCodeAt(0))
27 } catch {
28 // fall through to the pure-JS decoder
29 }
30 }
31
32 const bytes = []
33 let buffer = 0
34 let bits = 0
35 for (const char of clean) {
36 buffer = ((buffer << 6) | B64.indexOf(char)) & 0xffffff
37 bits += 6
38 if (bits >= 8) {
39 bits -= 8
40 bytes.push((buffer >> bits) & 0xff)
41 }
42 }
43 return bytes
44}
45
46// UTF-8 bytes to a string (so app names or SKUs with accents or emoji survive).
47function utf8(bytes) {
48 let out = ""
49 for (let i = 0; i < bytes.length; ) {
50 const b = bytes[i++]
51 if (b < 0x80) {
52 out += String.fromCharCode(b)
53 } else if (b >= 0xc0 && b < 0xe0) {
54 out += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i++] & 0x3f))
55 } else if (b >= 0xe0 && b < 0xf0) {
56 out += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f))
57 } else if (b >= 0xf0 && b < 0xf8) {
58 const cp = ((b & 0x07) << 18) | ((bytes[i++] & 0x3f) << 12) | ((bytes[i++] & 0x3f) << 6) | (bytes[i++] & 0x3f)
59 out += cp <= 0x10ffff ? String.fromCodePoint(cp) : "�"
60 } else {
61 out += "�"
62 }
63 }
64 return out
65}
66
67function decodeBase64(input) {
68 if (typeof Buffer === "function" && typeof Buffer.from === "function") {
69 try {
70 return Buffer.from(input, "base64").toString("utf8")
71 } catch {
72 // fall through
73 }
74 }
75 const bytes = base64Bytes(input)
76 return bytes ? utf8(bytes) : null
77}
78
79// The Play DeveloperNotification from the request, or null.
80function notification(request) {
81 const body = request.body && typeof request.body === "object" ? request.body : {}
82
83 // A Pub/Sub subscription with "payload unwrapping" turned on sends the JSON as-is.
84 if (typeof body.packageName === "string") return body
85
86 const data = body.message?.data
87 if (typeof data !== "string" || !data) return null
88
89 try {
90 const text = decodeBase64(data)
91 const parsed = text ? JSON.parse(text) : null
92 return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null
93 } catch {
94 return null
95 }
96}
97
98// ---- Small helpers -------------------------------------------------------------------------
99
100// Table lookup that ignores inherited names such as "toString".
101function pick(table, key, fallback) {
102 return key != null && Object.prototype.hasOwnProperty.call(table, String(key)) ? table[String(key)] : fallback
103}
104
105// Only link to real http(s) URLs.
106function link(url, cta) {
107 return typeof url === "string" && /^https?:\/\//.test(url) ? [{ cta, url }] : []
108}
109
110// Short, safe identifiers (package names, product IDs, order IDs) only.
111function clean(value) {
112 return typeof value === "string" && /^[A-Za-z0-9_.:-]{1,150}$/.test(value) ? value : null
113}
114
115function flag(query, name, values) {
116 return values.includes(String(query?.[name] ?? "").toLowerCase())
117}
118
119// Play Console's "Send test message"; confirm the connection quietly.
120function connected(dev) {
121 return {
122 title: "🔔 Google Play connected",
123 message: `Test notification received${clean(dev.packageName) ? ` for ${dev.packageName}` : ""}`,
124 topic: "Google Play",
125 priority: -1,
126 }
127}
128
129function handleRequest(request) {
130 const dev = notification(request)
131
132 if (dev?.testNotification) return connected(dev)
133
134 const app = clean(dev?.packageName) ?? "your app"
135
136 // A chargeback you can respond to: suggest a resolution with the ReviewRefund API within 24 hours.
137 const review = dev?.pendingRefundReviewNotification
138 if (review && typeof review === "object") {
139 const order = clean(review.orderId)
140 return {
141 title: "🧾 Chargeback needs your review",
142 message: `${order ? `Order ${order}` : "An order"} in ${app} has a chargeback request. Respond with the ReviewRefund API within 24 hours.`,
143 topic: "Google Play",
144 priority: 1, // High: there's a 24-hour deadline
145 buttons: link("https://play.google.com/console", "Open Play Console"),
146 }
147 }
148
149 // Guard: only voided purchases (and the review above), so other notifications stay quiet here.
150 const voided = dev?.voidedPurchaseNotification
151 if (!voided || typeof voided !== "object") {
152 console.log("Ignoring Google Play notification: not a voided purchase")
153 return null
154 }
155
156 const what = pick(PRODUCT_TYPES, voided.productType, "A purchase")
157 const order = clean(voided.orderId)
158 const partial = Number(voided.refundType) === 2
159
160 return {
161 title: partial ? "↩️ Purchase partly refunded" : "↩️ Purchase voided",
162 message:
163 what +
164 (order ? ` (${order})` : "") +
165 ` in ${app} ` +
166 (partial ? "was partly refunded by quantity." : "was refunded, charged back or canceled."),
167 topic: "Google Play",
168 priority: 0, // Normal: money going out that you may not have started yourself
169 buttons: link("https://play.google.com/console", "Open Play Console"),
170 }
171}
Payload

The Google Play voidedPurchaseNotification 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.

voidedPurchaseNotification · sample.json
1{
2 "message": {
3 "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzkwNjY5MjAwNTEyIiwidm9pZGVkUHVyY2hhc2VOb3RpZmljYXRpb24iOnsicHVyY2hhc2VUb2tlbiI6IlBVUkNIQVNFX1RPS0VOIiwib3JkZXJJZCI6IkdQQS4xMjM0LTU2NzgtOTAxMi0zNDU2NyIsInByb2R1Y3RUeXBlIjoxLCJyZWZ1bmRUeXBlIjoxfX0=",
4 "messageId": "136969346947",
5 "attributes": [],
6 "message_id": "136969346947",
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 refund or chargeback 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.