When a player buys something in your Godot game, the billing plugin hands GDScript a result: a transaction object, a token, a Dictionary. Godot server-side receipt validation means sending that result to a server, checking it with Apple or Google, and only then granting coins or items. This guide covers why the check belongs on a server, what it has to cover for each store, exactly what PowerUp checks, and the GDScript that calls it. If you haven't wired the store plugins yet, start with Godot in-app purchases on iOS and Android.
Why the game can't be the judge
Your game runs on hardware the player controls. A modified build or a patched function can claim a purchase that never happened, and if the game grants on its own say-so, anything it grants can be faked. Google puts it plainly in its Play Billing integration guide: your app "should always verify the legitimacy of purchases before granting benefits", and it recommends doing that on a secure backend.
There are two more reasons a server has to be involved, whatever your threat model:
- Things happen while the game is closed. Refunds, subscription renewals, cancellations and Ask to Buy approvals reach your server through store notifications. A game that only checks on the device can miss a refund entirely.
- Purchases move between devices and accounts. A restore on a new phone, a second account on the same Apple ID, a purchase posted twice after a timeout. Only a server that remembers every transaction can grant each one exactly once, to the right player.
What a server has to check
App Store (StoreKit 2)
StoreKit 2 gives you each transaction as a JWS: a signed JSON payload with Apple's certificate chain in its header. A server should:
- Verify the chain up to Apple's root certificate, and the signature. Apple's App Store Server Library does both with its SignedDataVerifier, in Node, Java, Python and Swift.
- Check the bundle id, and for live transactions your app's App Apple ID, so a transaction from another app can't be replayed into yours.
- Read the environment, Sandbox or Production, and decide what a sandbox purchase is worth on a live server.
- Look at the ownership type (family shared or purchased), the revocation date, and the app account token you attached at purchase.
Apple deprecated verifyReceipt and version 1 of its server notifications at WWDC23. New code should verify signed transactions instead.
Google Play
A Play purchase gives you a purchase token, and the token alone proves nothing. A server should:
- Read the purchase back from the Google Play Developer API with a service account:
purchases.products.getfor one-time products,purchases.subscriptionsv2.getfor subscriptions. - Check its state (purchased, pending or canceled), the product id, the test flag, and the obfuscated account id you set when the sheet opened.
- Acknowledge or consume it within three days. Google refunds a purchase nobody acknowledged and revokes it.
For both stores
- Grant once per transaction, however many times it's posted and by whoever.
- Listen for refunds and renewals: App Store Server Notifications version 2, and Google Play's real-time developer notifications through Pub/Sub.
- Keep each purchase tied to the player who made it.
What PowerUp checks, in order
PowerUp.submit_receipt() posts the transaction. Before anything is locked, PowerUp verifies it: App
Store transactions with Apple's App Store Server Library (Production first, then Sandbox), Play purchases
by reading them back from Google with your service account. Then, holding that player's lock, it runs
these checks in this order:
| Check | If it fails | The game gets |
|---|---|---|
| The transaction verifies with Apple or Google | Nothing recorded as a purchase | receipt_invalid (422): don't finish |
| It's paid, not pending | Nothing granted yet | pending: don't finish |
| It isn't a Family Sharing copy | Nothing granted | family_shared_ignored |
| It hasn't been refunded | Handled as a refund: what it granted comes back off | revoked |
| A store sandbox purchase is on a sandbox key, or for a tester, or during App Review | Nothing granted, logged | sandbox_ignored |
| A product in your catalog sells it | Nothing granted | receipt_invalid with reason unknown_product |
| Its account token names this player, or an account merged into them | Refused, logged | account_mismatch (403): don't finish |
| No other account owns it already | Nothing granted here | duplicate, owner other |
| It hasn't been granted before | Granted once | granted, or duplicate on a repeat |
A few details matter in practice:
- The price the player saw. When the receipt carries the purchase intent's id, PowerUp grants what the product granted when the player opened the sheet, even if you published a change since. The intent only counts if it's this player's, for this product and store, and less than an hour old. Otherwise the purchase settles at the published terms, and never as another product.
- Purchases without a token. Offer codes and promo codes carry no account token. The first player to
post one owns it, and a later poster gets
duplicate, never an error. - Google acknowledgement. After the grant is saved, PowerUp acknowledges the purchase, or consumes it for a consumable, and retries in the background if Google doesn't answer.
Account mismatches, ignored sandbox and Family Sharing purchases, and ownership decisions are written to the game's audit record, so support can see why a player didn't get something.
Calling it from Godot
This autoload settles every store transaction in one place. Your store scripts call settle_apple() or
settle_google() with the transaction and the intent id, and it retries a store or network outage with
the same idempotency key, so a retry can never grant twice.
# receipts.gd: an autoload that settles store transactions with PowerUp.
# show_message() stands for your own UI.
extends Node
func _ready() -> void:
# Fires for every transaction PowerUp settles, whichever script posted it.
PowerUp.receipt_verified.connect(_on_receipt_verified)
func _on_receipt_verified(receipt: Dictionary) -> void:
show_message(PowerUpDisclosure.receipt_line(receipt))
var grants: Variant = receipt.get("grants")
if grants is Dictionary and not (grants.get("items", []) as Array).is_empty():
await PowerUp.fetch_entitlements() # so is_entitled() knows the new items
## App Store: tx is the StoreKit 2 transaction your iOS plugin delivered.
func settle_apple(tx: Variant, intent_id: String = "") -> void:
var r: Dictionary = await _submit("apple", {"jws": tx.jws_representation, "intentId": intent_id})
if r.ok and r.finish_transaction:
tx.finish()
elif not r.ok:
_explain(r)
# Never finish() on a failure: StoreKit hands the transaction back on the next launch.
## Google Play: p is a purchase from the billing plugin, type "inapp" or "subs".
func settle_google(p: Dictionary, type: String, intent_id: String = "") -> void:
var r: Dictionary = await _submit("google", {
"purchaseToken": p.purchase_token, "productId": p.product_ids[0],
"productType": type, "intentId": intent_id,
})
if not r.ok:
_explain(r)
# Never acknowledge or consume in the game: PowerUp does both after it grants.
## Posts one transaction, retrying outages with the same idempotency key.
func _submit(provider: String, payload: Dictionary) -> Dictionary:
var key := PowerUp.new_idempotency_key() # one per transaction, kept across retries
var r: Dictionary = {}
for attempt in 3:
if attempt > 0:
await get_tree().create_timer(5.0 * attempt).timeout
r = await PowerUp.submit_receipt(provider, payload, key)
if r.ok or not (r.code in ["provider_unavailable", "transport_error"]):
break
return r
func _explain(r: Dictionary) -> void:
match r.code:
"account_mismatch":
show_message("This purchase belongs to another account.")
"receipt_invalid":
push_warning("Store transaction not settled: %s" % r.error)
"provider_not_configured":
push_error("This store isn't set up for this key's mode in the PowerUp console.")
What each answer means for the store:
| Answer | What to do |
|---|---|
ok, finish_transaction true | iOS: call finish(). Every status except pending is final. |
ok, status pending | Nothing yet. The store delivers it again when it's paid. |
receipt_invalid, reason unknown_product | Leave it unfinished. Map the product id in the console and publish, and the next launch settles it. |
account_mismatch | Leave it unfinished. The player may need their transfer or recovery code to get back to the account that bought it. |
provider_unavailable or transport_error | Retry later with the same key. On the next launch the store hands it back anyway. |
provider_not_configured | A setup problem: the store's credentials are missing for this key's mode. |
The errors reference lists every code the API can return.
Refunds never come through the game
The other half of validation is listening. PowerUp takes App Store Server Notifications (version 2) and Google Play's real-time developer notifications for each game, and treats them as carefully as receipts:
- An Apple notification is verified the same way as a transaction, nested data included.
- A Google push is checked against Google's signed token when you've set one up, and either way nothing it says is applied as sent: PowerUp reads the purchase back from Google first. A refund only counts once Google's voided purchases list includes that exact purchase.
- Every delivery is recorded, rejected ones too, and the same event delivered twice never applies twice.
A full refund takes back exactly what the purchase granted: items, a pass's premium track (tiers already claimed stay), a store subscription, and coins. Coins the player already spent leave the wallet in debt, and the next coins they earn or buy pay that off first. A reversed refund puts everything back.

Build it yourself, or use PowerUp
You can build all of this. Apple's App Store Server Library handles the signatures, Google's API client reads purchases back, and the rest is a table keyed by transaction id, two webhook endpoints, an acknowledgement retry, and refund rules for coins that are already spent. The work is in the edge cases: pending payments, Family Sharing, App Review's sandbox purchases on your live build, renewals, merged accounts and refunds after the coins are gone.
If you already run a backend for your Godot game, compare the options to see what it covers. PowerUp gives you the receipt checks, both store webhooks, the coin ledger and the catalog together, with nothing to host. The in-app purchases overview shows how they fit, and pricing has the plans.