Skip to content
Docs · Getting started

Getting started with the Godot SDK

Install the PowerUp Godot monetization SDK, initialize it with your key, keep the player token safe and load your first storefront.

Updated

This page takes the PowerUp Godot monetization SDK from a folder on disk to a store on screen. You'll install the addon, initialize it with your key, register the player and keep their token, ask the age question, and fetch your first storefront. The code is GDScript for Godot 4 and matches SDK 0.2.0.

Before you start

You need:

  • Godot 4. The SDK was built and tested on 4.7.
  • The addons/powerup/ folder. It comes with early access: there's no public download.
  • A sandbox publishable key and your API base URL. Both are in the console under Integrations → SDK.
  • A stable id for each install of your game. If you already have one, use it. If not, the code below makes one.

Install the addon

  1. Copy addons/powerup/ into your project's addons/ folder.
  2. Open Project → Project Settings → Plugins and enable PowerUp Monetization.

Enabling the plugin registers the PowerUp autoload, and that's all it does. It doesn't replace a node or override a control. To add the autoload by hand instead, put this under [autoload] in project.godot:

PowerUp="*res://addons/powerup/powerup.gd"

The addon's demo/ folder holds recorded API answers for testing without a network (see the last section). Ship it or leave it out.

Keys and environments

PowerUp has two kinds of key, and each comes in a sandbox and a production version.

KeyWhere it livesWhat it can reach
PublishableInside your game buildThe player routes of one game. It isn't a secret.
ServerYour game server onlyThe game server's routes, such as match reports and match ticket checks. It never ships in a build, and the SDK never needs it.

The key decides the environment. With a sandbox key:

  • PowerUp.purchase(sku_key) makes a test purchase with no payment, so you can try your store before any payment provider is connected.
  • Stripe checkouts use your Stripe test key, and store test purchases (TestFlight, Google Play license testers) are accepted.
  • The draftConfig option serves your unpublished draft instead of the published version.
  • The test ad provider hands out rewards.

With a production key, players only ever get the version you published, and real money moves only through the three checkouts.

Players are kept apart too. A player registered with a sandbox key can't be used with a production key, or the other way round: the API answers wrong_environment. Keep sandbox and production saves separate, or clear the saved token when you switch keys.

PowerUp Console · sandbox
The PowerUp console's Integrations and SDK page: active API keys, whether Stripe, the App Store and Google Play are ready in test and live, and game settings.
Integrations in the console: your keys, whether each checkout is ready to sell in test and live, and game settings such as web origins.

Initialize PowerUp

Call initialize() once, early. The autoload is in the scene tree by the time your first scene's _ready() runs, which is when the SDK can make requests.

const API_URL := "https://…"   # your API base URL, from Integrations → SDK

func _ready() -> void:
    PowerUp.initialize("YOUR_PUBLISHABLE_KEY", client_id, {
        "baseUrl": API_URL,         # required: the SDK has no default host
        "client": "mygame/1.0.0",   # optional: your build's name and version
    })

initialize(key, player_id, options) takes your publishable key, the install's stable id, and these options:

OptionWhat it does
baseUrlRequired. Your API base URL.
clientYour build's name and version, such as mygame/1.0.0, sent with every request.
appVersionYour build's version. It defaults to the project's application/config/version. PowerUp matches each item's version rules against it, so a build only sees and sells items it can show. With no version, it only gets items every version has.
platformweb, ios, android or desktop. Detected for you; a web export in a phone browser is web.
playerTokenA saved token. The same as calling set_player_token().
draftConfigtrue to preview the unpublished draft. Sandbox keys only.
jurisdictionKept for older games. The server ignores it and goes by the player's own record, so leave it out.
verbosetrue logs the initialize line and every failed call as a warning.

Register the player and keep the token

