Skip to content
Docs · Errors

Error codes

Every error code the PowerUp Godot SDK returns to your game, what causes it and what to do, from safe retries and rate limits to the words players see.

Updated

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:

FieldAlwaysWhat it holds
okyesfalse.
erroryesA message written for you and your logs, not for the player.
codeyesWhat happened, as a stable string. Branch on this.
statuswhen the server answeredThe HTTP status, such as 409. Missing for failures the SDK decided on its own.
detailswhen the server sent themSpecifics for the code, such as the new price after price_changed.
retry_afteron rate_limitedSeconds to wait before trying again.
idempotency_keywhen one was sentThe 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_key argument empty and the SDK makes one and returns it in the result, on failures too.
  • Retry transport_error and 5xx statuses with that key. A server error isn't stored, so the retry runs it fresh.
  • idempotency_conflict means the key was already used. With details.inFlight true, 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, wait retry_after seconds 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.

CodeWhat happenedWhat to do
bad_requestAn 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_initializedNo API key or no base URL, or purchase() before initialize().Call initialize() with baseUrl first.
player_token_requiredA 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_errorNo 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_treeA 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_failedGodot couldn't start the request at all.Check the base URL, then retry.
bad_responseThe 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_invalidThe 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_shownA 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 statusAn 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

CodeStatusWhat happenedWhat to do
unauthorized401The API key is missing, unknown or revoked.Check the build's key against the console. A revoked key needs a new build.
player_token_invalid401The 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_exists409register_player() for a client id that already has a token this install lost.Offer the recovery code, a transfer code or Restore Purchases.
gone410register_player() for an account that was merged into another.Same as above.
wrong_key_class403A server key was used in the game client.Ship the publishable key. Server keys belong on your game server only.
wrong_environment403A test player with the live key, or a live player with the test key.Use the key the player was created with.
sandbox_only403A 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_unproven403An 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_found404An 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_conflict409The idempotency key is in use. See retrying safely.Retry later with the same key, or fix the reused key.
rate_limited429Too 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_error500Something failed on the server.Retry with the same key.

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.

CodeStatusWhat happenedWhat to do
age_gate_required403The 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_set409set_birth_year() was already answered.Nothing. Only support can change it.
parental_consent_required403A child without a parent's consent recorded.Show the store read-only. A parent contacts your support.
spend_limit_reached409The purchase would take a teen or child past the game's monthly cap.Show spend_limit_notice().
minor_restricted403A 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.
locked403A 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

CodeStatusWhat happenedWhat to do
insufficient_coins409Not enough coins. details.balance and details.needed (the price).Show the shortfall. Offer coins if your game sells them.
price_changed409The 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_debt409A 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_owned409The player owns it already, by any route.Mark it owned and refresh.
not_available409Not 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_reached409The player has bought this as many times as it allows.Show it as unavailable.
update_required409This build can't use the item or offer.Ask the player to update the game.
cooldown / daily_cap409Too soon, or too often today. details.nextAvailableAt says when.Count down with seconds_until().
already_claimed409Nothing left to claim.Refresh.
not_completed422A 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.

CodeStatusWhat happenedWhat to do
checkout_failed502Stripe refused the checkout or couldn't be reached.Offer Try again, which starts a new checkout.
provider_not_configured503The 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_unavailable502Google Play's API couldn't be reached to check a purchase.Retry later. Leave the transaction unfinished, and the store replays it.
receipt_invalid422A 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_mismatch403The 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

CodeStatusWhat happenedWhat to do
per_run_cap409A boost or ad over its limit for this match.Hide the option until the next match.
no_charges409No bought charges left for this boost.Offer the other ways to pay.
ticket_expired409The match ticket an online boost names is too old.Request a new ticket for the next match.
signing_unavailable503Match tickets and boost tokens are off on this server.Let the player join without a ticket, and hide paid boosts.
token_too_large422A 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_fast409An 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_closed409An offline cube refund was already made, or its 60-second window closed.Tell the player the coins stay spent.

Accounts

CodeStatusWhat happenedWhat to do
merge_confirm_required409Redeeming 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_blocked409This device has a match or a boost still settling. details.reason says which.Finish the match, then try again.
merge_cooldown409The 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

CodeStatusWhat happened
already_in_club409The player is in a club already.
club_full409The club has no room.
club_closed409The club isn't taking new members.
club_name_taken409Another club has that name.
club_name_rejected422The name was refused. details.reason is too_short, too_long or blocked.
rejoin_cooldown409The player left a club recently. details.nextAvailableAt says when they can join again.
not_owner403Only 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:

FunctionCovers
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.

Questions

Should I show the error message to players?

No. The error text is written for you and your logs. Branch on the code, and show your own words or the ones PowerUpDisclosure gives for that code, such as transfer_refusal_text or club_refusal_text.

Is it safe to retry a purchase or a claim that timed out?

Yes, if you send the same idempotency key. The server stores its answer to every call that spends or grants for 24 hours and replays it to a retry with the same key, so the action never runs twice. A server error isn't stored, so a retry runs it again safely.

Why does request_match_ticket fail with transport_error after 2.5 seconds?

On purpose. A match ticket gives up quickly so a slow or unreachable server never keeps a player at the Play button. Let the player join without a ticket.

See it running on a real game.

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