Kraty

Flutter SDK

Pure Dart client for Flutter apps and Dart CLIs, covering events, leaderboards, lobbies, grants, inventory, wallet, and per-player auth.

The Dart / Flutter SDK targets Flutter apps (iOS, Android, web, desktop) and pure-Dart tooling. Auto-stamped idempotency keys on every write (preserved across retries), exponential backoff with jitter, sealed error codes you can switch on, and adaptive polling helpers for grants and lobbies, so the common patterns are one line of code.

This SDK is for game CLIENTS only. It deliberately does NOT expose the /server/v1 (server_integration) or /admin/v1 surfaces, which can mint currency, grant items, and rotate player secrets. Embedding a server_integration key in a shipped game client is a security incident: an attacker who dumps the APK extracts the key and immediately owns every player's economy. Call those endpoints from your own backend with @kraty/server-sdk (Node), never from a Flutter client.

Install

The package isn't on pub.dev yet, so install directly from the public GitHub repo against a tagged release. Each release is verified by flutter test before the tag goes out.

# pubspec.yaml
dependencies:
  kraty:
    git:
      url: https://github.com/Kraty-Org/kraty-sdk-flutter.git
      ref: v0.29.1

Run flutter pub get to fetch. Browse releases at github.com/Kraty-Org/kraty-sdk-flutter/releases. Migrate to dart pub add kraty once we publish to pub.dev (planned for v1.0).

Integration in three steps

Add the dependency above and run flutter pub get.

Get a client_sdk API key from the Kraty portal under your game → Settings → API Keys.

Drop the SDK into your game code:

import 'package:kraty/kraty.dart';

final kraty = Kraty(KratyClientOptions(apiKey: 'YOUR_CLIENT_SDK_KEY'));
final events = await kraty.events.listForPlayer();

That is the whole bootstrap. The SDK auto-registers the player on the first call and persists the identity in shared_preferences so it survives app restarts. See Authentication for the bring-your-own-id and device-link flows.

Authentication

Kraty uses two credentials in lock-step:

CredentialIdentifiesWhere it lives
SDK key (Authorization: Bearer …)The game (studio + game + permission set)Embedded in the game client, one per game / environment
Player secret (X-Player-Secret: …)The playerGenerated server-side on the first player-scoped call, persisted via SecretStore, attached to every player-scoped request

Player-scoped routes (events.start, events.progress, inventory.*, wallet.*, grants.*) require both. The SDK key alone gets you public catalog data (events list, leaderboards, lobby state). See the Authentication guide for the full security model.

One-line setup

import 'package:kraty/kraty.dart';

final kraty = Kraty(KratyClientOptions(apiKey: '<your-client-sdk-key>'));

// First player-scoped call below: the SDK generates a kp_<uuid>
// id, calls POST /sdk/v1/players/:id/register, persists the
// secret + id, and attaches X-Player-Secret on every call.
final events = await kraty.events.listForPlayer();
await kraty.events.start(events.first.eventKey);

Subsequent calls reuse the cached identity, within the session and across launches, since the default SharedPreferencesSecretStore survives restarts.

Bring your own player id

If your auth system already minted a stable id (Apple Sign-In, Google ID, your own account-service uuid), pin it in the constructor, and the SDK still handles register + secret persistence:

final kraty = Kraty(KratyClientOptions(
  apiKey: '<your-client-sdk-key>',
  activeExternalPlayerId: 'player_42',
));

// First call registers `player_42` and persists the secret;
// later launches restore the same identity transparently.
await kraty.events.listForPlayer();

Sign in with a server-issued secret

Device-link flow: your backend mints a fresh secret server-side (via /server/v1/players/:p/secret/rotate) and hands the pair back to the device. Install + persist with one call:

final secret = await myBackend.linkDevice(playerId);
await kraty.signIn(externalPlayerId: playerId, secret: secret);

The next request uses the new identity.

Log out / switch player

await kraty.logout();
// Next player-scoped call lazily registers a fresh player.

await kraty.signIn(
  externalPlayerId: 'player_99',
  secret: '<from your auth backend>',
);

logout() wipes the persisted active id + secret. The next call falls back through the three-tier resolver (constructor id → persisted id → fresh register).

Inspect the active identity

// null until the first player-scoped call resolves.
final id = kraty.activeExternalPlayerId;

