Google Play in-app purchase notifications on your phone.

Get a push notification when someone buys a one-time product (an in-app purchase) in your Google Play app.

oneTimeProductNotificationMobile appsPayments
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 all notifications for subscriptions and one-time products.

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

The Google Play oneTimeProductNotification webhook

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

oneTimeProductNotification · sample.json
1{
2 "message": {
3 "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzkwNjY5MjAwNTEyIiwib25lVGltZVByb2R1Y3ROb3RpZmljYXRpb24iOnsidmVyc2lvbiI6IjEuMCIsIm5vdGlmaWNhdGlvblR5cGUiOjEsInB1cmNoYXNlVG9rZW4iOiJQVVJDSEFTRV9UT0tFTiIsInNrdSI6InJlbW92ZV9hZHMifX0=",
4 "messageId": "136969346946",
5 "attributes": [],
6 "message_id": "136969346946",
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 in-app purchase 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.