# Error Code Reference

Every error response from `api.apogeetech.net` looks like this:

```json
{
  "code":      "insufficient_balance",
  "message":   "Not enough balance to place this bet",
  "requestId": "req_8f2a1c4d5e6b"
}
```

Include `requestId` when you contact support — we can grep server logs
for it. Codes are stable strings (machine-readable), messages are
human-readable and may change in subsequent versions.

## HTTP status matrix

| Status | Meaning | Client action |
|---|---|---|
| **200** | Success | Commit the bet / update UI |
| **201** | Created | Session / merchant / key created |
| **204** | No content (CORS preflight) | N/A |
| **302** | Redirect (from `/v1/launch` HTML mode) | Follow transparently |
| **400** | Malformed request body or parameters | Don't retry, fix the request |
| **401** | Bad HMAC signature, expired timestamp (>60s skew), replayed nonce, or missing admin token | Re-sign and retry once |
| **402** | Insufficient balance | Show insufficient, don't retry |
| **403** | Forbidden (session fingerprint mismatch, wrong merchant for session, missing permission) | Terminate session |
| **404** | Session / merchant / game / round not found | Prompt re-launch |
| **409** | Session busy (concurrent debit in flight) | Retry after 100-200 ms |
| **423** | Game disabled | Show maintenance screen |
| **429** | Rate limit exceeded | Back off per `Retry-After` header |
| **500** | Apogee internal error | Show generic error, log `requestId`, contact support |
| **502** | Operator wallet rejected or unreachable | Show wallet fatal overlay, call support |

## Full error code catalog

Every code currently emitted by `apogee-api`, grouped by origin layer.

### Request parsing / validation (400)

| Code | Fires when | Hint |
|---|---|---|
| `invalid_json` | Body isn't valid JSON | Validate before sending |
| `missing_fields` | One or more required fields absent | Check the endpoint's required list |
| `bad_amount` | `amount` is not a positive integer in minor units | No floats, no negatives, no zero |
| `bad_session` | `session` param doesn't match `^sess_[a-z0-9]+$` | Check param name and format |
| `bad_server_seed` | `/v1/fair/verify` got a malformed seed | 8-128 hex chars |
| `bad_divisor` | `/v1/fair/verify` `instantDivisor` < 2 | Default is 33 |
| `max_stake_exceeded` | Debit amount > merchant's configured `maxStake` | See `/v1/merchants/:id/rules` |
| `max_win_exceeded` | Credit amount or potential win > merchant's `maxWin` | Same |

### Authentication (401)

| Code | Fires when | Hint |
|---|---|---|
| `missing_signature` | `X-Apogee-Signature` header absent on a signed endpoint | Sign every request per docs/INTEGRATION.md §4 |
| `missing_key` | `X-Apogee-Key` header absent | Same |
| `bad_timestamp` | `X-Apogee-Timestamp` absent, malformed, or >60s skew | Use NTP; ±60s tolerance each side |
| `nonce_replay` | `X-Apogee-Nonce` was already used in the last 5 min | Generate a fresh random nonce per request |
| `bad_signature` | HMAC doesn't verify | Canonical string drift — see pitfalls §12 |
| `unknown_key` | Key prefix doesn't match any merchant | Check env var, contact support |
| `admin_required` | Admin-only endpoint, `X-Apogee-Admin-Token` missing | Admin token env var |

### Authorization (403)

| Code | Fires when | Hint |
|---|---|---|
| `insufficient_permission` | Key is valid but lacks the required permission (e.g. `sessions.create`) | Generate a key with the right scope in admin panel |
| `wrong_merchant` | You asked about a session belonging to a different merchant | Sessions are scoped to their creating merchant |
| `session_fingerprint_mismatch` | Client fingerprint changed materially from session creation | Usually a session hijack — terminate |
| `game_not_allowed` | Merchant's `allowedGames` doesn't include this game | Admin → merchant → allowed games |
| `game_disabled` | Game status is not `live` or `beta` | See `/v1/games` |
| `player_locked` | Operator wallet returned 403 with this code | Player is excluded — show support copy |
| `limit_exceeded` | Operator RG limit hit (daily loss, session time, etc.) | Operator-defined, surface the operator message |

### Not found (404)

