Google Play
The Google provider (id google) verifies purchases against the Play Developer API, authenticating with a service account through a native OAuth2 JWT-bearer grant with cached access tokens. Real-time developer notifications delivered through Pub/Sub decode into a typed DeveloperNotification:
use RoundlyConsulting\Purchases\Facades\Purchases;
use RoundlyConsulting\Purchases\Providers\Google\Google;
$google = app(Google::class);
$product = $google->product('coins.100', $purchaseToken); // ProductPurchase
$product->orderId;
$product->purchaseTime; // ?Carbon
$subscription = $google->subscription($purchaseToken); // SubscriptionPurchase (acknowledged by default)
$subscription->subscriptionState; // ?SubscriptionState
$subscription->expiryTime(); // ?Carbon
$subscription->price(); // ?Money — sum of the line items
$google->acknowledgeSubscription($purchaseToken, $subscription->productId()); // manual acknowledgement
$google->notification($request); // DeveloperNotification (RTDN)
// A token your app sent up ({purchaseToken, productId?}), verified and mapped — then recorded.
Purchases::sync($google->callbackResult($request));Verification rules
- product() throws VerificationException unless the purchase is in the purchased state.
- subscription() throws VerificationException only when the subscription has expired. A canceled one — auto-renew turned off — keeps access until its expiry, so it still verifies and a restore works.
- Both acknowledge the purchase with Google when acknowledge is on (the default) and it isn’t acknowledged yet. A subscription is acknowledged through the Play Developer API’s purchases.subscriptions.acknowledge with its product id (subscriptionsv2 has no acknowledge method). Set PURCHASES_GOOGLE_ACKNOWLEDGE=false to opt out and call acknowledgeSubscription($token, $subscriptionId) yourself.
- Product ids and tokens are percent-encoded into every API path, so a crafted one cannot reach another endpoint.
Authenticating RTDN pushes
A Pub/Sub push is a plain HTTPS POST anyone could forge, so notification() and result() — and therefore handle() and the webhook route — authenticate every request as a push first, whatever its body, and are fail-closed: with nothing configured, every push is rejected (400 on the route). In Google Cloud, edit the push subscription, enable authentication with a service account and an audience, then set the same two values:
# Google Cloud: push subscription → Enable authentication (service account + audience)
PURCHASES_GOOGLE_PUSH_AUDIENCE=https://your-app.test/purchases/webhooks/google
PURCHASES_GOOGLE_PUSH_SERVICE_ACCOUNT=[email protected]
# Optional, alone or on top: …/webhooks/google?token=<secret>
PURCHASES_GOOGLE_PUSH_TOKEN=
# Only when something upstream already authenticated the message
PURCHASES_GOOGLE_PUSH_AUTHENTICATE=trueEach push’s Authorization: Bearer OIDC token is then verified with crypto-for-laravel — the RS256 signature against Google’s JWKS (cached, re-fetched for a rotated key at most once a minute), the issuer accounts.google.com, the audience, the service-account email, email_verified and expiry. The optional ?token= secret is compared in constant time. Set PURCHASES_GOOGLE_PUSH_AUTHENTICATE=false only when your own pull subscriber hands already-authenticated messages to Purchases::handle().
Client purchase tokens
A client’s purchase token is not a push, so the webhook route never takes one. Verify it from your own authenticated route with product(), subscription() or callbackResult() — purchaseToken plus productId for a one-time product, purchaseToken alone for a subscription — and record it with Purchases::sync():
use RoundlyConsulting\Purchases\Facades\Purchases;
use RoundlyConsulting\Purchases\Providers\Google\Google;
// In your own authenticated route — never the webhook route:
// { "purchaseToken": "…", "productId": "coins.100" } → one-time product
// { "purchaseToken": "…" } → subscription
$model = Purchases::sync(app(Google::class)->callbackResult($request));Subscription notifications
An RTDN only says that a subscription changed — no expiry, order or price. So every state-changing subscription RTDN (purchased, renewed, recovered, restarted, canceled, grace period, on hold, paused, expired …) is recorded from the subscription’s current subscriptionsv2 state, read with the service account, as Google recommends: a renewal moves ends_at forward and fires SubscriptionRenewed. That read is GET-only — the RTDN path never acknowledges, since it cannot tell which of your users the purchase is for. If the read fails, the push fails too and Pub/Sub redelivers it; a subscription Google no longer keeps (410 Gone) is recorded from the RTDN alone. The service-account credentials are therefore needed for RTDNs as well.
Notification mapping
| Google notification type | Status |
|---|---|
| Recovered, Renewed, Purchased, Restarted, Canceled (auto-renew off — access runs until the expiry) | Completed |
| InGracePeriod | InGracePeriod |
| OnHold, Paused | OnHold |
| PendingPurchaseCanceled | Canceled |
| Revoked, voided purchase (refund result) | Refunded |
| Expired | Failed |
| Deferred, PriceChangeConfirmed, PauseScheduleChanged, ItemsChanged, CancellationScheduled, PriceChangeUpdated, PriceStepUpConsentUpdated | Informational — audited, never applied |
A canceled Google subscription — SUBSCRIPTION_CANCELED, auto-renew turned off — stays Completed until its ends_at ends it. Only an expired one stops being active.
Subscription prices are the sum of the line items’ recurring price (prepaid plans carry none). One-time product purchases carry no price in the Play Developer API, so their price() stays null.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.