Validate Google Play Store Purchase
POST https://api.botsi.com/v2/purchases/play-store/validateCall this after a purchase completes in your app. It confirms the transaction with Google Play and records the revenue against the Profile and the Paywall that produced it.
Identify the user with profileId. To give a returning user back the access their subscriptions carry, use Restore Google Play Subscriptions.
Request body#
isExperiment and aiPricingModelId are optional on the v2 API, though the SDK surface requires them. Omitting them still validates the purchase, but read Attributing the purchase to a Paywall before you do.
Send environment explicitly. It has no default.
A missing environment is treated as non-production, which silently drops the revenue event. The call still succeeds, so nothing surfaces until the revenue is missing from your reports.
Send production for live apps and sandbox for testing.
Send one offer object, never both
subscriptionOfferDetails and oneTimePurchaseOfferDetails are mutually exclusive. Which one you send depends on whether you are validating a subscription or a one-time purchase.
subscriptionOfferDetails#
Each entry in pricingPhases:
oneTimePurchaseOfferDetails#
A missing zero-priced phase turns a trial into revenue
Botsi identifies a free trial by a pricingPhases element with priceAmountMicros set to 0, alongside its usual billingPeriod, recurrenceMode, and billingCycleCount.
Leave that phase out and the event is read as a paid subscription, which inflates your revenue statistics with money nobody paid.
Example requests#
Which offer object you send depends on what the user bought. A subscription with an introductory free trial carries two pricing phases: the zero-priced trial, then the phase that charges once it ends.
curl -X POST "https://api.botsi.com/v2/purchases/play-store/validate" \
-H "Authorization: $BOTSI_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"token": "algojlnkodhhjceoicbpjngh.AO-J1Owvcga8j2uUoKEpn5AnvpSFr8dxEFm0c6qk5",
"placementId": "fresh_placement",
"productId": "monthly",
"paywallId": 569,
"isExperiment": true,
"aiPricingModelId": 32,
"environment": "production",
"profileId": "55bbd5f3-6de8-4d3b-8a55-006e6af6e3dd",
"subscriptionOfferDetails": {
"basePlanId": "1month-sub",
"pricingPhases": [
{
"priceAmountMicros": 359990000,
"currencyCode": "UAH",
"billingPeriod": "P1M",
"recurrenceMode": 1,
"billingCycleCount": 0
}
]
}
}'curl -X POST "https://api.botsi.com/v2/purchases/play-store/validate" \
-H "Authorization: $BOTSI_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"token": "algojlnkodhhjceoicbpjngh.AO-J1Owvcga8j2uUoKEpn5AnvpSFr8dxEFm0c6qk5",
"placementId": "fresh_placement",
"productId": "monthly",
"paywallId": 569,
"isExperiment": true,
"aiPricingModelId": 32,
"environment": "production",
"profileId": "55bbd5f3-6de8-4d3b-8a55-006e6af6e3dd",
"subscriptionOfferDetails": {
"basePlanId": "1month-sub",
"offerId": "freetrial-7d",
"pricingPhases": [
{
"priceAmountMicros": 0,
"currencyCode": "UAH",
"billingPeriod": "P1W",
"recurrenceMode": 2,
"billingCycleCount": 1
},
{
"priceAmountMicros": 359990000,
"currencyCode": "UAH",
"billingPeriod": "P1M",
"recurrenceMode": 1,
"billingCycleCount": 0
}
]
}
}'curl -X POST "https://api.botsi.com/v2/purchases/play-store/validate" \
-H "Authorization: $BOTSI_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"token": "algojlnkodhhjceoicbpjngh.AO-J1Owvcga8j2uUoKEpn5AnvpSFr8dxEFm0c6qk5",
"placementId": "fresh_placement",
"productId": "lifetime_unlock",
"paywallId": 569,
"isExperiment": true,
"aiPricingModelId": 32,
"environment": "production",
"profileId": "55bbd5f3-6de8-4d3b-8a55-006e6af6e3dd",
"oneTimePurchaseOfferDetails": {
"priceAmountMicros": 9990000,
"currencyCode": "USD"
}
}'Attributing the purchase to a Paywall#
Send the paywallSessionId from the Fetch Paywall response. Botsi reads the Paywall, Placement, A/B test, and AI Pricing Model from the token, and credits the revenue to the experiment that produced it. This is the preferred way to attribute a purchase.
curl -X POST "https://api.botsi.com/v2/purchases/play-store/validate" \
-H "Authorization: $BOTSI_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"token": "algojlnkodhhjceoicbpjngh.AO-J1Owvcga8j2uUoKEpn5AnvpSFr8dxEFm0c6qk5",
"productId": "monthly",
"paywallSessionId": "v1.eyJhIjo0MDIxLCJ3Ijo5MDUsInAiOiJvbmJvYXJkaW5nIiwiaSI6MTc4NTMxMjAwMH0.QmzR1w",
"environment": "production",
"profileId": "55bbd5f3-6de8-4d3b-8a55-006e6af6e3dd",
"subscriptionOfferDetails": {
"basePlanId": "1month-sub",
"pricingPhases": [
{
"priceAmountMicros": 359990000,
"currencyCode": "UAH",
"billingPeriod": "P1M",
"recurrenceMode": 1,
"billingCycleCount": 0
}
]
}
}'- The token wins. When
paywallSessionIdis sent,paywallId,placementId,abTestId,aiPricingModelId, andisExperimentall come from the token. Values you send for those fields are ignored, not merged. - Fields the token doesn't carry are cleared. They aren't filled in from the request.
- There's no age limit. You can validate a purchase long after the Paywall was fetched, unlike a
paywall_shownevent, which expires after 24 hours. profileIdstill identifies the buyer. The token attributes the sale, but it doesn't choose the Profile.
An invalid token fails the whole request
A paywallSessionId that isn't valid returns 400. Botsi never falls back to the explicit attribution fields, so the purchase isn't validated until you send a valid token or drop it.
Without a token, the explicit fields work exactly as before. Pass paywallId, isExperiment, and aiPricingModelId as Fetch Paywall returned them, as the examples above do. Omit both the token and the fields and the purchase still validates, but it detaches from the experiment that produced it, so the model gets no credit for the revenue it earned.
Finding the token#
The purchase token comes from getPurchaseToken() in the Google Play Billing Library. Google's own documentation on verifying purchases before granting entitlements covers where it appears in the billing flow.
Response#
A successful validation returns the updated Profile, including its Access Levels, subscriptions, and total revenue. Read state and accessLevels to decide what the user now has access to.
{
"ok": true,
"data": {
"profileId": "55bbd5f3-6de8-4d3b-8a55-006e6af6e3dd",
"appUserId": "user-123",
"state": "subscribed",
"totalRevenueUsd": 8.72,
"accessLevels": {},
"subscriptions": {},
"nonSubscriptions": {}
}
}accessLevels, subscriptions, and nonSubscriptions are keyed objects, empty when the Profile has no entries of that kind. Every field is described on Get a Profile. This response is the same object, without custom. The shape matches Validate Apple Store Purchase, so a single handler can process either store's response.