# 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.

- Company: Google Play (https://www.justpush.io/recipes/google-play)
- Event: `oneTimeProductNotification` (Get all notifications for subscriptions and one-time products)
- Tags: Mobile apps, Payments
- Install: https://studio.justpush.io/recipes/google-play/one-time-purchase
- Web page: https://www.justpush.io/recipes/google-play/one-time-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 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 `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/<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 (shown in Studio after install)?pending=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: oneTimeProductNotification
// Fires when a user buys a one-time product (in-app purchase), or cancels a pending one.
// Delivered through a Cloud Pub/Sub push subscription.
// Docs: https://developer.android.com/google/play/billing/rtdn-reference
//
// Only sent if you chose "Get all notifications for subscriptions and one-time products" in
// Play Console. RTDNs are thin: the app, the product ID (sku) and a purchase token; no price, no customer.

// Every documented notificationType, in words, with how loud to be.
const TYPES = {
    1: { title: "🛒 In-app purchase", text: "bought", priority: 0, money: true }, // Normal: a sale
    2: { title: "🚫 Pending purchase canceled", text: "pending purchase canceled", priority: -1 }, // Low: never paid
}

// ---- Decoding the Pub/Sub push -------------------------------------------------------------
// Pub/Sub wraps the notification: {"message": {"data": "<base64 JSON>", …}, "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 one-time product notifications, so subscriptions and voided purchases stay quiet here.
    const otp = dev?.oneTimeProductNotification
    if (!otp || typeof otp !== "object") {
        console.log("Ignoring Google Play notification: not a one-time product notification")
        return null
    }

    const type = Number(otp.notificationType)
    const info = pick(TYPES, Number.isInteger(type) ? type : null, null)
    if (!info) {
        console.log(`Skipping one-time product notification type ${otp.notificationType}`)
        return null
    }

    // ?pending=0 on the push endpoint skips canceled pending purchases.
    if (type === 2 && flag(request.query, "pending", ["0", "false", "no"])) {
        console.log("Skipping canceled pending purchase (pending=0)")
        return null
    }

    const app = clean(dev.packageName) ?? "your app"
    const product = clean(otp.sku)

    return {
        title: info.title,
        message: `${product ?? "A product"} ${info.text} in ${app}.`,
        topic: "Google Play",
        priority: info.priority,
        ...(info.money ? { sound: "cashregister" } : {}),
        buttons: link("https://play.google.com/console", "Open Play Console"),
    }
}
```

## Google Play oneTimeProductNotification webhook payload (sample)

```json
{
  "message": {
    "data": "eyJ2ZXJzaW9uIjoiMS4wIiwicGFja2FnZU5hbWUiOiJjb20uZXhhbXBsZS5hcHAiLCJldmVudFRpbWVNaWxsaXMiOiIxNzkwNjY5MjAwNTEyIiwib25lVGltZVByb2R1Y3ROb3RpZmljYXRpb24iOnsidmVyc2lvbiI6IjEuMCIsIm5vdGlmaWNhdGlvblR5cGUiOjEsInB1cmNoYXNlVG9rZW4iOiJQVVJDSEFTRV9UT0tFTiIsInNrdSI6InJlbW92ZV9hZHMifX0=",
    "messageId": "136969346946",
    "attributes": [],
    "message_id": "136969346946",
    "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.