// Force an early resolve (rare, usually unnecessary):
final (externalPlayerId: pid, secret: _) = await kraty.ensureIdentity();

Persistence backends

The SDK picks a durable default based on the runtime; game code does not construct or pass a SecretStore:

RuntimeDefault backend
Flutter apps (iOS, Android, web, desktop)SharedPreferencesSecretStore (wraps shared_preferences)
Pure-Dart CLIs / headless tests (no Flutter binding)InMemorySecretStore

shared_preferences is unencrypted on disk. For high-value economies, wrap flutter_secure_storage (Keychain on iOS, EncryptedSharedPreferences on Android) behind a custom SecretStore and pass it via KratyClientOptions.secretStore. The SecretStore interface lives in lib/src/secret_store.dart.

Configure

final kraty = Kraty(KratyClientOptions(
  apiKey: '<your-client-sdk-key>',
  timeout: Duration(seconds: 10),
  retry: KratyRetryConfig(
    attempts: 5,
    initialDelay: Duration(milliseconds: 200),
    maxDelay: Duration(seconds: 10),
    jitter: 0.25,
  ),
  onRequest: (info) {                     // optional telemetry
    print('${info.method} ${info.url} → ${info.status}');
  },
));

Always call kraty.close() on shutdown to release the underlying HTTP connection pool.

Platform and app version

Every request carries two telemetry headers the dashboard uses to split players by device and build: X-Kraty-Platform and X-Kraty-App-Version. Both are filled in for you — the platform from defaultTargetPlatform / kIsWeb (ios, android, web, windows, macos, linux), the version from your app bundle via package_info_plus (a dependency of the SDK since 0.30.0, resolved once on the first request). Nothing to configure for a standard build.

Shipping somewhere Flutter can't name — a store front, a console, a kiosk build? Define the platform in the dashboard (Settings → Platforms) and pass its key; pass appVersion only to override what the bundle says (e.g. to include a build number):

final kraty = Kraty(KratyClientOptions(
  apiKey: '<your-client-sdk-key>',
  platform: 'steam_deck',            // a key from Settings → Platforms
  appVersion: '1.4.2+318',           // optional override
));

An undefined platform key still gets stored, and the dashboard flags it as unregistered so you notice.

Resource clients

Kraty exposes eight resource clients, all sharing one KratyClient:

kraty.events             // event list / start / progress
kraty.leaderboards       // dashboard-configured cross-event boards (by key)
kraty.eventLeaderboards  // per-event-window boards (by UUID) + SSE live stream
kraty.grants             // pending / claim / open / collectAll / grant
kraty.lobbies            // read (with botSlots projection)
kraty.inventory          // list / consume
kraty.wallet             // list / debit
kraty.dailyLogin         // read: the daily login calendar (see Daily login)
kraty.guilds             // guilds: found / join / roles / help requests (see Guilds)
kraty.players       // register / rotate / setIdentity / getIdentity / getAnonymizedIdentity / setMetadata / mergeMetadata

The active player

After the first player-scoped call (or an explicit await kraty.ensureIdentity()), the SDK holds the player's externalPlayerId and secret internally. Every player-scoped method then resolves to that id on its own, so you do not pass it on each call.

final kraty = Kraty(KratyClientOptions(apiKey: '<your-client-sdk-key>'));

// All of these target the active player implicitly:
await kraty.events.listForPlayer();
await kraty.grants.listPending();
await kraty.inventory.list();
await kraty.wallet.list();

If you need to address a different player from the same client (server-side admin tooling, not a game client), pass as::

await kraty.grants.listPending(as: 'other_player');

as: skips active-player resolution entirely; no identity gets registered or persisted for the override id.

Events

// 1) What can this player play right now?
final available = await kraty.events.listForPlayer();

for (final e in available) {
  print('${e.eventKey} (${e.type})');
  print('  cost: ${e.entryCost?.currencies ?? []}');
  print('  metrics: ${e.metrics.map((m) => m['key']).toList()}');
}

// 2) Start an attempt. Pays entryCost atomically; throws on
//    `insufficient_entry_cost` if the player cannot afford it.
final start = await kraty.events.start(
  available.first.eventKey,
  playerContext: {'country': 'PT', 'level': 7},
);

