Concepts
Errors
The shape of every error the API returns, the full table of codes, and how to handle the ones your game will meet.
On this page
The shape
Every error has the same JSON body and a matching HTTP status:
{
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "Insufficient funds",
"details": { "required": 50, "available": 20 }
}
}| Field | Meaning |
|---|---|
code | A stable code in capitals. Branch on this, never on message. |
message | A sentence in English for developers and logs. It can change; do not show it to players. |
details | Optional. Extra facts, listed per code below. |
Show your players your own text, chosen from the code.
All the codes
| HTTP | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | The body, the query or the player reference is invalid. See details.fieldErrors. |
| 400 | IDEMPOTENCY_KEY_REQUIRED | A write that needs an Idempotency-Key has none |
| 401 | UNAUTHENTICATED | The key is missing, unknown or revoked, or the player token is invalid or expired |
| 402 | PAYMENT_FAILED | The payment was refused, or there is no payment provider for this environment |
| 403 | FORBIDDEN | This kind of key cannot do this; anonymous players are turned off; the item cannot be bought from the game |
| 403 | PLAYER_BLOCKED | The player is blocked |
| 403 | ENV_NOT_ENABLED | A live key was used by a studio that is not verified |
| 404 | NOT_FOUND | Unknown player, currency, item, pack or order, in this game and environment |
| 409 | INSUFFICIENT_FUNDS | The wallet, or the quantity of an item, is too low |
| 409 | LIMIT_REACHED | A limit is reached: an item's maxOwned, a pack's maxPerPlayer, or the balance ceiling |
| 409 | PACK_NOT_AVAILABLE | A pack is outside its sale window |
| 409 | PACK_SOLD_OUT | The total stock of a pack is used up |
| 409 | IDEMPOTENCY_CONFLICT | An idempotency key was reused with another request |
| 409 | CONFLICT | The action does not fit the current state, for example refunding an unpaid order or consuming a durable item |
| 429 | RATE_LIMITED | Too many requests. See Retry-After. |
| 500 | INTERNAL | An unexpected error on our side |
The SDKs add three codes for problems that have no HTTP answer:
| Code | HTTP | Meaning |
|---|---|---|
NETWORK_ERROR | 0 | No answer arrived (connection lost, DNS, firewall) |
TIMEOUT | 0 | The SDK gave up waiting |
INVALID_RESPONSE | as received | The answer was not the JSON GameCoin sends, for example an HTML error page from a proxy |
Details by code
INSUFFICIENT_FUNDS
details.required and details.available give the amount you asked for and what the player has. The same code covers an item: consuming more potions than the player owns.
{ "error": { "code": "INSUFFICIENT_FUNDS", "message": "Not enough items", "details": { "required": 5, "available": 1 } } }LIMIT_REACHED
For an item, details has maxOwned and owned. For a pack, it has maxPerPlayer and bought.
{ "error": { "code": "LIMIT_REACHED", "message": "Item ownership limit exceeded", "details": { "maxOwned": 1, "owned": 1 } } }PACK_NOT_AVAILABLE
The pack is not on sale now: its sale has not started, or it has ended. details.startsAt and details.endsAt are ISO 8601 dates in UTC, or null when that side is open.
{ "error": { "code": "PACK_NOT_AVAILABLE", "message": "The pack is not on sale", "details": { "startsAt": "2026-11-01T09:00:00.000Z", "endsAt": null } } }PACK_SOLD_OUT
The pack has a total stock and all its units are sold or held by pending orders. details.packSku names the pack. Units held by a pending order that is never paid come back after one hour.
VALIDATION_FAILED
details.fieldErrors maps each wrong field to a list of messages or codes:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Invalid request body",
"details": { "fieldErrors": { "paid": ["Unrecognized field"] } }
}
}Request bodies are strict: a field the route does not know is an error, not something that is ignored. details.fieldErrors is for developers; do not parse its messages. A few values are stable codes:
| Field error | Meaning |
|---|---|
PLAYER_REF_INVALID | The {player} in the path is not a GameCoin id or an ext: reference, often because it was encoded twice |
COUNTRY_NOT_SELLABLE | The country of a checkout is not a country where packs are sold |
URL_INVALID, URL_SCHEME_NOT_ALLOWED | A successUrl or cancelUrl is not acceptable |
ORIGIN_MISMATCH | A browser checkout's return URL does not have the origin of the page |
CURSOR_INVALID | The cursor of a list is not one GameCoin gave |
CODE_INVALID | A gift code (code) or a creator code (creatorCode) was refused. One answer for every cause (unknown, expired, used up, disabled, already used, not valid for this pack…), so codes cannot be guessed: see Codes API |
RATE_LIMITED
The Retry-After header holds the number of seconds to wait. See Rate limits.
IDEMPOTENCY_CONFLICT and IDEMPOTENCY_KEY_REQUIRED
They carry no details. A request that failed never uses up its idempotency key, so you can send it again with the same key. See Idempotency.
Handle errors
import { ErrorCode, GameCoinError } from "@apilow/gamecoin";
try {
await gamecoin.wallet.spend(ext("user-42"), { currency: "gems", amount: 50 }, { idempotencyKey: "revive-user-42-7" });
} catch (error) {
if (!(error instanceof GameCoinError)) throw error;
if (error.code === ErrorCode.INSUFFICIENT_FUNDS) {
const { required, available } = error.details; // 50, 20
console.log(`short by ${required - available}: offer a pack`);
} else if (error.code === ErrorCode.RATE_LIMITED) {
console.log(`wait ${error.retryAfter} seconds`);
} else {
throw error; // error.code, error.status, error.message, error.details
}
}from gamecoin import ErrorCode, GameCoinError
try:
gamecoin.wallet.spend(ext("user-42"), currency="gems", amount=50, idempotency_key="revive-user-42-7")
except GameCoinError as error:
if error.code == ErrorCode.INSUFFICIENT_FUNDS:
required, available = error.details["required"], error.details["available"] # 50, 20
print(f"short by {required - available}: offer a pack")
elif error.code == ErrorCode.RATE_LIMITED:
print(f"wait {error.retry_after} seconds")
else:
raise # error.code, error.status, error.message, error.detailsuse Apilow\GameCoin\ErrorCode;
use Apilow\GameCoin\GameCoinException;
try {
$gamecoin->wallet->spend(PlayerRef::ext('user-42'), ['currency' => 'gems', 'amount' => 50], ['idempotency_key' => 'revive-user-42-7']);
} catch (GameCoinException $e) {
if ($e->errorCode === ErrorCode::INSUFFICIENT_FUNDS) {
['required' => $required, 'available' => $available] = $e->details; // 50, 20
echo 'short by ', $required - $available, ": offer a pack\n";
} elseif ($e->errorCode === ErrorCode::RATE_LIMITED) {
echo "wait {$e->retryAfter} seconds\n";
} else {
throw $e; // $e->errorCode, $e->status, $e->getMessage(), $e->details
}
}_, err = client.Wallet.Spend(ctx, gamecoin.Ext("user-42"), gamecoin.SpendParams{Currency: "gems", Amount: 50}, gamecoin.WithIdempotencyKey("revive-user-42-7"))
var gcErr *gamecoin.Error
switch {
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeInsufficientFunds:
required, _ := gcErr.DetailInt64("required") // 50
available, _ := gcErr.DetailInt64("available") // 20
fmt.Printf("short by %d: offer a pack\n", required-available)
case errors.As(err, &gcErr) && gcErr.Code == gamecoin.CodeRateLimited:
fmt.Printf("wait %.0f seconds\n", gcErr.RetryAfter.Seconds())
case err != nil:
log.Fatal(err) // gcErr.Code, gcErr.Status, gcErr.Message, gcErr.Details
}using Apilow.GameCoin;
try
{
await gamecoin.Wallet.SpendAsync(PlayerRef.Ext("user-42"), new SpendRequest { Currency = "gems", Amount = 50 }, new RequestOptions { IdempotencyKey = "revive-user-42-7" });
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.InsufficientFunds)
{
var required = ex.Details["required"].GetInt64(); // 50
var available = ex.Details["available"].GetInt64(); // 20
Console.WriteLine($"short by {required - available}: offer a pack");
}
catch (GameCoinException ex) when (ex.Code == GameCoinErrorCodes.RateLimited)
{
Console.WriteLine($"wait {ex.RetryAfter?.TotalSeconds} seconds");
}
// Any other GameCoinException goes up: ex.Code, ex.Status, ex.Message, ex.Detailstry {
await gc.spend("gems", 50, "revive");
} catch (error) {
if (error.code === "INSUFFICIENT_FUNDS") {
const { required, available } = error.details; // 50, 20
showShop(required - available); // offer a pack
} else {
throw error; // a GameCoinError: code, status, message, details
}
}What to do for each code in a game:
| Code | In your game |
|---|---|
INSUFFICIENT_FUNDS | Offer a pack, or a cheaper choice. This is a normal event, not a failure. |
LIMIT_REACHED | Tell the player they already have it, or have bought it the most times |
PACK_NOT_AVAILABLE | Show « coming soon » or « ended » (read the dates in details) |
PACK_SOLD_OUT | Show « sold out » |
UNAUTHENTICATED | In a browser, the SDK renews the token once by itself. If it still fails, check the key. On your server, check the secret key and its environment. |
PLAYER_BLOCKED | Show your own "account suspended" message |
VALIDATION_FAILED | A bug in your request. Log details and fix the code. |
RATE_LIMITED | Wait Retry-After seconds. The SDKs do it for you for short waits. |
NETWORK_ERROR | The write may or may not have happened. Retry with the same idempotency key, or read the balance first. |
INTERNAL | Retry after a pause. If it lasts, report it. |
Where next
- Idempotency: why a retry after
NETWORK_ERRORis safe. - Rate limits