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.1Run 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:
| Credential | Identifies | Where 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 player | Generated 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:
| Runtime | Default 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 / mergeMetadataThe 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, whichevents.start(...)returned asattempt.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']; // 12Available 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 rewardsamount 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 lobbyisInsufficientEntryCost: 402, paid event the player cannot affordisPlayerSecretInvalid: 401, secret missing or wrongisPlayerAlreadyRegistered: 409, retry withforce: truein devisEntryRequirementFailed: 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).
| Client | Methods |
|---|---|
kraty.events | listForPlayer({as}), start(eventKey, {playerContext, as}), progress(eventKey, attemptId, input, {as}), finish(eventKey, attemptId, {as}) |
kraty.leaderboards | list(), read(key, {options}), join(key, {segment}), standings(key, {options}), submitScore(key, value, {segment, idempotencyKey}), listPeriods(key, {limit}) (dashboard-configured cross-event boards) |
kraty.eventLeaderboards | read(id, {options}), join(id), live(id), subscribe(id, {pollInterval}); per-event-window boards (UUID), SSE stream |
kraty.grants | listPending({as, limit}), claim(grantId, {as}), open(grantId, {as}), collectAll({as}), grant(entries, {as}) (permissive inventory mode; self-only) |
kraty.inventory | list({as}), consume(itemKey, input, {as}), live({as}), watch(handler, {ignoreOwnWrites, onError, as}) (SSE feed of item / wallet / grant changes from any source) |
kraty.wallet | list({as}), debit(economyKey, input, {as}), progress(economyKey, input, {as}) (client-writable progression only) |
kraty.dailyLogin | read({as}) (the game's daily login calendar; null when there is none) |
kraty.guilds | config, 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.lobbies | read(lobbyId) |
Identity surface on Kraty:
activeExternalPlayerId: getter,nulluntil 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})