Purchase Verification for Google Play
Use this skill when you need to verify in-app purchases or subscriptions from your backend server.
Why Verify Purchases Server-Side?
Client-side verification can be bypassed. Always verify purchases on your server:
- Prevent fraud and piracy
- Ensure user actually paid
- Check subscription status
- Handle refunds and cancellations
Authentication Setup
Your backend needs a service account with permissions to verify purchases.
Create service account
- Go to Google Cloud Console
- Create service account
- Grant "Service Account User" role
- Download JSON key
Grant API access
- Go to Play Console
- Users & Permissions → Service Accounts
- Grant service account access to your apps
Verify In-App Product Purchase
Get purchase details
bashgplay purchases products get \ --package com.example.app \ --product-id premium_upgrade \ --token <PURCHASE_TOKEN>
Response
json{ "kind": "androidpublisher#productPurchase", "purchaseTimeMillis": "1706400000000", "purchaseState": 0, "consumptionState": 0, "developerPayload": "user_123", "orderId": "GPA.1234-5678-9012-34567", "purchaseType": 0 }
Purchase states
0= Purchased1= Canceled2= Pending
Consumption states
0= Yet to be consumed1= Consumed
Acknowledge Purchase
After verifying, acknowledge the purchase:
bashgplay purchases products acknowledge \ --package com.example.app \ --product-id premium_upgrade \ --token <PURCHASE_TOKEN>
Important: Unacknowledged purchases will be refunded after 3 days.
Consume Purchase (for consumables)
For consumable items (coins, gems, etc.):
bashgplay purchases products consume \ --package com.example.app \ --product-id coins_100 \ --token <PURCHASE_TOKEN>
Verify Subscription
Prefer the v2 API. For new integrations use
gplay purchases subscriptionsv2 get(andsubscriptionsv2 cancel/defer/revoke,purchases productsv2 get). The v2SubscriptionPurchaseV2model reflects base plans and offers; the v1 endpoints below still work but are the legacy shape.
Get subscription details (v2, recommended)
bashgplay purchases subscriptionsv2 get \ --package com.example.app \ --token <SUBSCRIPTION_TOKEN>
Get subscription details (v1, legacy)
bashgplay purchases subscriptions get \ --package com.example.app \ --token <SUBSCRIPTION_TOKEN>
Response
json{ "kind": "androidpublisher#subscriptionPurchase", "startTimeMillis": "1706400000000", "expiryTimeMillis": "1709000000000", "autoRenewing": true, "priceCurrencyCode": "USD", "priceAmountMicros": "4990000", "paymentState": 1, "cancelReason": null, "userCancellationTimeMillis": null, "orderId": "GPA.1234-5678-9012-34567", "linkedPurchaseToken": null, "subscriptionState": 0 }
Subscription states
0= Active1= Canceled (still valid until expiry)2= In grace period3= On hold (payment failed, retrying)4= Paused5= Expired
Payment states
0= Payment pending1= Payment received2= Free trial3= Pending deferred upgrade/downgrade
Backend Implementation Example
Node.js/Express
javascriptconst { google } = require('googleapis'); async function verifyPurchase(packageName, productId, token) { const auth = new google.auth.GoogleAuth({ keyFile: '/path/to/service-account.json', scopes: ['https://www.googleapis.com/auth/androidpublisher'], }); const androidpublisher = google.androidpublisher({ version: 'v3', auth: await auth.getClient(), }); const result = await androidpublisher.purchases.products.get({ packageName: packageName, productId: productId, token: token, }); return result.data; } // Endpoint app.post('/verify-purchase', async (req, res) => { const { packageName, productId, token } = req.body; try { const purchase = await verifyPurchase(packageName, productId, token); if (purchase.purchaseState === 0) { // Purchase is valid // Grant access to user // Acknowledge purchase res.json({ valid: true, purchase }); } else { res.json({ valid: false }); } } catch (error) { res.status(400).json({ error: error.message }); } });
Python/Flask
pythonfrom google.oauth2 import service_account from googleapiclient.discovery import build SCOPES = ['https://www.googleapis.com/auth/androidpublisher'] SERVICE_ACCOUNT_FILE = '/path/to/service-account.json' credentials = service_account.Credentials.from_service_account_file( SERVICE_ACCOUNT_FILE, scopes=SCOPES) androidpublisher = build('androidpublisher', 'v3', credentials=credentials) @app.route('/verify-purchase', methods=['POST']) def verify_purchase(): data = request.json package_name = data['packageName'] product_id = data['productId'] token = data['token'] try: result = androidpublisher.purchases().products().get( packageName=package_name, productId=product_id, token=token ).execute() if result['purchaseState'] == 0: # Purchase is valid return jsonify({'valid': True, 'purchase': result}) else: return jsonify({'valid': False}) except Exception as e: return jsonify({'error': str(e)}), 400
Handle Subscription Events
Real-time Developer Notifications (RTDN)
Set up Pub/Sub to receive subscription events:
- Create Pub/Sub topic in Google Cloud Console
- Configure in Play Console:
- Monetization Setup → Real-time developer notifications
- Enter topic name
gplay can scaffold and decode RTDN without hand-writing the base64/JSON parsing:
bash# Print the Pub/Sub topic + Play Console setup steps gplay rtdn setup --package com.example.app # Decode an RTDN payload into readable JSON (notification type, token, etc.) # Accepts a full Pub/Sub envelope (message.data is base64) or the raw notification. gplay rtdn decode --data '{"message":{"data":"<BASE64_DATA>"}}' cat payload.json | gplay rtdn decode --file -
- Subscribe to events:
pythonfrom google.cloud import pubsub_v1 subscriber = pubsub_v1.SubscriberClient() subscription_path = subscriber.subscription_path(project_id, subscription_id) def callback(message): data = json.loads(message.data) if 'subscriptionNotification' in data: notification = data['subscriptionNotification'] notification_type = notification['notificationType'] purchase_token = notification['purchaseToken'] # Handle different events if notification_type == 1: # SUBSCRIPTION_RECOVERED # Subscription was recovered from account hold pass elif notification_type == 2: # SUBSCRIPTION_RENEWED # Subscription renewed successfully pass elif notification_type == 3: # SUBSCRIPTION_CANCELED # User canceled subscription pass elif notification_type == 4: # SUBSCRIPTION_PURCHASED # New subscription purchase pass elif notification_type == 7: # SUBSCRIPTION_EXPIRED # Subscription expired pass elif notification_type == 10: # SUBSCRIPTION_PAUSED # Subscription paused pass elif notification_type == 12: # SUBSCRIPTION_REVOKED # Subscription revoked (refunded) pass message.ack() subscriber.subscribe(subscription_path, callback=callback)
Subscription Management
Cancel subscription
bashgplay purchases subscriptions cancel \ --package com.example.app \ --token <SUBSCRIPTION_TOKEN>
Defer subscription
bashgplay purchases subscriptions defer \ --package com.example.app \ --token <SUBSCRIPTION_TOKEN> \ --json @defer.json
defer.json
json{ "deferralInfo": { "expectedExpiryTimeMillis": "1709000000000" } }
Revoke subscription (refund)
bashgplay purchases subscriptions revoke \ --package com.example.app \ --token <SUBSCRIPTION_TOKEN>
Check Voided Purchases
Get list of refunded/canceled purchases:
bashgplay purchases voided list \ --package com.example.app \ --start-time 1706400000000 \ --end-time 1709000000000
Remove entitlements for these purchases on your backend.
Order Information
Get order details
bashgplay orders get \ --package com.example.app \ --order-id GPA.1234-5678-9012-34567
Batch get orders
bashgplay orders batch-get \ --package com.example.app \ --order-ids "GPA.1234,GPA.5678,GPA.9012"
Refund order
orders refund is a destructive write and requires --confirm — without it the
command refuses to run.
bashgplay orders refund \ --package com.example.app \ --order-id GPA.1234-5678-9012-34567 \ --revoke \ # Also revoke entitlement/access --confirm # Required — refund is irreversible
Security Best Practices
DO:
- ✅ Always verify on server, never trust client
- ✅ Store purchase tokens securely
- ✅ Acknowledge purchases within 3 days
- ✅ Handle refunds and cancellations
- ✅ Use HTTPS for all API calls
- ✅ Rate limit your verification endpoint
- ✅ Log all verification attempts
DON'T:
- ❌ Verify purchases only on client
- ❌ Expose service account credentials
- ❌ Skip acknowledging purchases
- ❌ Grant access before verification
- ❌ Ignore voided purchases
- ❌ Store credit card info (PCI compliance)
Common Verification Flow
- User makes purchase in app
- App sends purchase token to your server
- Server verifies with Google Play API
- Server acknowledges purchase (if valid)
- Server grants access/content to user
- Server stores purchase token for future checks
- Server listens for RTDN events (cancellations, renewals)
Error Handling
Common errors
401 Unauthorized- Service account not authorized404 Not Found- Purchase token invalid or expired410 Gone- Purchase was refunded/canceled
Retry logic
javascriptasync function verifyWithRetry(packageName, productId, token, retries = 3) { for (let i = 0; i < retries; i++) { try { return await verifyPurchase(packageName, productId, token); } catch (error) { if (error.code === 404 || error.code === 410) { throw error; // Don't retry if purchase is invalid } if (i === retries - 1) throw error; await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1))); } } }
Testing
Test purchases
Use Google Play's test accounts to make test purchases without charging real money.
Test verification
bash# Verify test purchase gplay purchases products get \ --package com.example.app \ --product-id android.test.purchased \ --token <TEST_TOKEN>
Monitoring
Track these metrics:
- Purchase verification success rate
- Acknowledgment rate
- Refund rate
- Subscription churn rate
- Failed payment rate
Use this data to improve your monetization strategy.