PowerUp knows a player by a token it hands out once. This is the part that's easy to get wrong, so here's the contract:

  • The client id is your stable id for this install. PowerUp keeps it as the player's external id. On iOS and Android it's also the player's identity with the store, so a new client id is a new account to the App Store and Google Play as well.
  • register_player(client_id, display_name) asks for a token. A new one comes back once, through the player_token_issued signal.
  • Save the token next to the client id, and keep both out of anything like "erase save data".
  • On the next launch, pass the saved token to set_player_token(). Calling register_player() with a token set confirms it instead of making a new one.
# boot.gd: your first scene, or an autoload of your own listed after PowerUp
const API_URL := "https://…"                          # Integrations → SDK
const IDENTITY_PATH := "user://powerup_identity.cfg"  # never wiped by "erase save"

var _identity := ConfigFile.new()

func _ready() -> void:
    _identity.load(IDENTITY_PATH)
    var client_id: String = _identity.get_value("player", "client_id", "")
    if client_id == "":
        client_id = Crypto.new().generate_random_bytes(16).hex_encode()   # 32 hex characters
        _remember("client_id", client_id)

    PowerUp.initialize("YOUR_PUBLISHABLE_KEY", client_id, {"baseUrl": API_URL, "client": "mygame/1.0.0"})
    PowerUp.player_token_issued.connect(func(token: String) -> void: _remember("token", token))
    # A transfer or recovery code moved this install onto another account: keep its id.
    PowerUp.player_adopted.connect(func(external_id: String, _token: String) -> void: _remember("client_id", external_id))

    var token: String = _identity.get_value("player", "token", "")
    if token != "":
        PowerUp.set_player_token(token)
    # New install: 201 and a token. Saved token: 200, confirmed.
    var reg: Dictionary = await PowerUp.register_player(client_id, "Player")
    if not reg.ok:
        match reg.code:
            "player_exists", "player_token_invalid":
                _remember("token", "")
                _show_recovery()   # a recovery code, a transfer code, or Restore Purchases
            _:
                push_warning("PowerUp: %s (%s)" % [reg.error, reg.code])
        return

    await _check_age()
    await _load_store()

func _remember(key: String, value: String) -> void:
    _identity.set_value("player", key, value)
    _identity.save(IDENTITY_PATH)

If the token is lost, registering the same client id again answers player_exists. That's on purpose: knowing someone's client id must never be enough to take their account. player_token_invalid means the token was revoked (support cut it off, or the account was merged or deleted); the SDK drops it and you should forget your copy too. Either way, the account comes back with a recovery code the player saved or a transfer code from another device. On a phone there's one more way: start a new account (a new client id) and use Restore Purchases, which moves store purchases to it but never coins. There's no email recovery. Players and accounts covers all three.

Ask the age question

Purchases and ads wait on a neutral age question. Until it's answered, buy buttons read "Confirm your birth year to buy" and no ads are shown. fetch_me() says whether to ask:

func _check_age() -> void:
    var me: Dictionary = await PowerUp.fetch_me()
    if me.ok and me.me.player.ageGateRequired:
        _show_age_prompt()   # at a calm moment; then: await PowerUp.set_birth_year(year)

A player answers once (after that, only support can change it), and PowerUp keeps the age band the year implies, not the year itself. Ask in the lobby or when the player first taps Buy or Watch, not on a death screen.

Load your first storefront

func _load_store() -> void:
    var r: Dictionary = await PowerUp.fetch_storefront()
    if not r.ok:
        %Status.text = "The store isn't available right now."
        return
    for offer: Dictionary in r.storefront.offers:
        var button := PowerUpOfferButton.new()
        button.configure(offer)   # it disables itself for anything that can't be sold
        button.offer_pressed.connect(_on_offer_pressed)
        %OfferGrid.add_child(button)

func _on_offer_pressed(offer: Dictionary) -> void:
    # Sandbox keys only. Real money goes through the checkouts on the payments page.
    var bought: Dictionary = await PowerUp.purchase(offer.key)
    if bought.ok:
        %Status.text = "Bought %s" % offer.name
    else:
        %Status.text = PowerUpDisclosure.blocking_text(bought.code)   # words for players
        push_warning(bought.error)                                     # words for you