// 3) Push progress. `set` writes; `increment` adds.
//    The response carries any milestones whose threshold crossed.
final update = await kraty.events.progress(
  available.first.eventKey,
  start.attempt.id,
  const ProgressInput(mode: 'increment', metricValue: 1),
);
for (final fired in update.milestonesFired) {
  showToast('Milestone ${fired.key} → ${fired.grants.length} grants');
}

// 4) Attempt completed?
if (update.attempt.status == 'completed') {
  await kraty.grants.collectAll();
}

End the run (score-attack)

An untimed score-attack event has no completion target; the player decides when the run is over. events.finish finalizes the in-progress attempt at its current score and returns a FinishAttemptResponse whose outcome tells you which terminal state it landed in. Such events require it; otherwise the attempt just sits until the window closes.

final res = await kraty.events.finish('score_attack', start.attempt.id);
// res.attempt: the finalized attempt
// res.outcome: 'completed' | 'expired'
if (res.outcome == 'completed') {
  // No target (or target already met) → completion rewards rolled.
  await kraty.grants.collectAll();
}
// 'expired' → event had a target the player did not meet;
// participation rewards only, same as a timeout.

finish throws KratyApiError on a session / lobby event (those end via their session rules) and on an already-finished attempt.

Leaderboards

Two resource clients sit on the facade:

  • kraty.leaderboards: the dashboard-configured cross-event boards your studio defines. Addressed by stable key. Use this for most game UI.
  • kraty.eventLeaderboards: the per-event-window board tied to an attempt. Addressed by UUID, which events.start(...) returned as attempt.leaderboardId. Includes the SSE live stream.

List the game's boards

// Every non-archived board configured for this game, so the UI can
// discover keys instead of hard-coding them.
final boards = await kraty.leaderboards.list();
for (final b in boards) {
  // b.key, b.name, b.resetCadence, b.nextResetAt (ISO, null = never resets),
  // b.acceptClientScores (false = only your server may submit),
  // b.contextSegments (playerContext fields to pass as `segment`)
}

Snapshot read (by key)

final board = await kraty.leaderboards.read(
  'weekly_global',
  options: const LeaderboardReadOptions(limit: 50, includeSelf: true),
);

for (final e in board.entries) {
  print('#${e.rank} ${e.name} ${e.score} (${e.kind})'); // kind: player | bot
}
if (board.self != null) {
  print('You: #${board.self!.rank} score ${board.self!.score}');
}
// Promotion/relegation cutoffs for this division, from server config.
final promo = board.progression?.promotion;
final releg = board.progression?.relegation;
if (promo != null) print('Top ${promo.places} promote (${promo.itemKey})');
if (releg != null) print('Bottom ${releg.places} relegate');

board.progression carries the resolved ladder limits (promotion / relegation, each ProgressionBand { places, itemKey } or null). A per-division override wins over the global rules; either side is null when no ladder runs on that end. eventLeaderboards.read returns the same field.

For segmented boards (e.g. one bucket per region) pass segment with the same value your game sent as playerContext['region'] on attempt start. For historical periods, listPeriods(key) returns a newest-first list and read(key, options: ...period: ...) reads one.

Snapshot read (by event-window UUID)

final eventLeaderboard = await kraty.eventLeaderboards.read(
  start.attempt.leaderboardId,
  options: const EventLeaderboardReadOptions(limit: 50, includeSelf: true),
);

Submit a score directly

Push a score straight to a configurable board for the active player, with no event attempt in the loop. Only valid for score-ranked boards (a progression-ranked board returns 400 score_not_supported).

// Unsegmented / progression-segmented board: no segment.
final result = await kraty.leaderboards.submitScore('daily_steps', 8421);
print('${result.leaderboardId} ${result.score} #${result.rank}');

// context-segmented board: pass the bucket the client owns.
await kraty.leaderboards.submitScore(
  'weekly_region',
  8421,
  segment: 'EU',
  idempotencyKey: 'steps_2026_06_29', // optional; dedupes retries
);

rank is null when the board can't place the player yet (e.g. a best-aggregation board the score didn't beat). If the board has Accept client scores off, this throws KratyApiError with code == 'client_scoring_disabled' (403); score it from your backend instead. See Client vs server segmentation for when segment is required.

Join without a score

