This guide adds Godot Stripe Checkout to a web or desktop build. The player taps an offer, pays on Stripe's hosted page, and comes back to a wallet that has already updated. It uses the PowerUp addon, which does the server half for you, but the hard part on the web is timing, and those rules apply however you build it. You'll create the session early, open it inside the tap, handle a blocked tab, poll for the result and give subscribers a way to cancel.
How Stripe Checkout fits a Godot game
Stripe Checkout is a payment page Stripe hosts. Your game never sees card details. The flow has a client half and a server half:
- A server creates a Checkout Session with your Stripe secret key and gets back a URL.
- The game opens that URL in a browser tab, or in the system browser on desktop.
- The player pays. Stripe sends a signed webhook to the server, and the server grants the purchase.
- The game asks the server for the result and updates the screen.
Without PowerUp, steps 1 and 3 are yours to build: an endpoint that creates sessions, and a webhook handler that
checks Stripe's signature and grants each payment once, however many times Stripe retries it. Stripe's
fulfillment guide covers that side. With PowerUp, both run on
PowerUp with your own Stripe account, and the game makes three calls: start_checkout(), open_checkout() and
checkout_status().
Before you start
- A Godot 4 web or desktop build. On the web, use a single-threaded export: leave Thread Support off in the Web export preset (Godot 4.3 and later). Threaded exports need cross-origin isolation headers, and those get in the way of the checkout tab.
- The PowerUp addon in
addons/powerup/, with thePowerUpautoload initialized and the player registered. Getting started covers that part. - Stripe connected in the console under Payments → Stripe, with a test-mode key to start.
- Your web addresses listed in the console under Integrations, so Stripe can send players back to your game.
- A product priced for web or desktop in the storefront.
The bundled PowerUpStorefront node is handy in the sandbox, but it buys through a sandbox-only call. Real money
goes through the checkout calls below, so build your own product sheet around them.
Step 1: create the session when the sheet opens
Make the checkout session as soon as the player opens an offer, not when they tap Pay. By the time they've read the price, the URL is ready. Keep Pay disabled until it is.
# product_sheet.gd: the sheet that opens when a player taps an offer.
# Web and desktop builds. Phones use create_purchase_intent() instead.
extends Control
var _offer: Dictionary = {}
var _checkout: Dictionary = {}
var _opened := 0
func open_for(offer: Dictionary) -> void:
var price := PowerUpDisclosure.display_price(offer)
if price == "":
return # an offer that can't show its price is never a buy button
_opened += 1
var mine := _opened
_offer = offer
_checkout = {}
%Price.text = price
%Pay.disabled = true
%Pay.text = "Preparing…"
%ContinueHere.hide()
%TryAgain.hide()
show()
var res: Dictionary = await PowerUp.start_checkout(offer.key)
if mine != _opened or not visible:
return # the player closed the sheet or opened another offer meanwhile
if not res.ok:
_show_refusal(res)
return
_checkout = res.checkout
%Pay.disabled = false
%Pay.text = "Pay %s" % price
PowerUp runs every purchase check before it creates the session, so a refusal comes back now, while the player is
still reading, not after they've paid. Branch on code:
| Code | What it means | What to show |
|---|---|---|
age_gate_required | The player hasn't given their age | Your age question, then try again |
parental_consent_required | A child without a parent's consent | Say a parent needs to agree first |
spend_limit_reached | Over a child's or teen's monthly limit | Say the limit resets next month |
already_owned | A durable item, running subscription or pass track they have | Mark the offer as owned |
purchase_limit_reached | The offer's per-player limit is used up | Hide or grey out the offer |
not_available | Not on sale now, or not on this platform | Refresh the store |
provider_not_configured | Stripe isn't connected for this mode | A setup problem: check the console |
checkout_failed | Stripe refused or couldn't be reached | Offer Try again |
func _show_refusal(res: Dictionary) -> void:
push_warning("checkout refused: %s (%s)" % [res.code, res.error])
match res.code:
"age_gate_required":
%AgeSheet.open() # your age question; reopen the offer after it
"parental_consent_required":
%Status.text = "A parent needs to agree before you can buy this."
"spend_limit_reached":
%Status.text = "You've reached this month's spending limit."
"already_owned":
%Status.text = "You already own this."
"checkout_failed":
%Status.text = "We couldn't reach the checkout."
%TryAgain.show()
_:
%Status.text = "This can't be bought right now."
The session lasts 30 minutes. If the player opens the same offer again within 20 minutes, start_checkout()
hands back the open session (reused is true) instead of making a new one.
Step 2: open it inside the Pay handler
This is the rule that trips people up. A browser lets a page open a new tab only while it's still handling the
click. Put an await between the tap and the tab, and the browser treats it as a popup and blocks it. So the Pay
handler opens the URL it already has, and waits for nothing.
func _on_pay_pressed() -> void:
# No await before this line: the browser only allows the new tab
# while it is still handling the tap.
if PowerUp.open_checkout(_checkout):
watch(_checkout.checkoutId)
else:
%ContinueHere.show() # the browser blocked the tab
Godot's single-threaded web export handles input inside the browser's own event, so a button's pressed signal
counts as part of the click. On desktop, open_checkout() opens the system browser instead, and there's nothing
to block.
Step 3: when the tab is blocked, continue in this tab
Some browsers block the tab anyway, and some players have strict settings. open_checkout() returns false when
that happens. Offer a second button that sends the player to Stripe in the same tab.
Leaving the page isn't a popup, so waiting first is fine here. Create a new session in "redirect" mode: after
paying, Stripe brings the player back to your game's own address, and the game picks the checkout up when it
loads. (open_checkout(checkout, true) also works, but a session made for a new tab finishes on a "payment
received" page instead of your game.)
# `save` stands for your own save system.
func _on_continue_here_pressed() -> void:
var res: Dictionary = await PowerUp.start_checkout(_offer.key, "redirect")
if not res.ok:
_show_refusal(res)
return
# The game is about to unload. Keep the id in your save as a backup.
save.pending_checkout = res.checkout.checkoutId
save.write_now() # your own save code
PowerUp.open_checkout(res.checkout)
When the game loads again, look for a checkout to resume. checkout_return_id() reads the id a redirect checkout
came back with and removes it from the address bar, so a reload doesn't pick it up twice.
# In your boot code, after PowerUp.initialize() and set_player_token():
func _resume_checkout() -> void:
var id := PowerUp.checkout_return_id() # "" unless a redirect checkout just came back
if id == "":
id = save.pending_checkout
if id != "":
%ProductSheet.watch(id)
The return address has to be one of the web addresses you listed in the console. That stops anyone from using your checkout to send players somewhere else.
Step 4: poll for the result
Don't wait for a callback, and don't trust the return page. Stripe tells PowerUp through a signed webhook, and the
game asks for the status. checkout_poll_delay() gives the waits between polls: 3, 3, 5 and 8 seconds, then every
10 seconds. Poll at once as well when the player comes back to the game.
var _watching := ""
var _on_visible = null # keep a reference, or the browser callback is freed
func _ready() -> void:
PowerUp.checkout_completed.connect(_on_checkout_completed)
if OS.has_feature("web"):
_on_visible = JavaScriptBridge.create_callback(_on_visibility_change)
JavaScriptBridge.get_interface("document").addEventListener("visibilitychange", _on_visible)
func watch(checkout_id: String) -> void:
_watching = checkout_id
var attempt := 0
while _watching == checkout_id:
await get_tree().create_timer(PowerUp.checkout_poll_delay(attempt)).timeout
attempt += 1
await _poll()
func _poll() -> void:
var id := _watching
if id == "":
return
var res: Dictionary = await PowerUp.checkout_status(id)
if not res.ok or _watching != id:
return # a dropped request: the next poll tries again
var status: String = res.checkout.status
%Status.text = PowerUpDisclosure.checkout_line(status)
if status in ["completed", "expired", "failed"]:
_watching = ""
save.pending_checkout = ""
%TryAgain.visible = status != "completed"
func _on_visibility_change(_args: Array) -> void:
if JavaScriptBridge.eval("document.visibilityState") == "visible":
_poll() # web: the player came back from the checkout tab
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_IN:
_poll() # desktop: the player came back from the browser
func _on_checkout_completed(_result: Dictionary) -> void:
# Fires once per checkout. The coins or items are already granted and
# wallet_changed has fired, so a wallet label connected to it is current.
hide()
A checkout is in one of four states:
- open: the player hasn't paid yet.
checkout_line()says "Finish paying in the checkout window, then come back." - completed: paid and granted.
checkout_completedfires once, along withwallet_changed. - expired: nobody paid within 30 minutes. Offer Try again, which starts a new checkout.
- failed: the payment didn't go through. Offer Try again.
If Stripe's webhook is slow, PowerUp also asks Stripe about the session itself while the game polls, so polling is all the game has to do. Closing the sheet doesn't cancel anything: if the player pays later, the purchase still lands, and the wallet shows it the next time it loads.
Step 5: desktop builds
On Windows, macOS and Linux, open_checkout() opens the player's default browser. After paying, the browser lands
on a short PowerUp page that says the payment was received and tries to close itself. Back in the game,
NOTIFICATION_APPLICATION_FOCUS_IN triggers a poll, and the result is there.
Desktop builds always come back to that page, so they don't need a return address. If you ship on Steam, read Steam's rules on in-game purchases first: the web checkout overview has the details.
Subscriptions and the billing portal
A subscription product goes through the same start_checkout() call. The session comes back with mode set to
"subscription", and Stripe bills the player each period after that. Two things are different.
First, the offer card has to carry the price per period, the renewal terms and how to cancel.
PowerUpDisclosure.subscription_lines(offer) returns those lines, or nothing if a part is missing, and the
publish checks refuse a subscription without them.
Second, subscribers need a way to cancel. For a Stripe subscription that's Stripe's billing portal.
subscription_portal() returns a short-lived portal URL. Fetch it when the Manage sheet opens and open it in the
tap, with the same timing rule as checkout.
var _manage_url := ""
func open_manage_sheet() -> void:
_manage_url = ""
show()
var res: Dictionary = await PowerUp.fetch_subscription()
if not res.ok or res.subscription == null:
return
var sub: Dictionary = res.subscription
%Renewal.text = PowerUpDisclosure.sub_status_line(sub) # "Renews Oct 31" or "Ends Oct 31"
%WhereToCancel.text = PowerUpDisclosure.manage_line(sub)
%Manage.text = PowerUpDisclosure.manage_button_text(sub)
if sub.manage.kind == "stripe_portal":
var portal: Dictionary = await PowerUp.subscription_portal()
if portal.ok:
_manage_url = portal.url
else:
_manage_url = PowerUpDisclosure.manage_url(sub) # App Store or Google Play
func _on_manage_pressed() -> void:
if _manage_url != "":
PowerUp.open_url(_manage_url) # inside the tap, like open_checkout()
The portal URL expires quickly, so fetch a fresh one each time the sheet opens. A player who subscribed through the App Store or Google Play and now plays on the web gets that store's page instead.
Testing it
- Use a sandbox key. A sandbox key checks out with your Stripe test-mode key, and a production key with your live one. Pay with Stripe's test card 4242 4242 4242 4242, any future expiry date and any CVC.
- Block the tab on purpose. Turn popup blocking up in your browser and make sure Continue in this tab brings you back to the game with the purchase granted.
- Walk away. Open a checkout and close it without paying. The sheet should keep saying "open", and after 30 minutes the status turns to expired.
- Test without a network.
PowerUp.set_transport()sends every request to your own function instead of HTTP, and the addon ships recorded answers, so you can run the sheet's states in a headless test.
Checklist
- Single-threaded web export, and your web addresses listed in the console
start_checkout()when the sheet opens, Pay disabled until it answersopen_checkout()in the Pay handler, with noawaitbefore it- A Continue in this tab button for blocked tabs, and
checkout_return_id()at boot - Polling with
checkout_poll_delay(), plus a poll when the player comes back - Try again for expired and failed checkouts
- Subscription cards that show price, renewal and cancel lines, and a Manage button that opens the portal
For the bigger picture, including tax with Stripe Managed Payments and one wallet across all three checkouts, see web checkout for Godot games. The full method list is in the payments docs.