Skip to content

Verify Google Play purchases server-side without the Java SDK

Server-side purchase verification on Google Play is one of those tasks where the official docs steer you toward a specific stack — pull in the Google API Java client, set up OAuth, wire up the AndroidPublisher service, hand-roll the request. If your backend is Node, Go, Python, Rust, or basically anything else, you’re either translating that boilerplate or reaching for a community wrapper.

There’s a shorter path: gplay validates receipts from a single command line. It’s the same Google Play Android Publisher API call, just without the ceremony.

Say your Android app sent your server a purchaseToken and a productId after a one-time purchase or subscription. You need to confirm with Google that it’s real, not consumed, and not refunded.

Terminal window
gplay purchases subscriptionsv2 get \
--package com.example.app \
--token "$PURCHASE_TOKEN"

Output (minified JSON, one line — shown here formatted for reading):

{
"kind": "androidpublisher#subscriptionPurchaseV2",
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"regionCode": "US",
"lineItems": [{
"productId": "monthly_pro",
"expiryTime": "2026-08-05T12:34:56Z",
"autoRenewingPlan": { "autoRenewEnabled": true }
}],
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"linkedPurchaseToken": ""
}

Your backend parses that JSON and decides whether to grant entitlements.

import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
export async function verifySubscription(packageName, token) {
const { stdout } = await run('gplay', [
'purchases', 'subscriptionsv2', 'get',
'--package', packageName,
'--token', token,
]);
const purchase = JSON.parse(stdout);
return {
active: purchase.subscriptionState === 'SUBSCRIPTION_STATE_ACTIVE',
productId: purchase.lineItems?.[0]?.productId,
expiresAt: purchase.lineItems?.[0]?.expiryTime,
};
}

No googleapis dependency. No OAuth token management. gplay handles the service-account auth internally from the file at GPLAY_SERVICE_ACCOUNT.

package purchases
import (
"context"
"encoding/json"
"os/exec"
)
type Verified struct {
Active bool `json:"active"`
ProductID string `json:"productId"`
ExpiresAt string `json:"expiresAt"`
}
type v2 struct {
State string `json:"subscriptionState"`
LineItems []struct {
ProductID string `json:"productId"`
ExpiryTime string `json:"expiryTime"`
} `json:"lineItems"`
}
func Verify(ctx context.Context, pkg, token string) (Verified, error) {
out, err := exec.CommandContext(ctx, "gplay",
"purchases", "subscriptionsv2", "get",
"--package", pkg, "--token", token,
).Output()
if err != nil {
return Verified{}, err
}
var p v2
if err := json.Unmarshal(out, &p); err != nil {
return Verified{}, err
}
v := Verified{Active: p.State == "SUBSCRIPTION_STATE_ACTIVE"}
if len(p.LineItems) > 0 {
v.ProductID = p.LineItems[0].ProductID
v.ExpiresAt = p.LineItems[0].ExpiryTime
}
return v, nil
}
import json
import subprocess
def verify_subscription(package: str, token: str) -> dict:
out = subprocess.check_output([
"gplay", "purchases", "subscriptionsv2", "get",
"--package", package, "--token", token,
])
purchase = json.loads(out)
return {
"active": purchase["subscriptionState"] == "SUBSCRIPTION_STATE_ACTIVE",
"product_id": purchase["lineItems"][0]["productId"] if purchase.get("lineItems") else None,
"expires_at": purchase["lineItems"][0].get("expiryTime") if purchase.get("lineItems") else None,
}

Same command, three languages, same shape.

For consumables and non-consumables:

Terminal window
gplay purchases products get \
--package com.example.app \
--product-id premium_upgrade \
--token "$PURCHASE_TOKEN"

You get back purchaseState (0 = purchased, 1 = canceled, 2 = pending), consumptionState, and orderId. Same JSON shape, same backend integration.

Google requires purchases to be acknowledged within 3 days or they’re auto-refunded. gplay handles both flavors:

Terminal window
# Subscriptions
gplay purchases subscriptions acknowledge \
--package com.example.app \
--subscription-id monthly_pro \
--token "$PURCHASE_TOKEN"
# Consumable products
gplay purchases products consume \
--package com.example.app \
--product-id gems_pack_100 \
--token "$PURCHASE_TOKEN"

Both are idempotent — safe to retry.

To sweep for refunds you missed:

Terminal window
gplay purchases voided list \
--package com.example.app \
--start-time 2026-07-01T00:00:00Z \
--paginate

--paginate fetches every page automatically. Run it as an hourly cron and revoke entitlements when a purchaseToken shows up.

In your backend service or container, put the service-account JSON at a known path and set:

Terminal window
export GPLAY_SERVICE_ACCOUNT=/secrets/play-sa.json
export GPLAY_PACKAGE=com.example.app # optional default
export GPLAY_NO_UPDATE=1 # disable the update check in prod
export GPLAY_TIMEOUT=30s # tighter timeout for API paths

That’s the whole setup. No OAuth flow, no refresh tokens, no client library.

Why this ends up simpler than the SDK path

Section titled “Why this ends up simpler than the SDK path”
  • Language-agnostic. Your backend team keeps its stack; the CLI is the shared interface.
  • Static binary. 12 MB, no runtime dependency, works in alpine, distroless, Lambda custom runtime, wherever.
  • Deterministic JSON. The output shape is stable — no library version drift.
  • --dry-run for tests. Sanity-check request payloads in staging without hitting Google.
  • Same tool for the rest of Play Console. The same CLI that verifies purchases also uploads builds, checks vitals, and downloads reports. One dependency, six APIs.
Terminal window
brew install tamtom/tap/gplay
gplay setup --auto
gplay purchases subscriptionsv2 get --package com.example.app --token TEST_TOKEN

Full purchase reference at /reference/purchases/. If you’re validating high volume, install the purchase-verification skill and your AI agent will scaffold the backend integration for you.