// Appear on the board at 0 without scoring; returns current standings.
final board = await kraty.leaderboards.join('weekly_region', segment: 'EU');
final evBoard = await kraty.eventLeaderboards.join(start.leaderboardId);

Flexible standings

read returns one segment; standings returns one block per segment (scope: self_segment | mine | segment | all), each flagging the caller, live or for a past period.

final mine = await kraty.leaderboards.standings(
  'season_league',
  options: const StandingsReadOptions(scope: 'self_segment', externalId: 'player_alice'),
);
print(mine.segments.first.selfRank); // caller's rank in their division

final periods = await kraty.leaderboards.listPeriods('season_league');
final past = await kraty.leaderboards.standings(
  'season_league',
  options: StandingsReadOptions(scope: 'all', period: periods.periods.first.periodStartedAt),
);

Live SSE stream

final stream = await kraty.eventLeaderboards.live(leaderboardId);

stream.events.listen((event) {
  switch (event.kind) {
    case 'ready':       // initial handshake
      break;
    case 'score_update': // someone climbed
      refreshUi(event.data);
      break;
    case 'closed':       // server finalized
      break;
  }
});

stream.errors.listen((err) {
  // Transport drop: re-call .live() after a backoff
});

// Stop streaming:
await stream.cancel();

The SDK does not auto-reconnect; that policy belongs to your app (game-paused vs background-tab handling differs by use case).

Show a player's level next to their rank

Board reads take a progression list — name the economy keys and each player entry comes back with those balances:

final board = await kraty.leaderboards.read(
  'weekly',
  options: const LeaderboardReadOptions(progression: ['level']),
);
board.entries.first.progression?['level'];   // 12

Available on leaderboards.read, leaderboards.standings, and eventLeaderboards.read. Omit it and nothing extra is read server-side. Read live per request (not snapshotted like name/avatar), so a level-up shows immediately. Max 8 keys.

Finalization catch-up

Boards end. A session (lobby) inside an event can terminate early (first to N points, roster full, idle timeout), and the whole event window closes at its end time. When a board the player is on ends you usually want a result screen: "your session ended, you placed 2nd."

The finalized SSE event tells you this live, but only while connected. A player who backgrounded the app misses it and returns to a fresh board. So the SDK keeps a small persisted registry of the boards the player is in (every events.start auto-tracks its board) and gives you one callback that fires exactly once per board across both paths: the live SSE event, and checkFinalizations() for boards that ended while away.

// Register once (e.g. at app boot). Returns an unsubscribe function.
final off = kraty.onFinalized((result) {
  final placed = result.self != null ? '#${result.self!.rank}' : 'unplaced';
  showResultScreen(result.ref.leaderboardId, result.reason, placed);
  // Acknowledge so it never resurfaces and leaves storage.
  kraty.dismiss(result.ref);
});

// On app foreground / reconnect: cheap when nothing ended.
final ended = await kraty.checkFinalizations();
debugPrint('${ended.length} board(s) finalized while away');

result.reason distinguishes why the board ended, even on the catch-up path, because the board persists it. Use the FinalizationReason constants rather than magic strings:

switch (result.reason) {
  case FinalizationReason.sessionTerminated: // your lobby ended early
  case FinalizationReason.windowClosed:       // the whole event ended
  case FinalizationReason.finalized:          // ended, exact cause unknown
}

Delivery is at-most-once even across a crash: the registry entry is marked reported before your callback fires. dismiss(ref) drops one handled entry; clearReported() bulk-drops every delivered entry (returns the count).

Rewards are separate and durable: any prize from a finalized session or window lands as a pending grant regardless of connectivity. Pull them with grants.collectAll(). onFinalized is for the result screen; grants are for what the player earned. Persistence uses shared_preferences in Flutter (in-memory in pure-Dart); override via membershipStore in options.

Grants and crates

// Manual loop.
final pending = await kraty.grants.listPending();
for (final g in pending) {
  if (g.kind == 'crate') {
    await kraty.grants.open(g.id);
  } else {
    await kraty.grants.claim(g.id);
  }
}

// Or in one call:
final result = await kraty.grants.collectAll();
print('Opened ${result.opened.length} crates, claimed ${result.claimed.length}');
if (result.hasFailures) {
  for (final f in result.failures) {
    print('${f.grant.id} failed: ${f.error}');
  }
}

