Skip to content
Guide · Payments

Stripe checkout in a Godot game

A Godot Stripe Checkout guide for web and desktop builds. Create the session early, open it inside the tap, poll for the result, let subscribers cancel.

Updated

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:

  1. A server creates a Checkout Session with your Stripe secret key and gets back a URL.
  2. The game opens that URL in a browser tab, or in the system browser on desktop.
  3. The player pays. Stripe sends a signed webhook to the server, and the server grants the purchase.
  4. 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 the PowerUp autoload 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:

CodeWhat it meansWhat to show
age_gate_requiredThe player hasn't given their ageYour age question, then try again
parental_consent_requiredA child without a parent's consentSay a parent needs to agree first
spend_limit_reachedOver a child's or teen's monthly limitSay the limit resets next month
already_ownedA durable item, running subscription or pass track they haveMark the offer as owned
purchase_limit_reachedThe offer's per-player limit is used upHide or grey out the offer
not_availableNot on sale now, or not on this platformRefresh the store
provider_not_configuredStripe isn't connected for this modeA setup problem: check the console
checkout_failedStripe refused or couldn't be reachedOffer 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_completed fires once, along with wallet_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 answers
  • open_checkout() in the Pay handler, with no await before 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.

Questions

Why does the browser block my Stripe Checkout tab in a Godot web export?

Browsers only let a page open a new tab while it is handling the click itself. If your Pay handler waits for the network before opening the checkout, the browser treats it as an unwanted popup. Create the session when the product sheet opens, and open it in the Pay handler with no wait in between.

Can I put my Stripe secret key in my Godot game?

No. Anything inside an export can be pulled out of it, and a secret key can charge, refund and read your whole Stripe account. The checkout session has to be created on a server. With PowerUp, PowerUp creates it with your key, which it stores encrypted.

How does the game know the payment went through?

It asks. Stripe tells the server through a signed webhook, and the game polls the checkout's status every few seconds and when the player comes back to it. Don't grant anything just because the player landed back on your page.

Does Stripe Checkout work with a multithreaded Godot web export?

Threads need cross-origin isolation headers, and those get in the way of opening the checkout in a new tab. Use a single-threaded web export, which Godot has supported since 4.3.

See it running on a real game.

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