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 hasn't 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 doesn't declare. | err.isInvalidMetric |
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 doesn't 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 can't afford. message names the resource shortfall ("not enough cash to enter — need 50"). Tx is rolled back — 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 can't see yet (visibility gate). | err.isUnlockConditionFailed |
entry_requirement_failed | Player attempted an event they can see but doesn't meet the joinability requirement (e.g. "must own item X"). | err.isEntryRequirementFailed |
tenant_mismatch | You tried to access a resource that doesn't 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 |
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) doesn't 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 |
attempt_finished | progress on an attempt that's 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 couldn't 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 isn't 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're 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 didn't 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 isn't a member of the studio they're trying to act on. |
Handling patterns
Typed handling with helper getters
try {
await kraty.events.start('player_42', '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 hasn\'t 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(externalId);
break;
default:
rethrow;
}
}Network failures
KratyNetworkError covers everything that didn't produce an HTTP
response (DNS, socket reset, timeout). The SDK auto-retries
network errors with backoff before surfacing this.
try {
await kraty.events.listForPlayer('player_42');
} on KratyNetworkError catch (err) {
// Backend unreachable. Show offline UI, queue actions for later.
print('Network: ${err.message}');
print('Original: ${err.originalCause}');
}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 — you only see the 429 surface as a thrown error
if all retry attempts (default 3) exhausted the budget.
If you're a studio backend hitting /server/v1 hard for batch
fulfilment, prefer fewer larger requests over many small ones. Per-
key rate limits sit at ~600 req/min for client_sdk and ~1000/min
for server_integration today.
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 — track the changelog.
Authentication
Kraty's two-layer auth model — SDK key authenticates the game, player secret authenticates the player. Plus the API-surface security boundary and key rotation guidance.
Reliability and data durability
The recovery commitments Kraty makes — RPO, RTO, backup retention, restore drills, and what happens to your data if you leave.