collectAll opens crates first; the rolled-contents grants the crates produce land in the next listPending; recall it after a moment if you want to drain those too.

In permissive inventory mode a game can also grant items / currency to its own player with grants.grant() — self only, and it fails with 403 inventory_not_permissive in any other mode (catch it with err.isInventoryNotPermissive):

await kraty.grants.grant([
  GrantEntryInput.item('gold_sword', 1),
]);

See Rewards → Inventory management for the full picture.

Inventory and wallet

Only meaningful when the game is platform-managed — settings.inventoryManagement is 'platform' or 'permissive'. For studio-managed games these endpoints return empty lists.

final items   = await kraty.inventory.list();
final wallet  = await kraty.wallet.list();

// Spend.
await kraty.inventory.consume('health_potion',
  ConsumeItemInput(quantity: 1));
await kraty.wallet.debit('gold',
  DebitWalletInput(amount: 100));

Credit / grant flows are server-API-only by design; clients cannot mint resources. Server-side IAP fulfilment goes through @kraty/server-sdk.

Push progress from the client

The one exception, and narrow on purpose: a progression item flagged client writable in the dashboard can be advanced straight from the game. Spendable currencies can never be opened up.

final res = await kraty.wallet.progress('xp',
  ProgressWalletInput(amount: 250));

res.balance;            // 250
for (final d in res.derived) {
  // e.g. economyKey 'level', previous 1, current 3
  if (d.delta > 0) showLevelUp(d.current);
}
await kraty.grants.collectAll();   // claim the per-level rewards

amount is signed, so a correction can roll progress back; the balance never goes below zero. 403 means the resource is a currency or isn't client-writable; 409 means it's itself derived — push to the source item instead.

Live inventory updates

Grants don't only come from the client. An operator can grant an item from the dashboard, your backend can credit a currency, and the engine pays out event rewards and level-ups on its own schedule. watch() subscribes to all of it over SSE so a running game reflects those changes without polling:

final stop = await kraty.inventory.watch(
  (ev) {
    switch (ev.kind) {
      case 'inventory_changed':
        refreshBackpack();
      case 'wallet_changed':
        setBalance(ev.economyKey, ev.balance);
      case 'grant_created':
        showRewardPopup(ev.data);
    }
  },
  ignoreOwnWrites: true,
  onError: (err) => scheduleReconnect(err),
);

// on teardown / logout
await stop();

Every event carries an origin (client / server / admin / engine); ignoreOwnWrites skips client so your own consume doesn't bounce back as a refresh. Treat an event as "refresh this" — the REST reads stay authoritative.

The stream does not auto-reconnect: pass onError and re-subscribe after a backoff if you need resumption. kraty.inventory.live() returns the raw handle (events / errors / cancel()) if you'd rather drive the streams yourself.

Lobbies (matchmaking)

When you call events.start on a lobby-matched event, it may throw KratyApiError with code == 'lobby_forming'. The SDK exposes a ready-made polling helper:

try {
  final start = await kraty.events.start('quick_brawl');
  // Got an attempt: lobby was already full or a slot opened.
} on KratyApiError catch (err) {
  if (err.isLobbyForming) {
    final lobbyId = (err.details as Map)['lobbyId'] as String;
    final lobby = await pollLobbyUntilActive(kraty.lobbies, lobbyId);
    // Now safe to retry events.start:
    final start = await kraty.events.start('quick_brawl');
  }
}

Lobby reads carry a botSlots projection: the number of bot slots the server will materialise on promote, derived from lobby age. Use it to render a smooth "filling up" UI instead of waiting for a sudden all-bots-at-once promote:

final lobby = await kraty.lobbies.read(lobbyId);
print('${lobby.participantCount} humans + ${lobby.botSlots} bots'
      ' / ${lobby.capacity}');
print('filled (clamped to capacity): ${lobby.filledSlots}');

Polling helpers

// Adaptive grants polling: grows interval while empty, snaps
// back to the floor when grants land.
final stop = Completer<void>();
unawaited(pollPendingGrants(
  kraty.grants,
  options: PollPendingGrantsOptions(
    start: Duration(seconds: 2),
    grow: 1.5,
    max: Duration(seconds: 30),
    onBatch: (batch) => print('${batch.length} pending'),
  ),
  signal: stop.future,
));
// later: stop.complete();

