Skip to content
Guide · Receipt validation

Server-side receipt validation for Godot

Godot server-side receipt validation for App Store and Google Play purchases. Why the game can't be the judge, what to check, and the GDScript that calls it.

Updated

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.get for one-time products, purchases.subscriptionsv2.get for 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:

CheckIf it failsThe game gets
The transaction verifies with Apple or GoogleNothing recorded as a purchasereceipt_invalid (422): don't finish
It's paid, not pendingNothing granted yetpending: don't finish
It isn't a Family Sharing copyNothing grantedfamily_shared_ignored
It hasn't been refundedHandled as a refund: what it granted comes back offrevoked
A store sandbox purchase is on a sandbox key, or for a tester, or during App ReviewNothing granted, loggedsandbox_ignored
A product in your catalog sells itNothing grantedreceipt_invalid with reason unknown_product
Its account token names this player, or an account merged into themRefused, loggedaccount_mismatch (403): don't finish
No other account owns it alreadyNothing granted hereduplicate, owner other
It hasn't been granted beforeGranted oncegranted, 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:

AnswerWhat to do
ok, finish_transaction trueiOS: call finish(). Every status except pending is final.
ok, status pendingNothing yet. The store delivers it again when it's paid.
receipt_invalid, reason unknown_productLeave it unfinished. Map the product id in the console and publish, and the next launch settles it.
account_mismatchLeave it unfinished. The player may need their transfer or recovery code to get back to the account that bought it.
provider_unavailable or transport_errorRetry later with the same key. On the next launch the store hands it back anyway.
provider_not_configuredA 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.

PowerUp Console · sandbox
The PowerUp console's Payments page: settled revenue, subscription charges, webhook rejections and rebind alerts, above the payment rails.
Payments in the console counts webhook deliveries it refused, and players who had purchases moved to them by five or more restores in 30 days.

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.

Questions

Is validating a purchase on the device enough?

On iOS, StoreKit 2 checks Apple's signature on the device, which helps, but the device still decides what to grant, and a refund made while the game is closed only reaches a server. On Android, Google recommends sending every purchase token to a secure backend and verifying it there before granting anything.

Should I still use Apple's verifyReceipt endpoint?

No. Apple deprecated verifyReceipt and version 1 of its server notifications at WWDC23. StoreKit 2 signed transactions, checked with Apple's App Store Server Library, are the current path, and that's what PowerUp uses.

What stops a player posting the same receipt twice?

PowerUp records each transaction once, consumables by their transaction (the purchase token on Google Play) and items and subscriptions by their original purchase. A second post answers duplicate and grants nothing, and a purchase another account owns answers duplicate with the owner marked as someone else.

Do I still need store notifications if I validate every purchase?

Yes. Refunds, renewals, cancellations and billing problems often happen while your game is closed, and a server hears about them through App Store Server Notifications and Google's real-time developer notifications.

See it running on a real game.

We’ll walk you through the console with buggs.io’s setup.