# Errors

> The shape of every error the API returns, the full table of codes, and how to handle the ones your game will meet.

## The shape

Every error has the same JSON body and a matching HTTP status:

```json
{
  "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.

```json
{ "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`.

```json
{ "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.

```json
{ "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:

```json
{
  "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](/docs/api/codes) |

### RATE_LIMITED

The `Retry-After` header holds the number of seconds to wait. See [Rate limits](/docs/concepts/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](/docs/concepts/idempotency).

## Handle errors

<!-- tabs:start -->
```javascript tab="Node.js"
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
  }
}
```
```python tab="Python"
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.details
```
```php tab="PHP"
use 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
    }
}
```
```go tab="Go"
_, 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
}
```
```csharp tab="C#"
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.Details
```
```javascript tab="Browser"
try {
  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
  }
}
```
<!-- tabs:end -->

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](/docs/concepts/idempotency): why a retry after `NETWORK_ERROR` is safe.
- [Rate limits](/docs/concepts/rate-limits)