| Code | Fires when |
|---|---|
| `not_found` | Generic — no route matched |
| `session_not_found` | Session token doesn't exist or expired |
| `merchant_not_found` | `mrc_...` doesn't exist in Firestore |
| `game_not_found` | `gameId` not in catalog |
| `round_not_found` | Round ID not in round telemetry collection |

### Wallet-specific (402, 409, 429, 502)

| Code | Status | Meaning |
|---|---:|---|
| `insufficient_balance` | 402 | Player's balance < stake |
| `insufficient_funds` | 402 | Alias, operator-side convention |
| `session_busy` | 409 | Mutex held by another in-flight debit/credit for this session |
| `rate_limited` | 429 | >10 req/s burst or >5 req/s sustained per session |
| `operator_unreachable` | 502 | Apogee → operator wallet call timed out after 8s |
| `operator_wallet_error` | 502 | Operator returned 5xx or 400+ with message |
| `operator_rejected` | 502 | Operator returned non-2xx with no known code |
| `operator_bad_response` | 502 | Operator returned 200 but with missing / invalid `balance` field |
| `session_expired` | 401 (from operator forwarding) | Operator says session is dead — relaunch needed |

### Game state (423)

| Code | Fires when |
|---|---|
| `game_disabled` | Round loop is halted for maintenance or status change |
| `wrong_phase` | Bet placed outside the betting window (client-side preflight) |
| `window_closed` | Debit returned after betting window closed — bet refunded |

### Client-side bet preflight (no HTTP status — local)

Every code below is surfaced in-game by `preflightBet()` before any
network call. They never reach the server.

| Code | Preflight check | Fires when |
|---|---|---|
| `wallet_blocked` | #1 | A prior unrecoverable error locked the wallet until relaunch |
| `wallet_not_ready` | #2 | Initial balance fetch hasn't returned yet |
| `wallet_busy` | #3 | Another wallet op is in flight for this bet panel |
| `wrong_phase` | #4 | Game not in `betting` phase |
| `no_session` | #5 | `?sess=` missing from launch URL in remote mode |
| `bad_currency` | #6 | Currency not in `CURRENCY_META` or invalid decimals |
| `bad_stake` | #7 | Stake is NaN / Infinity / ≤ 0 |
| `below_min_stake` | #8 | Stake < merchant's configured minimum |
| `above_max_stake` | #9 | Stake > merchant's configured maximum |
| `auto_target_too_low` | #9a | Auto-cashout target < `minAutoTarget` |
| `auto_target_too_high` | #9b | Auto-cashout target > `maxAutoTarget` |
| `non_integer_stake` | #10 | Stake × `10^decimals` isn't an integer |
| `insufficient_balance` | #11 | Known balance < stake (fast-path reject) |
| `cashout_cooldown` | #12 | <1s after a manual cashout |
| `rejected_cooldown` | #12b | <2s after a rejected debit (anti-mashing) |
| `too_many_bets` | #13 | Bet count for panel ≥ `MAX_BETS_PER_PANEL` |
| `bet_in_progress` | #14 | A previous bet on this panel is still `pending` |

See `docs/BET-PREFLIGHT.md` for the full specification.

## Error handling pattern (Node)

```js
async function placeBet(sessionToken, amount) {
  const r = await fetch(`${APOGEE_API}/v1/wallet/debit`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body:    JSON.stringify({ session: sessionToken, amount, reason: "bet" }),
  });
  const d = await r.json().catch(() => ({}));
  if (r.ok) return { ok: true, balance: d.balance };

  // Handle by code, not HTTP status
  switch (d.code) {
    case "insufficient_balance":
    case "insufficient_funds":
      return { ok: false, userMessage: "Not enough balance" };
    case "rate_limited":
      return { ok: false, userMessage: "Slow down a moment", retryAfter: 2000 };
    case "session_busy":
      return { ok: false, retryable: true, retryAfter: 200 };
    case "operator_unreachable":
    case "operator_wallet_error":
    case "operator_bad_response":
      console.error("[apogee] operator down", d.requestId);
      return { ok: false, fatal: true, userMessage: "Wallet unavailable — please retry" };
    default:
      console.error("[apogee] unexpected error", r.status, d);
      return { ok: false, fatal: true };
  }
}
```

## Reporting bugs

Always include the `requestId` from the error response body. We retain
it in Cloud Run logs for 90 days. File at
`support@apogeetech.net` or open a ticket in your operator slack.
