Error reference
Every error code Kraty returns, when it fires, what HTTP status it maps to, and how to handle it in the SDK.
Kraty errors are sealed: every non-2xx response carries a
code from a known set, plus a human-readable message and an
optional structured details. Codes are stable: a new code is a
breaking change, never silently introduced.
Response envelope
{
"error": {
"code": "insufficient_entry_cost",
"message": "not enough cash to enter, need 50",
"details": { "resource": "cash", "needed": 50, "have": 30 }
}
}| Field | Type | Notes |
|---|---|---|
code | string | One of the codes below. Stable. |
message | string | Player-facing where appropriate. Localise if you need other languages. |
details | object? | Code-specific extra fields. Optional, may be absent. |
Code reference
Grouped by HTTP status. Every code has a typed is… getter on
the SDK's error class. Match on the getter, not on a string
comparison. The "SDK helper" column lists the canonical getter
name; each SDK adapts to its language convention:
| Language | Convention | Example |
|---|---|---|
| TypeScript (client + server) | isXxx getter | err.isPlayerSecretInvalid |
| Dart (Flutter) | isXxx getter | err.isPlayerSecretInvalid |
| C# (Unity) | IsXxx property (PascalCase) | err.IsPlayerSecretInvalid |
| Python (server) | is_xxx property (snake_case) | err.is_player_secret_invalid |
For codes the SDK has not been bumped to know about yet, use the generic escape hatch:
- TS / Dart:
err.is('code_string')(orerr.isCode(...)in Dart) - C#:
err.Is("code_string") - Python:
err.is_code("code_string")
400 Bad Request
| Code | When | SDK helper |
|---|---|---|
validation_failed | Request body / query failed schema validation. details carries field-level errors. | err.isValidationFailed |
invalid_metric | progress referenced a metric key the event does not declare. | err.isInvalidMetric |
cannot_partner_self | Partner events: a player invited themselves. | err.code === 'cannot_partner_self' |
401 Unauthenticated
| Code | When | SDK helper |
|---|---|---|
unauthenticated | Authorization header missing on a protected route. | err.isUnauthenticated |
session_invalid | Bearer token is malformed, revoked, or rejected. | err.isSessionInvalid |
player_secret_invalid | X-Player-Secret header missing, malformed, or does not match the stored hash for :externalId. | err.isPlayerSecretInvalid |
402 Payment Required
| Code | When | SDK helper |
|---|---|---|
insufficient_entry_cost | events.start on a paid event the player cannot afford. message names the resource shortfall ("not enough cash to enter, need 50"). Tx is rolled back, so partial debits never persist. | err.isInsufficientEntryCost |
403 Forbidden
| Code | When | SDK helper |
|---|---|---|
forbidden | Member / API key has the right authentication but lacks the permission for this route. | err.isForbidden |
unlock_condition_failed | Player attempted an event they cannot see yet (visibility gate). | err.isUnlockConditionFailed |
entry_requirement_failed | Player attempted an event they can see but does not meet the joinability requirement (e.g. "must own item X"). | err.isEntryRequirementFailed |
tenant_mismatch | You tried to access a resource that does not belong to the studio / game your API key is scoped to. | err.isTenantMismatch |
player_banned | The player has been soft-banned by the studio (auto-detection or operator action). All player-scoped SDK writes are gated. Existing scores stay intact; lift via the server SDK or portal. | err.isPlayerBanned |
missing_team_attribute | Server roster PUT: the event's team board is segmented by a team attribute (e.g. regatta_league) and the roster didn't send it. details.attributeKey names it. Send it in attributes, or declare a fallback on that board segment. | err.code === 'missing_team_attribute' |
player_blocked | Partner events: invitePartner / acceptInvite between two players with a block in either direction. | err.code === 'player_blocked' |
inventory_not_permissive | grants.grant (a client-side grant) on a game whose inventory management isn't set to permissive. Enable it in the game's Settings, or create grants from your backend with a server_integration key. | err.isInventoryNotPermissive |
422 Unprocessable Entity
| Code | When | SDK helper |
|---|---|---|
anti_cheat_rejected | A server-side anti-cheat validator on the event rejected this progress write. The write was rolled back; the player's score is unchanged. The body's message carries the validator's reason. | err.isAntiCheatRejected |
404 Not Found
| Code | When | SDK helper |
|---|---|---|
not_found | The referenced resource (event, attempt, grant, lobby, leaderboard, player) does not exist or is archived. | err.isNotFound |
409 Conflict
| Code | When | SDK helper |
|---|---|---|
conflict | Generic mutation conflict (e.g. wallet debit on a 0 balance, idempotency-replay with a different body). | err.isConflict |
event_disabled | Event is configured but disabled. | err.isEventDisabled |
no_active_window | Event has no currently-active window; player is between windows. | err.isNoActiveWindow |
window_ended | Team event: the window is past its end (or being closed). Final for this window: stop the run. | err.code === 'window_ended' |
window_busy | Team event: the running window was locked for an instant (e.g. while being edited). Retryable: Retry-After header and details.retryable: true; retry the same call shortly. | err.code === 'window_busy' |
wrong_board_kind | A player-board write (eventLeaderboards.join, the dashboard's score tools) aimed at a co-op team board. Team boards rank teams and move only through members' progress. | err.code === 'wrong_board_kind' |
team_formation_mismatch | Team event: a call for another way of forming teams (matchTeam on a partner or roster event, a partner call on a matchmade event). | err.code === 'team_formation_mismatch' |
late_join_next_window | matchTeam after the event's matching closed for this window (lateJoin: next_window). details.nextWindowId names the next one. | err.code === 'late_join_next_window' |
partner_limit_reached | Partner events: a player's slots (maxPartners, pending invites included) are full. details.externalPlayerId says whose. | err.code === 'partner_limit_reached' |
already_partners | Partner events: the two players already share a bar. | err.code === 'already_partners' |
invite_expired | Partner events: acceptInvite after the invite passed the event's invite lifetime. Ask for a new invite. | err.code === 'invite_expired' |
random_partner_disabled | Partner events: randomPartner on an event without random partners. | err.code === 'random_partner_disabled' |
attempt_finished | progress on an attempt that is already completed / expired. | err.isAttemptFinished |
idempotency_conflict | Same idempotencyKey used with a different request body within the cache TTL. | err.isIdempotencyConflict |
player_already_registered | register for a player who already has a secret. In dev / test, retry with ?force=true. In production, route to your recovery flow. | err.isPlayerAlreadyRegistered |
429 Too Many Requests
| Code | When | SDK helper |
|---|---|---|
rate_limited | Per-key rate limit exceeded. Retry-After header carries the wait. The SDK auto-retries with backoff. | err.isRateLimited |
max_attempts_reached | Player burned all attempts allowed for the current event window. | err.isMaxAttemptsReached |
max_daily_attempts_reached | Per-day cap reached; wait until midnight in the event's timezone. | err.isMaxDailyAttemptsReached |
500 Internal Error
| Code | When | SDK helper |
|---|---|---|
internal_error | Unhandled exception. Logged + alerted server-side; surface a generic "something went wrong" to the player. | err.isInternalError |
invalid_unlock_condition | Event config has a malformed unlock condition tree; operator should fix it in the portal. | err.isInvalidUnlockCondition |
invalid_entry_requirement | Same as above for the entry requirement field. | err.isInvalidEntryRequirement |
no_leaderboard | Server could not allocate / find the leaderboard for this attempt. Usually transient. | err.isNoLeaderboard |
202 (special) Lobby forming
| Code | When | SDK helper |
|---|---|---|
lobby_forming | Returned by events.start on lobby-matched events when the lobby is not yet at capacity. details.lobbyId carries the lobby to poll. Despite the 2xx status, the SDK throws this as KratyApiError so the caller can try/catch uniformly. | err.isLobbyForming |
Portal / member-management codes
These fire on the portal session surface (/admin/v1 and the
member-OAuth login flow). They are never returned to game clients
through /sdk/v1, so SDK consumers can ignore them; they exist
for the portal UI and for any internal tooling you build against
the admin API.
| Code | HTTP | When |
|---|---|---|
no_account | 404 | OAuth claims did not match any existing member. Trigger the sign-up / invitation acceptance flow. |
no_active_studio | 403 | Session exists but has no active studio context (user removed from their last studio mid-session). |
invitation_invalid | 404 / 409 | Invitation token not found, already accepted, or revoked. |
invitation_expired | 410 | Invitation outside its TTL; operator must re-send. |
invitation_email_mismatch | 403 | The invitation was issued to a different email than the authenticated user's. |
member_not_found | 404 | Referenced member record was deleted between request issue and execution. |
not_a_member | 403 | Caller is not a member of the studio they are trying to act on. |
Handling patterns
Typed handling with helper getters
try {
await kraty.events.start('bounty_hunt');
} on KratyApiError catch (err) {
if (err.isLobbyForming) {
final lobbyId = (err.details as Map)['lobbyId'] as String;
await pollLobbyUntilActive(kraty.lobbies, lobbyId);
} else if (err.isInsufficientEntryCost) {
showInsufficientResourceDialog(err.message);
} else if (err.isPlayerSecretInvalid) {
await reregister();
} else if (err.isEntryRequirementFailed) {
showLockedEventDialog(err.message);
} else {
rethrow;
}
}Switch on code for the rest
try {
await kraty.events.progress(...);
} on KratyApiError catch (err) {
switch (err.code) {
case KratyErrorCode.noActiveWindow:
showToast('Event has not started yet.');
break;
case KratyErrorCode.maxAttemptsReached:
showToast('Out of tries. Come back next round.');
break;
case KratyErrorCode.attemptFinished:
// server-side completion races client; just refresh state
await kraty.grants.collectAll();
break;
default:
rethrow;
}
}Network failures
KratyNetworkError covers everything that did not produce an
HTTP response (DNS, socket reset, timeout). The SDK auto-retries
network errors with backoff before surfacing this.
try {
await kraty.events.listForPlayer();
} on KratyNetworkError catch (err) {
// Backend unreachable. Show offline UI, queue actions for later.
print('Network: ${err.message}');
print('Original: ${err.originalCause}');
}A KratyNetworkError is not a Kraty outage. A degraded
backend answers with 5xx or times out; a name-resolution or
no-route failure means the request never left the device. On
mobile the triggers are almost always device-side: Android waking
from doze with a stale network handle, captive-portal Wi-Fi,
airplane mode, or a carrier resolver that is briefly unreachable
after a Wi-Fi-to-cellular handover.
Handle it as an offline state, not an exception path: show cached data, queue the write, retry on the next interaction. Every write carries an auto-generated idempotency key preserved across retries, so re-issuing a failed mutation later can never double-apply it.
The Unity SDK additionally backs off connectivity failures on a slower ladder than server errors, since a resolver needs seconds rather than milliseconds to recover -- see Unity → Offline is not a crash.
Idempotency replays
Replaying a write with the same idempotencyKey and the same
body returns the original response: no error, no double-effect.
Replay with a different body returns 409
idempotency_conflict.
The SDK auto-generates an idempotency key per POST / PUT / PATCH and preserves it across retries, so a network failure between request-send and response-receive is replay-safe by default.
Rate limiting
429 rate_limited carries a Retry-After header. The SDK
honours it automatically, so you only see the 429 surface as a
thrown error if all retry attempts (default 4) exhausted the
budget.
If you are a studio backend hitting /server/v1 hard for batch
fulfilment, prefer fewer larger requests over many small ones.
Per-key rate limits today: client_sdk keys get a read/write
split (~600 reads/min, ~120 writes/min); server_integration
keys get ~1000 req/min. See the full table in
REST API → Rate limits.
Constants
All codes are exported as KratyErrorCode constants in the
Flutter SDK so you can compare without typos:
import 'package:kraty/kraty.dart';
if (err.code == KratyErrorCode.insufficientEntryCost) { ... }The full list mirrors the table above. Adding a new code is a
breaking SDK change and bumps the minor version, so pin a specific
SDK version in your pubspec.yaml / package.json to stay in
control.