NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

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. Every acknowledgement sends a JSON object ({}) as its body, as Google requires.
  • Every Play Developer API path segment — package name, product id, subscription id, token — is percent-encoded, and an empty, . or .. one is refused (“Malformed Google id.”) before Google is called, so a crafted value cannot reach another endpoint.
  • product() and subscription() are direct lookups: an error answer from Google stays the HTTP client’s RequestException.

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=true

Each 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().

Only your app’s notifications

Push authentication proves that Google sent a message, not which app it is about. An RTDN whose packageName is not your PURCHASES_GOOGLE_PACKAGE_NAME — another app on a shared Pub/Sub topic, or one that names no package — is an informational Notification result: audited, answered 204, never applied, never looked up under your app and never redelivered.

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

callback() and callbackResult() refuse a missing, empty or non-string purchaseToken, and a productId that is present but not a string, before Google is called; a missing or empty productId means a subscription. A token Google rejects with a 4xx — 400 unknown, 410 no longer valid — is a VerificationException too, while refused credentials, a wrong package_name, rate limits and outages stay a RequestException: see Client callbacks.

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 — any error answer but 410, or an unreachable Google — the push fails with a 500 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. An RTDN whose token is . or .. is refused (400) before Google is called.

Notification mapping

Google notification typeStatus
Recovered, Renewed, Purchased, Restarted, Canceled (auto-renew off — access runs until the expiry)Completed
InGracePeriodInGracePeriod
OnHold, PausedOnHold
PendingPurchaseCanceledCanceled
Revoked, voided purchase (refund result)Refunded
ExpiredFailed
Deferred, PriceChangeConfirmed, PauseScheduleChanged, ItemsChanged, CancellationScheduled, PriceChangeUpdated, PriceStepUpConsentUpdatedInformational — 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 crypto

By 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.