Connect Google Play
Wire your Google Play subscriptions and one-off purchases into Crossdeck. The connect handshake is service-account-based (no OAuth — Google doesn't expose one for Play Developer access), and the runtime data path is Real-time Developer Notifications via Pub/Sub. This doc covers the credentials, the topic config, the cold-start state Google's API leaves us in, and the daily reconciler that fills the structural gaps.
TL;DR
- Two pieces to wire: a service-account JSON Crossdeck uses to read your Play Developer API, and a Pub/Sub topic Google Play publishes RTDN events to. Both happen in the Play Console + Google Cloud Console; Crossdeck stores the credential in Secret Manager and grants Google's notification SA permission to publish to our topic.
- Topic name is
gplay-rtdn, notgoogle-play-rtdn. Pub/Sub forbids resource names starting withgoog, so the obvious name doesn't create. The dashboard copies the full path; pasting it into Play Console is one click. - Connect never half-saves. The backend at
backend/src/auth/google-connect.ts:1-25validates the credentials with a real Play API call (inappproducts.list) before writing anything. A 401 / 403 / 404 from Google means nothing is persisted — you fix the cause and re-submit. - Google has no "list all subscriptions" API — only
purchases.subscriptionsv2.get(packageName, purchaseToken). So a fresh rail connect with no prior data writes acold_start_no_historystate, honestly. History materialises as RTDNs fire, at worst over one billing cycle. See The cold-start state. - A daily reconciler at 03:30 UTC (
backend/src/workers/google-mrr-reconcile.ts:1-40) refetches every revenue-bearing Google subscription to catch RTDN gaps, and opens an alert if your service account looks revoked.
The model
Each rail Crossdeck supports has its own platform-side shape. Knowing how Google Play differs prevents the most common class of "why isn't this working" question:
| Rail | Auth model | Webhook | Bulk history? |
|---|---|---|---|
| Stripe | OAuth (Stripe Connect) | One Crossdeck-side webhook fans out to every OAuth'd account | Yes — subscriptions.list |
| Apple App Store | .p8 key upload + Key ID + Issuer ID | One Apple-side webhook URL per app; sandbox + prod share the URL | ~180 days of notifications, replay-on-demand |
| Google Play | Service-account JSON upload | Pub/Sub topic; we own the topic, Google publishes | No. Per-token get only. |
The "no bulk history" cell is the one that surprises developers used to Stripe. Google's stance is that subscriptions belong to the user-purchase-token relationship, not to a developer's enumerable list. We respect that limitation rather than hide it — see The cold-start state for what happens at first connect.
Before you start
You'll need:
- An Android app published (or at least registered) in the Google Play Console. The package name (Android application ID) must match Play Console exactly — this is the routing key Crossdeck uses to send each RTDN to the right project.
- A Google Cloud Console project you can create a service account in. It does NOT need to be the same Cloud project that backs your Play app — Play Console can grant API access to a service account from any Cloud project.
- Play Console "Account owner" or "Admin" role for the developer account. Granting API access to a service account is restricted; if you only have Developer role you'll see "Setup → API access" but won't be able to grant.
- About 10 minutes the first time. Half of it is waiting for Play Console to finish provisioning the API access link.
You don't need any code changes to your Android app. The Play Billing Library writes purchases to Google; Google sends Crossdeck the events. The Android SDK quickstart covers SDK setup separately if you also want client-side analytics.
Step 1 · Create the service account
In Google Cloud Console:
- Open IAM & Admin → Service Accounts. Pick the Cloud project you want the account to live in (or create one — any project works).
- Click + Create service account. Give it a name like
crossdeck-play-reader. Leave the "Grant access" step empty — Play Console grants the relevant access in Step 2, not Cloud IAM. - On the service account row, click the ⋮ menu → Manage keys → Add key → Create new key → JSON. Your browser downloads a JSON file like
crossdeck-play-reader-abc123.json. Keep it private — it's a credential.
Google does not expose an OAuth flow for the Play Developer API. The official integration shape is a service account whose client_email is added to the Play Console's API access list. Crossdeck holds the JSON in Google Cloud Secret Manager, never in Firestore — see backend/src/auth/google-connect.ts:1-30. The client_email is non-secret and is the only field we display on the connected-rail card so you can confirm at a glance which account is wired.
Step 2 · Grant the service account Play Console access
In Play Console (play.google.com/console):
- Top-level Setup → API access. (Not inside an individual app — at the developer-account level.)
- If this is your first time using the API, you'll see a one-time "Link a Google Cloud project" step. Pick the same Cloud project that holds your service account from Step 1, or any project under the same Google account. Confirm.
- Scroll to the Service accounts table. Find the row matching the
client_emailfrom your JSON (it'll look like[email protected]). Click Grant access. - On the permissions screen, grant — at minimum — "View financial data, orders, and cancellation survey responses" and "Manage orders and subscriptions". The dashboard's connect modal copy at
dashboard/rails/rails.js:1134-1138names these two exactly. Click Apply, then Invite user. - Wait for Play Console to flip the status to Active. Refresh once or twice; it usually takes under a minute.
Crossdeck verifies credentials by calling androidpublisher.inappproducts.list against your packageName before persisting (backend/src/auth/google-connect.ts:1-25). That call requires "View financial data" at minimum. You'll see "Google rejected the credentials" in the modal — go back to the Play Console permissions sheet, confirm both boxes are checked, click Apply again, and re-submit.
Step 3 · Connect Crossdeck
In the dashboard:
- Open Payment rails. You'll see three cards — Stripe, Apple, Google Play.
- On the Google Play card, click Connect Google Play. A modal opens.
- Pick the Environment for this rail: Production for your live Play account, Sandbox for a separate testing-only account if you operate one. (Most teams connect one rail at production; sandbox-only license testers route automatically — see verify.)
- Enter the Package name exactly as it appears in Play Console (reverse-DNS, lowercase, e.g.
com.acme.notes). - Upload the service-account JSON via the file picker, or paste its contents into the textarea (the dashboard accepts both — see
dashboard/rails/rails.js:1237-1265). The modal will preview theclient_emailso you can confirm you uploaded the right file before submitting. - Click Verify & connect. Crossdeck calls
inappproducts.listagainst your packageName using the credentials. On success: the JSON lands in Secret Manager, the non-secret rail record (packageName, client_email, env, verifiedAt) lands on the project doc, and thegooglePackageIndex/{packageName}entry is written for fast RTDN routing.
Success: the modal closes, the rail card flips to "Connected", showing the package name, client_email, environment, and the Pub/Sub topic path (next step).
Failure: the modal stays open with a specific error message — "JSON is missing client_email or private_key", "private_key field is malformed", "Google rejected the credentials (401)", etc. Each maps to a real check in the connect flow at backend/src/auth/google-connect.ts. There is never a partial-save: either the rail record is written and you're connected, or nothing is persisted and the error tells you what to fix.
Step 4 · Wire Real-time Developer Notifications
The connect handshake above gives Crossdeck the credentials to read your Play Developer API. RTDN is the inbound stream — Google pushes purchase events to a Pub/Sub topic we own, and Crossdeck processes each one.
The topic path
Crossdeck owns one Pub/Sub topic that every customer's Play Console publishes to:
projects/crossdeck-47d8f/topics/gplay-rtdn
The connect modal exposes a Copy button — use that to grab the exact string.
gplay-rtdn and not google-play-rtdn?
Pub/Sub forbids resource names that start with the goog prefix (reserved for Google's own systems). The natural-feeling name doesn't create. Our backend codepath documents this explicitly at backend/src/webhooks/google.ts:11-22 — the original name failed to provision and silently broke deploys for weeks until the rename.
Configure RTDN in Play Console
- Back in Play Console, open your app: your app → Monetize → Monetization setup.
- Scroll to Real-time developer notifications and click Edit.
- Tick Send real-time notifications to this topic. Paste the topic path (
projects/crossdeck-47d8f/topics/gplay-rtdn) into the Topic name field. - Click Send test notification. If you see "Validation successful", the topic exists, Crossdeck has granted Google's notification SA
pubsub.publisher, and the path is correct. - Click Save.
Crossdeck grants the role pubsub.publisher to Google's system service account [email protected] on the gplay-rtdn topic. That account is the canonical sender for Play RTDN across every Play developer in the world. Authority is enforced at the Pub/Sub level; we don't accept publishes from anyone else. See backend/src/webhooks/google.ts:11-20 for the routing model.
Step 5 · Verify
Two pieces to confirm: the credentials are good (covered above), and the RTDN delivery path works end-to-end.
Quick smoke test
Use Play Console's Send test notification button from Step 4 again. That sends a one-time test payload to the topic. Within 10–15 seconds Crossdeck should log receipt; the dashboard's rail card shows a "Last RTDN received" timestamp that ticks. If it doesn't, see the touchpoints table — the most common cause is RTDN being configured on a different app's monetization setup page than the packageName Crossdeck holds.
Trigger a real (or test) purchase
The end-to-end test is a license-test purchase from a Play Console-registered tester account:
- In Play Console: Setup → License testing. Add a Google account to the testers list.
- On that account's device, install your app from an Internal Testing track (or production if you've launched).
- Run through your in-app purchase flow. Pay; the purchase is free for license testers.
- Within a few seconds: Google sends an RTDN to
gplay-rtdn. Crossdeck's webhook handler (backend/src/webhooks/google.ts) decodes the message, routes by your packageName to your project, fetches the authoritative purchase state viapurchases.subscriptionsv2.get, writes a SubscriptionRecord, and grants any mapped entitlements. - Flip the dashboard env switcher to Sandbox (license-test purchases route there — see below). Open Customers; the tester account's transaction is visible.
Google's RTDN payload doesn't carry an explicit "this is a test purchase" flag. Crossdeck reads testPurchase: true from the Play API response on subscriptions, and purchaseType on one-off products (backend/src/webhooks/google.ts:30-38). License-tester purchases land in your sandbox dashboard; real Play Store purchases land in production. See Sandbox vs production for the full env model.
The cold-start state
The first read of any newly-connected rail is "what subscriptions does this developer have right now?" Stripe answers with subscriptions.list. Apple answers with ~180 days of replayable notifications. Google answers with nothing — there is no list endpoint.
So Crossdeck does the honest thing instead of faking a number. Discovery at backend/src/migration/google-discover.ts:1-35 handles two cases:
- Your project already has Google subscriptions in Firestore (from earlier RTDN traffic, or from running discovery again). Discovery walks each, refetches via
purchases.subscriptionsv2.get, and rewrites the SubscriptionRecord. End-state matches what the webhook would do on the next RTDN for the same subscription — same write path, same fields. Discovery here repairs drift, it doesn't enumerate new. - Fresh connect with zero subscriptions (the cold start). Discovery writes a migration doc with
status: "cold_start_no_history". The rail card shows "No historical data — subscriptions populate as RTDNs arrive." On the developer's next billing cycle, every active subscriber generates a renewal RTDN; over one cycle (up to a month for annual plans) the dashboard fills out naturally.
Google's per-token get works fine — Crossdeck's purchases.subscriptionsv2.get path will fetch each subscription's authoritative state once you supply the token. Today this is operator-driven: paste tokens via the migration script or hold off until RTDNs fire naturally. We do not enumerate Google subscriptions because the API doesn't let us. We say so rather than hiding it behind a "Loading…" that never resolves.
What runs after connect
Three pieces of automation kick in the moment your rail is connected:
1 · Webhook handler — every RTDN
At backend/src/webhooks/google.ts: every RTDN that lands on gplay-rtdn is decoded, routed by packageName to your project via googlePackageIndex, deduped on a stable composite ID (so Google's at-least-once delivery doesn't double-write), then state-machined into your SubscriptionRecord. Every decision — including rejects and no-ops — appends to the project's audit log.
2 · Daily reconciler — 03:30 UTC
At backend/src/workers/google-mrr-reconcile.ts:1-40: every revenue-bearing Google subscription is refetched via purchases.subscriptionsv2.get and rewritten through the same path the webhook and discovery use. Three rails converging on one writer means there's no drift between "what the webhook said" and "what Google says now." If an RTDN was missed (Google retries on 4xx but eventually gives up; Pub/Sub itself is at-least-once but messages can deadletter), the daily sweep catches it.
3 · Service-account watchdog — same daily sweep
The reconciler counts consecutive auth failures (401 / 403) in a single run. If 3 or more tokens fail with auth errors in the same project, that's almost certainly a revoked service account or expired key — a project-level outage no per-event surface can detect (single-message webhook failures look like noise individually). The sweep writes projects/{p}/healthChecks/googleReconcile with status: "failing" and opens an alert. You see it as a Slack / email notification (if you've wired one) and as a banner on the rails card.
Touchpoints that quietly break things
Following the same pattern as the sandbox vs production touchpoints table — these are real ways a "connected" rail goes silently wrong.
| Touchpoint | What goes wrong | Defence |
|---|---|---|
| packageName in Crossdeck differs from the one configured in Play Console | RTDNs are tagged with packageName. If Crossdeck's googlePackageIndex doesn't have your packageName as a key, the receiver falls back to a project-scan and either finds nothing or routes to a stale project. Either way, no subscription writes happen. |
The Connect modal validates the packageName up-front by calling inappproducts.list. Re-check that the package name in the rail card matches Play Console exactly (lower-case, reverse-DNS). Mismatches almost always trace to a typo on connect or a Play Console rename without re-connecting Crossdeck. |
| Service account permissions revoked in Play Console after connect | Your Play Console admin removes the SA from the access list (perhaps cleaning up unused entries). The next purchase RTDN arrives, Crossdeck tries to call purchases.subscriptionsv2.get to confirm state, and gets a 403. The webhook deadletters; the SubscriptionRecord is never written. |
The daily reconciler watchdog catches this within 24 hours — 3+ 403s in one run flips healthChecks/googleReconcile to failing and opens an alert. Re-grant the SA in Play Console; the next reconciler run (or any new RTDN) recovers. |
| Service-account key rotated or deleted in Cloud Console | Same effect as above (401 instead of 403). The credential Crossdeck holds in Secret Manager no longer works. | Same defence — reconciler watchdog. Resolution: Connect Google Play again with the new JSON; the connect handler overwrites the Secret Manager entry. |
RTDN topic name typo'd into Play Console (google-play-rtdn vs gplay-rtdn) |
Play Console accepts a non-existent topic name without erroring on save. Notifications publish nowhere. Crossdeck never sees an event. | Use the Copy button in the connect modal. Always run Play Console's Send test notification after saving — that's the only check that catches a wrong topic name. "Validation successful" means Crossdeck received the test. |
| RTDN configured on the wrong app's monetization setup page | Multi-app developer accounts have a separate Monetization setup per app. Wiring the topic on the wrong app means Crossdeck receives events for an app it doesn't have credentials for. The receiver fetches via purchases.subscriptionsv2.get against the connected app's credentials, gets a 404 (wrong app), and deadletters. |
The audit log row for the failed call carries the packageName from the RTDN. Cross-check against the packageName on the rail card; mismatches name the wrong-app problem directly. |
| Pub/Sub message backlog from a deploy issue | If the receiver function fails repeatedly (a backend bug, an outage, a regional Cloud Functions issue), Pub/Sub retries with exponential backoff and eventually deadletters. Messages can sit for hours. | Pub/Sub deadletters are observable in Cloud Console. The daily reconciler catches anything that wasn't projected by re-reading each known subscription's state — so even a multi-hour deadletter is bounded by the next 03:30 UTC sweep. |
| License-test purchase routes to sandbox, developer reads production | Your QA tester runs a license-test purchase. testPurchase: true from the Play API → Crossdeck writes under env: "sandbox". You open the dashboard in production, see nothing, and think the rail is broken. |
Flip the env switcher to Sandbox. The transaction is there. The amber chrome on the dashboard is the giveaway when you're in sandbox; calm white means production. See Sandbox vs production. |
What Google's API doesn't let us do
Naming these explicitly so you know what to expect from Crossdeck on the Google rail, vs what we can do on Stripe or Apple. These are platform limits we don't paper over.
- No bulk subscription enumeration. The Play Developer API exposes
purchases.subscriptionsv2.get(packageName, purchaseToken)only — per-token. We can't "fetch your subscriber list" because Google doesn't expose one. Hence the cold-start state. - No "give me everything since timestamp T" replay. Apple's App Store Server API lets us replay ~180 days of notification history. Google offers nothing analogous. If RTDN delivery breaks for an extended window, we re-converge via the reconciler's per-token refresh on the subscriptions we already know about — but we cannot recover ones we never saw.
- No platform-side env routing field on RTDN. The payload itself has no "this is a test purchase" flag, so we route env via the Play API response after the receiver fetches authoritative state. This adds one round-trip per RTDN but is the only correct way.
- No one-off purchase history beyond what the receiver saw. Same as subscriptions — there's no
purchases.products.list. Cold-start has no list of past one-offs.
Where we work around these (the daily reconciler, the project-scan fallback in routing, the cold-start state as a first-class output) is in the code, not the docs. The intent: a developer reading our docs gets the truth about what Google permits, and the platform behaves consistently with that truth.
Related
- Connect App Store — the .p8 key + JWS verifier pattern for the Apple rail.
- Connect Stripe — Stripe Connect OAuth, the bulk-list-driven rail.
- Rail webhooks → Google Play RTDN — deeper detail on the RTDN payload taxonomy and verification.
- Sandbox vs production — how the env partition works for all three rails, including the license-tester / testPurchase routing for Google.
- Entitlements & gating — how a Google subscription gets mapped to a Crossdeck entitlement your app can read.