Crossdeck Docs
Dashboard

Connect Google Play

Payment rails 10 min · Service account · Pub/Sub RTDN · daily reconciler

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

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:

RailAuth modelWebhookBulk history?
StripeOAuth (Stripe Connect)One Crossdeck-side webhook fans out to every OAuth'd accountYes — subscriptions.list
Apple App Store.p8 key upload + Key ID + Issuer IDOne Apple-side webhook URL per app; sandbox + prod share the URL~180 days of notifications, replay-on-demand
Google PlayService-account JSON uploadPub/Sub topic; we own the topic, Google publishesNo. 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:

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:

  1. Open IAM & Admin → Service Accounts. Pick the Cloud project you want the account to live in (or create one — any project works).
  2. 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.
  3. 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.
Why a service account, not OAuth.

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):

  1. Top-level Setup → API access. (Not inside an individual app — at the developer-account level.)
  2. 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.
  3. Scroll to the Service accounts table. Find the row matching the client_email from your JSON (it'll look like [email protected]). Click Grant access.
  4. 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-1138 names these two exactly. Click Apply, then Invite user.
  5. Wait for Play Console to flip the status to Active. Refresh once or twice; it usually takes under a minute.
If you grant less than the two permissions above, the connect-time validation call will 403.

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:

  1. Open Payment rails. You'll see three cards — Stripe, Apple, Google Play.
  2. On the Google Play card, click Connect Google Play. A modal opens.
  3. 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.)
  4. Enter the Package name exactly as it appears in Play Console (reverse-DNS, lowercase, e.g. com.acme.notes).
  5. 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 the client_email so you can confirm you uploaded the right file before submitting.
  6. Click Verify & connect. Crossdeck calls inappproducts.list against 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 the googlePackageIndex/{packageName} entry is written for fast RTDN routing.
What success and failure each look like.

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.

Why 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

  1. Back in Play Console, open your app: your app → Monetize → Monetization setup.
  2. Scroll to Real-time developer notifications and click Edit.
  3. Tick Send real-time notifications to this topic. Paste the topic path (projects/crossdeck-47d8f/topics/gplay-rtdn) into the Topic name field.
  4. 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.
  5. Click Save.
How Google can publish to a topic we own.

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:

  1. In Play Console: Setup → License testing. Add a Google account to the testers list.
  2. On that account's device, install your app from an Internal Testing track (or production if you've launched).
  3. Run through your in-app purchase flow. Pay; the purchase is free for license testers.
  4. 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 via purchases.subscriptionsv2.get, writes a SubscriptionRecord, and grants any mapped entitlements.
  5. Flip the dashboard env switcher to Sandbox (license-test purchases route there — see below). Open Customers; the tester account's transaction is visible.
Where the sandbox/production split happens for Google.

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:

If you have existing Play subscribers and need them visible immediately, you'll need to feed Crossdeck the purchase tokens.

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.

TouchpointWhat goes wrongDefence
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.

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.