Play Billing Entitlement Integrity Without False Revocations
One Product, Many Failure Modes: On Android and Google TV, Maze Point sells exactly one thing: a non-consumable "Remove Ads" product, com.slopwair.mazepoint.remove_ads, sold through Google Play Billing Library 8.x via the Godot Google Play Billing plugin. On phones and tablets it removes ads. Android TV and Google TV builds never show ads, so on a TV the same product unlocks the full game instead. With a single product, the purchase flow is simple. Making sure a paying player keeps what they paid for, across phones, TVs, flaky networks, app kills, and reinstalls, took more care. This post covers the problems we found while hardening that path and how each fix works.
We followed one principle throughout: granting is cheap and should be fast; revoking is expensive and should be slow. A player who loses what they paid for has a worse experience, and a worse impression of us, than any other bug in this area could cause.
1. The Three-Day Acknowledgment Clock
Google Play requires every purchase to be acknowledged within three days. If it is not, Google automatically refunds the purchase and revokes it. Our first integration acknowledged inside the purchase callback, which covers the normal case. It does not cover a callback that never runs: the app is killed while the purchase sheet is up, the process is reclaimed in the background on a low-memory TV box, or the acknowledgment request fails because the network dropped.
The fix is to stop treating acknowledgment as something that happens once. Every time the billing client connects, at startup and when the app resumes, we query owned purchases and acknowledge any that are in the PURCHASED state with is_acknowledged == false. Acknowledging is idempotent from our side, so retrying on every launch is safe, and an interrupted purchase now gets acknowledged on the next launch instead of being refunded three days later.
if purchase_state == PURCHASED:
found_active = true
_on_purchase_verified(PRODUCT_REMOVE_ADS) # persist locally first
if not purchase.get("is_acknowledged", false):
billing.acknowledgePurchase(purchase["purchase_token"])
elif purchase_state == PENDING:
found_pending = true
purchase_pending_approval.emit(PRODUCT_REMOVE_ADS)
Pending purchases, such as slow payment methods or a family member's approval, get their own state. The button shows PENDING APPROVAL..., no entitlement is granted, and nothing is revoked while a purchase is pending.
2. Persist Before You Acknowledge
The order of the two lines above matters. In version 1.2.5 we changed the order so the verified purchase is written to local storage before the acknowledgment goes out. If the process dies between the two steps, the worst case is a saved entitlement with an unacknowledged purchase, and the startup re-query fixes that. With the old order, the worst case was an acknowledged purchase and no local record, so the player would lose the purchase until the next successful query.
This builds on an existing design that predates that release: the cached entitlement comes first. When the game boots, it reads the cached entitlement directly from local storage and applies it immediately, before the billing client has even connected. A paying player gets their purchase from the first frame, and a slow or offline Google Play connection cannot take it away at launch. The network query that follows reconciles with the cache instead of blocking on it.
3. Atomic Entitlement Writes
A cached entitlement is only useful if the file is never half-written. Writing directly over the existing file means a crash or power loss during the write can leave a truncated file, which reads as "not entitled." We now write the new record to a temporary file next to the target, flush it, and rename it over the original. On platforms where a rename cannot replace an existing file, we fall back to removing the old file and renaming, and on load we recover from a leftover temporary file if the main one is missing.
var tmp := path + ".tmp"
var f := FileAccess.open(tmp, FileAccess.WRITE)
if f == null:
return false
f.store_buffer(payload)
f.close()
var err := DirAccess.rename_absolute(tmp, path)
return err == OK
The save returns a boolean, and callers check it. A failed save is reported as an error, not treated as a success.
4. Stale Results and Overlapping Queries
Billing calls are asynchronous, and Android lifecycle events happen often. Returning from the purchase sheet triggers a resume, and so do switching TV inputs and pulling down the notification shade. Without guards, several queryPurchases calls could be in flight at once, and an older response that arrived late could overwrite the result of a newer purchase.
Generation counters
Each query increments a generation counter and captures its value. When a timeout or response handler runs, it first checks whether its generation is still current. If not, it does nothing. The explicit purchase flow uses its own counter, so a timeout from an earlier tap cannot cancel a later one.
_query_generation += 1
var gen := _query_generation
billing.queryPurchases("inapp")
await get_tree().create_timer(QUERY_TIMEOUT).timeout
if _is_query_in_flight and _query_generation == gen:
_is_query_in_flight = false # only the latest query may clear the flag
Debounce guards
A new resume query is skipped while one is already in flight, and resume queries are suppressed entirely while the player is in an explicit purchase or restore flow. The purchase callback is the source of truth for that flow, and a resume query must not race it.
5. Revoking Only When We Are Sure
Refunds are real, and a refunded purchase should eventually be withdrawn. Our early logic removed the entitlement whenever a query result did not include the product. That treats "I could not find it" as "it does not exist," which is wrong in several ordinary situations: the query failed, the Play Store app was updating, the device was signed in to a different account, or the response belonged to a narrower query than we assumed.
We rebuilt revocation around a few rules:
- Only authoritative answers count. Revocation is considered only after a successful, complete owned-purchases query; failed or partial answers are not treated as evidence.
- Pending is not absent. If the product is pending, nothing is revoked.
- Fresh grants get a grace period. A newly granted entitlement is not reconsidered right away, because Play's purchase cache can lag behind a purchase that just completed.
- Absence must repeat over time. One empty answer only records an observation. Revocation requires several confirmations spread over a meaningful period, so a single bad session cannot remove a purchase.
- Any sighting resets the clock. If the purchase shows up again, or the player buys again, all recorded absence observations are cleared.
When a revocation is confirmed, it is applied live: the store manager emits a signal, the purchase and restore buttons reappear, and on phones and tablets the ad controller re-enables ads without a restart. Granting goes through the same path in the other direction, so both transitions are tested the same way.
6. Testing It Without a Phone in Hand
All of this logic is in GDScript, so we test it headlessly with a mock billing singleton that can return any combination of purchased, pending, unacknowledged, failed, and empty responses, in any order and with simulated timestamps. The tests cover acknowledgment on startup, persistence happening before acknowledgment, stale-generation responses being discarded, and the full revocation sequence, including the cases where it must not revoke. Export-preset tests assert that the billing plugin is enabled on every Android target, including the TV preset. We then confirm on Android phone and Google TV emulators that the billing controller initializes cleanly and that D-pad focus lands on the purchase controls.
Lessons
- Acknowledgment is a recurring reconciliation step, not a one-time callback.
- Persist locally before telling the store you are done, and make that write atomic.
- Async results need an identity. A generation counter is a small, reliable way to give them one.
- Make the wrong outcomes uneven on purpose: grant quickly, and revoke only after repeated, authoritative evidence over time.