These are the Godot SDK error codes your game can meet when it calls PowerUp, what causes each one, and what to do about it. Every code here is stable: it keeps one meaning on every call, so you can branch on it safely. The codes come from two places: the SDK itself, for problems it catches before or instead of a request, and the server, for everything it refuses. What each method returns on success is in the SDK reference.
The result shape
Every call that talks to the server returns a Dictionary with ok. A failure carries these fields:
| Field | Always | What it holds |
|---|---|---|
ok | yes | false. |
error | yes | A message written for you and your logs, not for the player. |
code | yes | What happened, as a stable string. Branch on this. |
status | when the server answered | The HTTP status, such as 409. Missing for failures the SDK decided on its own. |
details | when the server sent them | Specifics for the code, such as the new price after price_changed. |
retry_after | on rate_limited | Seconds to wait before trying again. |
idempotency_key | when one was sent | The key to reuse if you retry this same action. |
Three older calls differ: fetch_entitlements() returns an empty Array on failure, fetch_player() an
empty Dictionary, and record_evidence() nothing at all. The request_failed(endpoint, reason) signal
fires for every request that didn't succeed, whether the network failed or the server refused, which
makes it a good place for logging. It doesn't fire for arguments the SDK rejects before sending.
# item is one entry of fetch_catalog()'s items, with price {coins} while it's for sale.
func _on_buy_pressed(item: Dictionary) -> void:
var res := await buy_with_retry(item.key, int(item.price.coins))
if res.ok:
return
var details: Dictionary = res.get("details", {})
match res.code:
"price_changed":
_ask_again(item, details.priceCoins) # show the new price, never charge it silently
"insufficient_coins":
_offer_coins(details.needed - details.balance)
"wallet_in_debt":
_toast("Coins are on hold until a refund is repaid")
"already_owned":
_mark_owned(item)
_:
_toast("That didn't work. Try again in a moment.")
push_warning("%s (%s)" % [res.error, res.code])
Retrying safely
Calls that spend or grant (buying, claiming, redeeming a reward, starting a checkout) send an idempotency key. The server stores its answer under that key for 24 hours, in the same transaction as the effect, so a request that committed and then lost its answer is replayed, never run twice.
- Use one key per player action, and the same key for every retry of that action. Leave the
idempotency_keyargument empty and the SDK makes one and returns it in the result, on failures too. - Retry
transport_errorand 5xx statuses with that key. A server error isn't stored, so the retry runs it fresh. idempotency_conflictmeans the key was already used. Withdetails.inFlighttrue, the first attempt is still running: wait a moment and retry with the same key. With it false, the same key was sent with a different request, which is a bug: make a new key for each new action.- Don't resend any other 4xx with the same key. A refusal is stored like any answer, so the same key
brings back the same refusal. Fix the cause first, then make a new key for the new attempt. After
rate_limited, waitretry_afterseconds and try again with a new key. - Never retry a code redeem automatically. Redeeming a transfer or recovery code, like
register_player(), sends no key, because its answer carries a token that is never stored. Every wrong code also counts toward a limit.
func buy_with_retry(item_key: String, price: int) -> Dictionary:
var key := PowerUp.new_idempotency_key() # one key for this tap, every attempt
var res: Dictionary = {}
for attempt in 3:
res = await PowerUp.buy_item(item_key, price, key)
if res.ok or not _retryable(res):
return res
await get_tree().create_timer(1.0 + attempt * 2.0).timeout
return res
func _retryable(res: Dictionary) -> bool:
if res.code == "transport_error":
return true
if res.code == "idempotency_conflict":
return bool((res.get("details", {}) as Dictionary).get("inFlight", false))
return int(res.get("status", 0)) >= 500
Codes the SDK decides itself
These come back without a request, or because the request never got an answer. None of them has a server status, except where noted.
| Code | What happened | What to do |
|---|---|---|
bad_request | An argument was wrong: an empty key, a value outside its allowed list, a club index past 63. The server sends the same code (status 400) for a malformed request. | Fix the call. The message names the argument. |
not_initialized | No API key or no base URL, or purchase() before initialize(). | Call initialize() with baseUrl first. |
player_token_required | A player call with no token set. The server answers the same (401) to a player call that arrives without one. | Call register_player() or set_player_token() first. |
transport_error | No answer: the host couldn't be reached or resolved, TLS failed, or the request ran past 15 seconds (2.5 for a match ticket). Also any failure your set_transport() function reports. | Show an offline state and retry with the same key. For a match ticket, join without one. |
not_in_tree | A call made before the PowerUp node joined the scene tree, so Godot couldn't start the request. | Await a frame, or make the call from a node's _ready(). |
request_failed | Godot couldn't start the request at all. | Check the base URL, then retry. |
bad_response | The server answered without something the SDK needs, such as a redeemed code with no token. | Retry later. If it repeats, report it. |
transfer_code_invalid / recovery_code_invalid | The typed text can't be a code, so it wasn't sent. The server uses the same codes (422) for a code that is wrong, used or expired. | Let the player correct it. A local refusal never counts toward the wrong-code limit. |
recovery_code_not_shown | A retried create_recovery_code() whose first answer was lost. A code was made that nobody saw. Status 201. | Make a new one with a fresh key. It replaces the unseen one. |
http_ plus the status | An error answer without the usual error body, for example from a proxy in front of the API. | Treat 5xx as retryable and anything else as a failure. |
Codes from the server
Keys, players and requests
| Code | Status | What happened | What to do |
|---|---|---|---|
unauthorized | 401 | The API key is missing, unknown or revoked. | Check the build's key against the console. A revoked key needs a new build. |
player_token_invalid | 401 | The saved token was revoked: by support, by a merge, or by a deletion. | Forget the saved token, then offer the ways back to an account. |
player_exists | 409 | register_player() for a client id that already has a token this install lost. | Offer the recovery code, a transfer code or Restore Purchases. |
gone | 410 | register_player() for an account that was merged into another. | Same as above. |
wrong_key_class | 403 | A server key was used in the game client. | Ship the publishable key. Server keys belong on your game server only. |
wrong_environment | 403 | A test player with the live key, or a live player with the test key. | Use the key the player was created with. |
sandbox_only | 403 | A test-only feature on a live key: purchase(), the test ad provider, or the draft preview. On a live game, only players marked as testers may use them. | Use the real payment and ad flows in production. |
ownership_unproven | 403 | An old account claimed without proof tried to delete itself, make a code or merge. | Support verifies the owner in the console, then it works. |
not_found | 404 | An unknown key or id, a club route while clubs are switched off, or fetch_progress() on a server without online progress. | Hide the feature, or show the device's own progress. |
idempotency_conflict | 409 | The idempotency key is in use. See retrying safely. | Retry later with the same key, or fix the reused key. |
rate_limited | 429 | Too many requests. A player gets 30 requests every 10 seconds, with bursts up to 60. Accounts have tighter limits of their own. | Wait retry_after seconds. details.limit names the limit that was hit. |
internal_error | 500 | Something failed on the server. | Retry with the same key. |
Age, consent and spending
These come from the rules on players and accounts. Each one is a decision the server made about this player, so don't try to work around it in the client.
| Code | Status | What happened | What to do |
|---|---|---|---|
age_gate_required | 403 | The player's age is unknown and the call needs it: a purchase with the age gate on, or an ad or a boost paid with coins or an ad. | Show the birth-year prompt at a calm moment, then try again. |
age_already_set | 409 | set_birth_year() was already answered. | Nothing. Only support can change it. |
parental_consent_required | 403 | A child without a parent's consent recorded. | Show the store read-only. A parent contacts your support. |
spend_limit_reached | 409 | The purchase would take a teen or child past the game's monthly cap. | Show spend_limit_notice(). |
minor_restricted | 403 | A boost paid with coins or an ad, or a boost ad, for a teen or child when the game doesn't allow it. | Hide that payment option. PowerUpDisclosure.boost_blocked_text() says why. |
locked | 403 | A premium pass reward without premium, or a match ticket for an event the player has no access to. | Show the gate, such as PowerUpDisclosure.event_gate_text(). |
Coins, items and rewards
| Code | Status | What happened | What to do |
|---|---|---|---|
insufficient_coins | 409 | Not enough coins. details.balance and details.needed (the price). | Show the shortfall. Offer coins if your game sells them. |
price_changed | 409 | The coin price moved since the player saw it. details.priceCoins is the price now. | Show the new price and ask again. Nothing was charged. |
wallet_in_debt | 409 | A refund took back coins the player had already spent. details.debt is what's owed. | Say coins are on hold. Spending resumes once the debt is repaid by new coins. |
already_owned | 409 | The player owns it already, by any route. | Mark it owned and refresh. |
not_available | 409 | Not on sale now, not on this platform, or a mode a boost doesn't allow. details.reason is rail_unavailable when this device pays another way, or mode. | Refresh the store. On rail_unavailable, use the other payment flow. |
purchase_limit_reached | 409 | The player has bought this as many times as it allows. | Show it as unavailable. |
update_required | 409 | This build can't use the item or offer. | Ask the player to update the game. |
cooldown / daily_cap | 409 | Too soon, or too often today. details.nextAvailableAt says when. | Count down with seconds_until(). |
already_claimed | 409 | Nothing left to claim. | Refresh. |
not_completed | 422 | A quest, event level or pass tier claimed before it's done, or an ad not yet verified. | Refresh. The claim button shouldn't have been enabled. |
Payments
The flows behind these are on payments.
| Code | Status | What happened | What to do |
|---|---|---|---|
checkout_failed | 502 | Stripe refused the checkout or couldn't be reached. | Offer Try again, which starts a new checkout. |
provider_not_configured | 503 | The payment provider isn't set up for this game in this mode (test or live). | A setup problem: add the credentials in the console. Hide the buy button meanwhile. |
provider_unavailable | 502 | Google Play's API couldn't be reached to check a purchase. | Retry later. Leave the transaction unfinished, and the store replays it. |
receipt_invalid | 422 | A store transaction that doesn't verify. details.reason is unknown_product when no product in your config sells it. | Leave it unfinished until the product is mapped in the console. |
account_mismatch | 403 | The store purchase belongs to an unrelated account. | Don't finish the transaction. |
A few answers that look like failures come back with ok: true and say so in the body: a receipt that
was a duplicate, a test purchase, or a Family Sharing purchase; a restore with skipped entries; and an
ad session that was rejected. Read the body, not just ok.
Match tickets, boosts and ads
| Code | Status | What happened | What to do |
|---|---|---|---|
per_run_cap | 409 | A boost or ad over its limit for this match. | Hide the option until the next match. |
no_charges | 409 | No bought charges left for this boost. | Offer the other ways to pay. |
ticket_expired | 409 | The match ticket an online boost names is too old. | Request a new ticket for the next match. |
signing_unavailable | 503 | Match tickets and boost tokens are off on this server. | Let the player join without a ticket, and hide paid boosts. |
token_too_large | 422 | A match ticket or boost token would be longer than your game server accepts. details gives the length and the limit. | Ask for less, such as fewer vouched items. The same request always gets the same answer. |
too_fast | 409 | An ad was reported complete sooner than its network allows. details.retryAt says when. The session stays open. | Report the same completion again from retryAt. |
already_voided / void_window_closed | 409 | An offline cube refund was already made, or its 60-second window closed. | Tell the player the coins stay spent. |
Accounts
| Code | Status | What happened | What to do |
|---|---|---|---|
merge_confirm_required | 409 | Redeeming a code would merge this device's account. details is the preview. Nothing changed. | Show PowerUpDisclosure.transfer_preview_lines(details), then call again with confirmed true. |
merge_blocked | 409 | This device has a match or a boost still settling. details.reason says which. | Finish the match, then try again. |
merge_cooldown | 409 | The code's account took in another account in the last 30 days. details.availableAt says when. | Show the date. |
transfer_code_invalid, recovery_code_invalid, ownership_unproven and rate_limited also apply
here. PowerUpDisclosure.transfer_refusal_text(code, details) puts every one of them in the player's
words.
Clubs
| Code | Status | What happened |
|---|---|---|
already_in_club | 409 | The player is in a club already. |
club_full | 409 | The club has no room. |
club_closed | 409 | The club isn't taking new members. |
club_name_taken | 409 | Another club has that name. |
club_name_rejected | 422 | The name was refused. details.reason is too_short, too_long or blocked. |
rejoin_cooldown | 409 | The player left a club recently. details.nextAvailableAt says when they can join again. |
not_owner | 403 | Only the club's owner can do that. |
For every club code, PowerUpDisclosure.club_refusal_text(code, details) gives the line to show.
Words for players
You rarely need to write refusal text yourself. These PowerUpDisclosure functions take a code (or the
answer that carries it) and return the line a player reads:
| Function | Covers |
|---|---|
blocking_text(reason) / blocking_short(reason) | Why an offer can't be bought: age, consent, spend cap, owned, limit, wrong device. |
transfer_refusal_text(code, details) | Transfer and recovery code refusals. |
club_refusal_text(code, details) | Club refusals, with the reason a name was refused. |
boost_refusal_text(code) | Refused boosts, and a match ticket's refused Start Big. |
ad_unavailable_text(placement) | Why a rewarded ad isn't offered now. |
receipt_line(receipt) / restore_skipped_lines(restore) | Store purchases that didn't add anything, and why. |
The full list is in the SDK reference.