// Fixed-interval lobby poll with a TimeoutException after
// `timeout`.
final lobby = await pollLobbyUntilActive(
  kraty.lobbies,
  lobbyId,
  options: PollLobbyOptions(
    interval: Duration(seconds: 1),
    timeout: Duration(seconds: 60),
  ),
);

Errors

Every non-2xx response throws KratyApiError with a code, message, and HTTP status. Network failures (DNS, socket reset, timeout) throw KratyNetworkError.

try {
  await kraty.events.start('bounty_hunt');
} on KratyApiError catch (err) {
  if (err.isLobbyForming) {
    // matchmaking
  } else if (err.isInsufficientEntryCost) {
    // player cannot afford; err.message has the resource detail
  } else if (err.isPlayerSecretInvalid) {
    // re-register or surface to the user
  } else {
    switch (err.code) {
      case KratyErrorCode.noActiveWindow:
        // event is between windows
        break;
      case KratyErrorCode.maxAttemptsReached:
        // player burned all attempts for this window
        break;
      default:
        rethrow;
    }
  }
} on KratyNetworkError {
  // backend unreachable
}

Typed getters on KratyApiError:

  • isLobbyForming: 202 lobby_forming, poll the lobby
  • isInsufficientEntryCost: 402, paid event the player cannot afford
  • isPlayerSecretInvalid: 401, secret missing or wrong
  • isPlayerAlreadyRegistered: 409, retry with force: true in dev
  • isEntryRequirementFailed: 403, ownership gate failed

Full code reference: Error codes.

Retries and idempotency

Every POST / PUT / PATCH is auto-stamped with an idempotencyKey (16-byte URL-safe random by default), preserved across retries, so a network reset between request-sent and response-received does not double-charge or double-grant.

Default retry policy: 408 / 425 / 429 / 5xx and network failures, exponential backoff with jitter, honours Retry-After. Configure via KratyRetryConfig (see Configure).

Telemetry

final kraty = Kraty(KratyClientOptions(
  apiKey: '...',
  onRequest: (info) {
    metrics.timing('kraty.${info.url}', info.duration.inMilliseconds);
    if (!info.ok) metrics.increment('kraty.error.${info.status}');
  },
));

Fires once per HTTP attempt, including retries. Use the attempt field to dedupe.

Resource reference

Every player-scoped method defaults to the active player and accepts as: to address a different one (server-side admin tooling only).

ClientMethods
kraty.eventslistForPlayer({as}), start(eventKey, {playerContext, as}), progress(eventKey, attemptId, input, {as}), finish(eventKey, attemptId, {as})
kraty.leaderboardslist(), read(key, {options}), join(key, {segment}), standings(key, {options}), submitScore(key, value, {segment, idempotencyKey}), listPeriods(key, {limit}) (dashboard-configured cross-event boards)
kraty.eventLeaderboardsread(id, {options}), join(id), live(id), subscribe(id, {pollInterval}); per-event-window boards (UUID), SSE stream
kraty.grantslistPending({as, limit}), claim(grantId, {as}), open(grantId, {as}), collectAll({as}), grant(entries, {as}) (permissive inventory mode; self-only)
kraty.inventorylist({as}), consume(itemKey, input, {as}), live({as}), watch(handler, {ignoreOwnWrites, onError, as}) (SSE feed of item / wallet / grant changes from any source)
kraty.walletlist({as}), debit(economyKey, input, {as}), progress(economyKey, input, {as}) (client-writable progression only)
kraty.dailyLoginread({as}) (the game's daily login calendar; null when there is none)
kraty.guildsconfig, mine, get, search, searchPage, recommended, create, join, myRequests, cancelRequest, updateProfile, leave, requests, acceptRequest, declineRequest, kick, promote, demote, transferLeadership, help, requestHelp, donate, cancelHelp (all take {as}; see Guilds)
kraty.lobbiesread(lobbyId)

Identity surface on Kraty:

  • activeExternalPlayerId: getter, null until first resolve.
  • ensureIdentity(): resolve up-front (rare).
  • signIn(externalPlayerId:, secret:): install + persist a server-issued identity.
  • logout(): wipe the persisted identity.

Free functions:

  • pollPendingGrants(grantsClient, {options, signal})
  • pollLobbyUntilActive(lobbiesClient, lobbyId, {options})