offers lists what this player can buy on this platform right now, with prices and terms already worked out. If it's empty, you probably haven't published yet: players only see published versions. Publish from the console, or pass "draftConfig": true with a sandbox key to see your draft.

Storefront and offers explains every field and the display rules, and payments replaces that sandbox purchase() with real checkouts.

Read results and signals

Every call to the server returns a Dictionary. Await it and check ok:

FieldWhenWhat it holds
okAlwaystrue or false
errorFailuresA message written for you, not for players
codeFailuresWhat to branch on, such as age_gate_required or transport_error
statusThe server answered a failureThe HTTP status
detailsSome failuresSpecifics, such as balance and needed for insufficient_coins
retry_afterrate_limitedSeconds to wait

Signals cover the same ground for UI that listens instead of awaiting: wallet_changed after coins move, home_loaded, badges_changed, checkout_completed, receipt_verified, and request_failed for any call that failed. The SDK reference lists all 16.

Two habits help from day one:

  • Count down on the server's clock. Every answer carries the server's time, and the SDK keeps it against the monotonic clock. PowerUp.seconds_until(ends_at) counts to an end time correctly even when the device clock is wrong, and returns -1 when there's no end time, so you draw no countdown.
  • Retry with the same key. Purchases and claims take an optional idempotency key as their last argument. Make one per player action with PowerUp.new_idempotency_key() and pass the same one if you retry that action: the server replays its first answer instead of acting twice.

Test without a network

set_transport(callable) sends every request to your function instead of over HTTP. With the recorded answers in addons/powerup/demo/, a store screen runs in the editor or in a headless test with no server:

# offline_store.gd: answer the SDK from the recorded demo answers
const DEMO := "res://addons/powerup/demo/"
var _answers := {}

func _ready() -> void:
    for file in ["register", "me", "home", "storefront", "catalog"]:
        var recorded: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(DEMO + file + ".json"))
        _answers[recorded.route] = recorded
    PowerUp.set_transport(_answer)
    PowerUp.initialize("YOUR_PUBLISHABLE_KEY", "offline-player", {"baseUrl": "http://127.0.0.1"})

func _answer(request: Dictionary) -> Dictionary:
    var route := "%s %s" % [request.method, str(request.path).get_slice("?", 0)]
    if _answers.has(route):
        return {"status": int(_answers[route].status), "body": _answers[route].body}
    return {"status": 404, "body": {"error": {"code": "not_found", "message": "nothing recorded for " + route}}}

Each recorded file names its route, such as GET /v1/storefront, with the status and body the API gave. The transport gets method, path, url, headers, body and body_text, and returns status and body, or error for a failure that never reached a server. It can be a coroutine. Pass Callable() to go back to HTTP.

Before you ship

  • Swap the sandbox key for a production key, and keep production saves apart from sandbox ones.
  • Publish your config. Players only see published versions, and publishing runs 48 automatic checks first (49 with Stripe Managed Payments).
  • Connect your payment providers and follow payments for each platform.
  • Give players a way back to their account: after the first purchase, offer to save a recovery code.

Questions

Where do I find my API URL and publishable key?

In the console under Integrations → SDK. You make sandbox and production keys there too. The publishable key goes in your game, and the server key stays on your game server.

Is it safe to ship the publishable key inside my game?

Yes. It only reaches the player routes of one game, and everything that charges or grants is checked on the server. The server key is the one to keep secret, and it never goes in a build.

What happens if a player clears their save data?

If the token is gone, registering the same client id again answers player_exists. The player gets the account back with a recovery code or a transfer code. Restore Purchases on a new account brings back store purchases, but not coins. Keep the client id and the token out of "erase save" and it won't come up.

Can I try the SDK without a server?

Yes. set_transport() answers every request from a function of yours, and the addon ships recorded answers in its demo folder, so a store screen runs in the editor or a headless test with no network.

See it running on a real game.

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