TypeScript SDK
Pure-fetch web/Node client for the Kraty platform: events, leaderboards, lobbies, grants, inventory, wallet, and per-player auth.
@kraty/sdk is the TypeScript / JavaScript client SDK, built for
web games, JS-based mobile shells (React Native via the JS
bridge), and any TS / JS runtime that needs to consume the
/sdk/v1 surface. Auto-stamped idempotency keys on every write
(preserved across retries), exponential backoff with jitter,
sealed error codes you can switch on, Server-Sent-Events
leaderboard streaming, and adaptive polling helpers keep the
common patterns to one line of code.
Targets Node 18+ and any modern browser / runtime that ships
crypto.randomUUID() and fetch. Zero runtime dependencies.
This SDK is for game CLIENTS only. It does not expose the
/server/v1 (server_integration) or /admin/v1 surfaces,
which can mint currency, grant items, and rotate player
secrets. Server-side IAP fulfilment, manual grants, and admin
tooling belong on your own backend: use
@kraty/server-sdk (Node / TS) or
the Python server SDK. Embedding a
server_integration key in a web bundle is a security
incident.
Install
The SDK isn't on npm yet, so install directly from the public GitHub
repo against a tagged release. Each release ships compiled dist/
artefacts so no build step is needed on your side.
npm install github:Kraty-Org/kraty-sdk-typescript#v0.29.1
# or with pnpm:
pnpm add github:Kraty-Org/kraty-sdk-typescript#v0.29.1Browse releases at
github.com/Kraty-Org/kraty-sdk-typescript/releases.
Once we publish to npm (planned for v1.0) you'll be able to swap to
pnpm add @kraty/sdk.
Integration in three steps
Install with the command above.
Get a client_sdk API key from the Kraty portal under your
game → Settings → API Keys.
Drop the SDK into your game code:
import { Kraty } from '@kraty/sdk';
const kraty = new Kraty({ apiKey: 'YOUR_CLIENT_SDK_KEY' });
const available = await kraty.events.listForPlayer();That is the whole bootstrap. The SDK auto-registers the player on the first call and reuses the identity on every subsequent one. 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 web bundle, one per game / environment |
Player secret (X-Player-Secret: …) | The player | Generated server-side, persisted by the SDK across launches, attached to every player-scoped call |
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 { Kraty } from '@kraty/sdk';
const kraty = new Kraty({ apiKey: '<your-client-sdk-key>' });
// Every call below auto-resolves the player identity. The SDK
// generates one, persists it, and reuses it across launches.
const events = await kraty.events.listForPlayer();
await kraty.events.start(events[0].eventKey);That is the whole bootstrap. On the very first player-scoped call the SDK:
- Generates a
kp_<uuid>external player id. - Calls
POST /sdk/v1/players/:id/registerto mint the player secret. - Persists both to a platform-appropriate store (
localStoragein browsers / React Native, in-memory elsewhere; see Persistence backends). - Wires them onto the client.
Subsequent calls (in the same session and after a page reload) restore the persisted identity without a round-trip.
Bring your own player id
When your own auth system already minted an id (e.g. your account service maps Apple / Google IDs to internal user IDs), pass it in. The SDK still handles register + persistence:
const kraty = new Kraty({
apiKey: '<your-client-sdk-key>',
activeExternalPlayerId: 'player_42',
});
// First call registers `player_42` and persists the secret.
// All subsequent boots restore the same identity transparently.
await kraty.events.listForPlayer();Bootstrap with a known player id
When you already know the player's id at launch (your own auth
resolved it), Kraty.connectAsPlayer(...) gives you a fully
wired, player-authenticated SDK in one call: it reads any stored
secret, registers if absent, and persists the result back to the
secretStore:
import { Kraty, LocalStorageSecretStore } from '@kraty/sdk';
const secretStore = new LocalStorageSecretStore();
const kraty = await Kraty.connectAsPlayer({
options: { apiKey: '<your-client-sdk-key>' },
externalPlayerId: 'player_42',
secretStore,
});
// Ready to use: the secret is already attached to every request.
await kraty.events.listForPlayer();At app boot you can check whether this device has connected before, and skip straight into the session instead of showing an onboarding screen:
const stored = await Kraty.readStoredIdentity(secretStore);
if (stored) {
// Returning player: resume immediately.
const kraty = new Kraty({
apiKey: '<your-client-sdk-key>',
playerSecret: stored.secret,
activeExternalPlayerId: stored.externalPlayerId,
});
startGame(stored.externalPlayerId, kraty);
} else {
// Fresh install / post-logout: show "tap to play".
showOnboarding();
}Sign in with a server-issued secret
Device-link flow: a player taps "transfer my save", your backend
mints them a fresh secret server-side (via
/server/v1/players/:p/secret/rotate), and you hand the pair to
the SDK to install + persist:
const secret = await myBackend.linkDevice(playerId);
await kraty.signIn({ externalPlayerId: playerId, secret });The next call uses the new identity. Use this instead of mutating storage manually.
Log out / switch player
await kraty.logout();
// Next player-scoped call lazily registers a fresh player.
// Or sign in as someone else immediately:
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 same three-tier resolver (constructor id
→ persisted id → fresh register).
Inspect the active identity
// Returns null until the first player-scoped call resolves
// (or you await ensureIdentity directly).
const id = kraty.activeExternalPlayerId;
// Force a resolve up-front (rare, usually unnecessary).
const { externalPlayerId } = await kraty.ensureIdentity();Persistence backends
The SDK picks a default SecretStore based on the runtime;
game code does not construct or pass one:
| Runtime | Default backend |
|---|---|
Browser / React Native (globalThis.localStorage) | LocalStorageSecretStore |
| Node / workers / SSR | InMemorySecretStore (volatile across process restarts) |
The Node case is rare for the client SDK; server-side
fulfilment belongs in
@kraty/server-sdk, which has its own
auth model. If you do need a custom backend (e.g. an encrypted
file store on Node), the SecretStore interface lives in
src/secret-store.ts and accepts an instance via
KratyClientOptions.secretStore.
Configure
import { Kraty } from '@kraty/sdk';
const kraty = new Kraty({
apiKey: process.env.KRATY_API_KEY!,
timeoutMs: 10_000,
retry: {
attempts: 5,
initialDelayMs: 200,
maxDelayMs: 10_000,
jitter: 0.25,
},
onRequest: (info) => { // optional telemetry
console.log(`${info.method} ${info.url} → ${info.status}`);
},
});The API key alone identifies your studio + game; pass nothing else.
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. In a browser the SDK reports web on its own.
It can't read your build number or tell a React Native iOS build from
an Android one, so pass those in:
import { Platform } from 'react-native';
const kraty = new Kraty({
apiKey: '...',
platform: Platform.OS, // 'ios' | 'android'; or a key from Settings → Platforms
appVersion: '1.4.2', // your build version
});Built-in keys are ios, android, web, windows, macos,
linux, editor; anything else should be defined in the dashboard
(Settings → Platforms). An undefined key still gets stored, and the
dashboard flags it as unregistered so you notice.
Resource clients
Kraty exposes ten resource clients, all sharing one
KratyClient:
kraty.events // event list / start / progress
kraty.leaderboards // cross-event boards by key: read / listPeriods
kraty.eventLeaderboards // per-event attempt board by UUID: read + live/subscribe SSE
kraty.grants // pending / claim / open / collectAll
kraty.lobbies // read (with botSlots projection)
kraty.inventory // list / consume
kraty.wallet // list / debit
kraty.players // register / rotate / setIdentity / getIdentity / getAnonymizedIdentity / setMetadata / mergeMetadata
kraty.friends // friend codes / search / suggestions / requests / presence / blocks
kraty.catalog // items + currencies for display
kraty.dailyLogin // read: the daily login calendar (see Daily login)
kraty.guilds // guilds: found / join / roles / help requests (see Guilds)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; you do not pass it
on each call.
const kraty = new Kraty({ 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 (typically server-side admin tooling, not a game client),
pass as:
await kraty.grants.listPending({ as: 'other_player' });as skips the active-player resolution entirely; no identity
gets registered or persisted for the override id.
Real vs anonymized identity
Every player carries two orthogonal identities:
- Anonymized (synthetic) — a stable
{ name, avatar }pulled from the game's identity pool at register-time, seeded off the player id so it never changes. Safe to surface on public boards or cross-game aggregations without exposing the real profile. - Display (real) — what
players.setIdentity()writes. Optional; falls back to the anonymized value when the player never renamed themselves. This is what leaderboards render as the primary name.
Both accessors return the same envelope shape ({ name, avatar?, country? })
so a game can render either view with one branch:
const kraty = await Kraty.connectAsPlayer({ options, externalPlayerId, secretStore });
const real = await kraty.players.getIdentity(); // display name if set, else pool
const anon = await kraty.players.getAnonymizedIdentity(); // always the immutable pool value
greet(real?.name ?? 'Player');
publicScoreboard(anon?.name, anon?.country); // render a flag next to the aliasThe same shape is embedded on the register response so game code can read both identities without a follow-up call:
const reg = await kraty.players.register('player_42');
reg.displayIdentity?.name; // real name (or the pool value, if never renamed)
reg.anonymizedIdentity?.name; // e.g. "Crazy Panda" — always the pool value
reg.anonymizedIdentity?.country; // ISO-3166 alpha-2, for a flag iconEvery LeaderboardEntry mirrors this: name / avatar hold the real
display view for that participant, and anonymizedName /
anonymizedAvatar carry the immutable pool value so a game can flip
between the two views without a second lookup.
To rename the active player from a "choose your handle" screen:
await kraty.players.setIdentity({ name: 'CaptainAlice', avatar: 'https://…/alice.png' });The rewrite is retroactive: past-period standings and the live board both surface the new name immediately. The anonymized identity stays intact.
Custom player metadata
Kraty carries a free-form metadata bag on every player — studios
attach any keys they want (VIP flag, gender, preferred language,
cohort, whatever the platform's model doesn't already cover). The bag
surfaces on the register response and on every leaderboard entry so
game code can render metadata-aware UI (VIP crowns, gender-aware
avatars) without a follow-up player-lookup call.
Two write shapes:
players.setMetadata(bag)— replace the whole bag with the supplied object.players.mergeMetadata(patch)— shallow-merge the patch into the existing bag. Keys not in the patch stay untouched.
// Replace: player's metadata becomes exactly { vip: true, tier: 'gold' }.
await kraty.players.setMetadata({ vip: true, tier: 'gold' });
// Merge: only the `preferredLanguage` key is touched.
await kraty.players.mergeMetadata({ preferredLanguage: 'pt' });Authorized by the player's own secret — a client can only ever edit
its own metadata. Studios that need moderation (e.g. reject profanity
inside a handle key) should set metadata from their backend via the
server SDK players.setMetadata instead.
Run an event end to end
// 0) Connect. The first player-scoped call auto-registers + persists
// the active player.
const kraty = new Kraty({ apiKey: '<your-client-sdk-key>' });
// Show a result screen exactly once when a board the player is on ends:
// live over SSE while subscribed, and on catch-up after a cold start.
kraty.onFinalized((result) => {
const placed = result.self ? `#${result.self.rank}` : '—';
showResultScreen(result.ref.leaderboardId, result.reason, placed);
void kraty.dismiss(result.ref);
});
// 1) What can the active player play right now?
const available = await kraty.events.listForPlayer();
for (const e of available) {
console.log(`${e.eventKey} (${e.type})`);
if (e.entryCost?.currencies?.length) {
for (const c of e.entryCost.currencies) {
console.log(` cost: ${c.amount} ${c.key}`);
}
}
}
// 2) Start an attempt. Pays entryCost atomically; throws on
// insufficient_entry_cost if the player cannot afford it.
const start = await kraty.events.start(
available[0].eventKey,
{ country: 'PT', level: 7 },
);
// 3) Subscribe to the attempt's live board. `subscribe(...)` is the
// high-level helper; it composes the SSE stream with a background
// poll so bots tick and deltas are deduped for you. (The raw
// `live(...)` primitive is available if you want to drive the poll
// yourself.)
const sub = kraty.eventLeaderboards.subscribe(start.leaderboardId, (ev) => {
if (ev.kind === 'score_update') repaint(ev.data);
});
// 4) Push progress. `set` writes; `increment` adds. The board repaints
// via the subscription above.
const update = await kraty.events.progress(
available[0].eventKey,
start.attempt.id,
{ mode: 'increment', metricValue: 1 },
);
for (const fired of update.milestonesFired) {
showToast(`Milestone ${fired.key} → ${fired.grants.length} grants`);
}
// 5) Attempt completed? Tear down the stream and collect rewards.
if (update.attempt.status === 'completed') {
await sub.close();
await kraty.grants.collectAll();
}For an untimed score-attack event (no completion target), step 5
never trips on its own; the player decides when the run is over.
Call events.finish to end it and settle the current score instead
of relying on a target (see below).
End the run (score-attack)
events.finish finalizes the in-progress attempt at its current
score and returns an outcome. Untimed score-attack events (no
completion target) require it; otherwise the attempt just sits
until the window closes. Wire it to your "End run" button:
async function onEndRun() {
const { attempt, outcome } = await kraty.events.finish(
'score_attack',
activeAttemptId,
);
await sub.close(); // tear down the live board
if (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.
showResultScreen(attempt);
}finish throws KratyApiError on a session / lobby event (those
end via their session rules) and on an already-finished attempt.
Team events
A team event is a normal event whose progress also counts for the
player's team. listForPlayer tells you how the event forms teams in
teamFormation: external (your server posts rosters) and attribute
(e.g. country, from playerContext) need nothing from the client;
auto_match matches on start (or matchTeam up front);
partner_slots needs partner invites or randomPartner first.
teamOptions carries what that setup needs: maxPartners and
allowRandom (hide "random partner" when false), teamSize, maxMembers,
and contextKeys, the playerContext keys a country team reads (send the
player's pick there; never send country, which the server resolves). You call the same start / progress / finish; the
responses (and listForPlayer) gain a teams array with one entry per
team seat the player holds: team score, goal (goal.remaining is the
boss HP), milestone chips (unlocked, paid, pendingMinContribution)
and the player's own contribution and in-team rank.
const res = await kraty.events.progress('team_chest', attemptId, {
mode: 'increment',
metricValue: 3,
});
const [team] = res.teams ?? [];
if (team) {
renderGoalBar(team.score, team.goal?.target);
for (const m of team.milestones) if (m.unlocked && m.paid) showChestOpened(m.key);
}
// Roster view: teammates in rank order, `isSelf` on your row.
const page = await kraty.events.teamMembers(team.eventTeamId);When the player holds several teams and the event routes contributions
to a chosen team (partner bars), pass eventTeamId in the progress
input; without it the call fails with team_required. Only progress
made while the player is on the team counts for it. Team milestone
rewards are paid as grants: the player whose progress unlocks a tier is
paid immediately, teammates a few seconds later (watch paid on the
milestone, or collect pending grants). listForPlayer returns teams
for whichever player id it is called with, like the rest of the listing;
the teammates list (teamMembers) requires the player's own secret. Progress after
the window's end returns 409 window_ended (final: the window is over).
A 409 window_busy (with Retry-After and details.retryable: true)
means the running window was locked for an instant, for example while it
was being edited: retry the same call shortly, don't end the run. Team rewards (member rank,
team rank) are paid after the window closes, as ordinary grants.
Team boards
Team-vs-team events (tournaments, races, country cups) rank teams on a
team board. It is an ordinary event leaderboard: read and stream it
with kraty.eventLeaderboards using the seat's team.leaderboardId.
team.boardRank is your team's place on it (live during the window,
final after close).
const [team] = res.teams ?? [];
if (team?.leaderboardId) {
const board = await kraty.eventLeaderboards.read(team.leaderboardId, {
includeSelf: true,
externalId: playerId,
});
// board.participantScope === 'team'; board.self is your TEAM's rank.
for (const e of board.entries) {
// Team rows are `kind: 'player'` with a `team` object; rival bot teams
// are `kind: 'bot'` with `team: null`.
renderRow(e.rank, e.name, e.score, e.team?.badge, e.team?.memberCount, e.isSelf);
}
}Code that ignores participantScope / team still renders team boards
as ordinary rows. Team boards can't be joined: join answers 409
wrong_board_kind (a team enters its board through its members'
progress). Ties go to the team that reached the score first.
Auto-matched teams
Events with auto_match teams group players into ad-hoc teams of the
event's teamSize. start puts a player without a team on one (the
oldest open team with room, else a new one), so a plain start is
enough; matchTeam does the same on demand, e.g. to show the team
before playing. It is idempotent. A team plays from its first member; a
team still short of members when its join window closes gets a smaller
goal, sized to its real members.
const { eventTeamId, teams } = await kraty.events.matchTeam('team_race');matchTeam answers 409 late_join_next_window (with
details.nextWindowId) when the event only matches early in a window,
and 409 team_formation_mismatch on events whose teams are formed
another way.
Partner events
Events with partner_slots teams let a player fill up to maxPartners
slots: invite friends, or take a random partner. Every pair is its own
team with its own bar, so progress names the bar it is for
(eventTeamId), and milestones on a bar pay both partners whoever
played. A per-player roll-up over all the player's bars rides on each
seat as team.rollup (value plus its checkpoints, each reached /
paid).
// Invite by external id or friend code; a pending invite holds a slot on both sides.
const invite = await kraty.events.invitePartner('train_journey', { friendCode: 'K7P2QX' });
// The other player sees it and answers.
const [incoming] = await kraty.events.partnerInvites('train_journey', { direction: 'in' });
const accepted = await kraty.events.acceptInvite('train_journey', incoming.eventTeamId);
// …or kraty.events.declineInvite(…); the inviter can kraty.events.cancelInvite(…).
// No friend around: pair with someone waiting, or wait for a partner.
const random = await kraty.events.randomPartner('train_journey'); // status: 'paired' | 'waiting'
// Who to invite: friends first, then recently active players with a free slot.
const suggestions = await kraty.events.partnerSuggestions('train_journey', { limit: 10 });
// Play for one bar.
await kraty.events.progress('train_journey', attemptId, {
mode: 'increment',
metricValue: 1,
eventTeamId: accepted.eventTeamId,
});If the other player had already invited you, invitePartner accepts
their invite instead (status: 'accepted'). A declined, cancelled or
expired invite frees both slots, and the two can invite each other
again. A random partner request with nobody to pair waits for the
event's randomFallbackSeconds, then stays a solo bar with a smaller
goal; you can play on it meanwhile. Unfriending or blocking a partner
later never breaks a pair. Errors: 403 player_blocked (a block either
way), 409 partner_limit_reached (details.externalPlayerId says who
is full), 409 already_partners, 400 cannot_partner_self, 409
random_partner_disabled. An invite that expired answers 409 invite_expired. Roll-up checkpoints are paid once per window,
even across several attempts: yours right away, a partner's a few
seconds later. Suggestions, invites and random pairings show players the
way friends.suggestions does (externalPlayerId and display identity),
so the id you get is the one invitePartner takes.
Leaderboards
Two resource clients sit on the facade:
kraty.leaderboards: the dashboard-configured cross-event boards your studio defines (weekly / monthly / all-time, optionally segmented). Addressed by stable key. Use this for most game UI.kraty.eventLeaderboards: the auto-generated per-event-window leaderboard tied to an attempt. Addressed by UUID, the oneevents.start(...)returned inattempt.leaderboardId. Includes SSE live streaming.
List the game's boards
// Every non-archived board configured for this game, so the UI can
// discover keys instead of hard-coding them.
const boards = await kraty.leaderboards.list();
for (const b of 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
// Top 50 of an unsegmented all-time board. `includeSelf` resolves
// to the active player by default, so there's no need to repeat the id.
const allTime = await kraty.leaderboards.read('all_time_global', {
limit: 50,
includeSelf: true,
});
for (const e of allTime.entries) {
console.log(`#${e.rank} ${e.name} ${e.score} (${e.kind})`); // kind: player | bot
}
if (allTime.self) {
console.log(`You: #${allTime.self.rank} score ${allTime.self.score}`);
}For segmented boards you MUST pass the bucket value: the same
field your client supplied in playerContext[segmentation.key]
on attempt start:
const leagueBoard = await kraty.leaderboards.read('season_league', {
segment: 'diamond',
limit: 20,
includeSelf: true,
});To show "last week's top 10" alongside the current board, list
the snapshotted periods and re-read by periodStartedAt:
const { periods } = await kraty.leaderboards.listPeriods('weekly_global');
const lastWeek = periods[0]; // newest snapshot
const snap = await kraty.leaderboards.read('weekly_global', {
period: lastWeek.periodStartedAt,
limit: 10,
});The response carries key, period, segment, entries, self, and
progression so your UI can label the section correctly. progression gives
the promotion/relegation cutoffs for the caller's division —
{ promotion: { places, itemKey } | null, relegation: { places, itemKey } | null }
— so you can render "top 3 promote / bottom 3 relegate" straight from server
config (either side is null when no ladder runs on that end). The
eventLeaderboards.read below returns the same progression field.
Snapshot read: by event-window UUID
const eventLeaderboard = await kraty.eventLeaderboards.read(start.leaderboardId, {
limit: 50,
includeSelf: true,
});Use this only when you want the attempt's leaderboard view, the
short-lived board for one event window. For "the leaderboard
players see in the lobby UI", kraty.leaderboards.read(key) is
what you want.
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.
const result = await kraty.leaderboards.submitScore('daily_steps', 8421);
console.log(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 turned off, this throws
KratyApiError with code: 'client_scoring_disabled' (403): that
board is server-authoritative; score it from your backend with
@kraty/server-sdk. 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.
const board = await kraty.leaderboards.join('weekly_region', { segment: 'EU' });
const 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.
const mine = await kraty.leaderboards.standings('season_league', {
scope: 'self_segment', // or 'mine' | 'all' | 'segment'
externalId: 'player_alice',
});
mine.segments[0].selfRank; // caller's rank in their division
mine.segments[0].participated; // true
const { periods } = await kraty.leaderboards.listPeriods('season_league');
const past = await kraty.leaderboards.standings('season_league', {
scope: 'all',
period: periods[0].periodStartedAt,
});Show a player's level next to their rank
Board reads take the same progression option the friends reads do —
name the economy keys you want and each player entry comes back with
those balances (bots never carry them):
const board = await kraty.leaderboards.read('weekly', { progression: ['level'] });
board.entries[0].progression; // { level: 12 }Works on leaderboards.read, leaderboards.standings, and
eventLeaderboards.read. Omit it and the response is unchanged — the
server does no extra work.
Unlike the rest of a board entry (name, avatar, country), which is snapshotted into Redis when the player joins the board, these balances are read live on each request. So they reflect a level-up immediately, and they cost one indexed query per enriched page. Max 8 keys.
Live SSE stream
const stream = await kraty.eventLeaderboards.live(leaderboardId);
try {
for await (const ev of stream.events) {
switch (ev.kind) {
case 'ready': break; // initial handshake
case 'score_update': repaint(ev.data); break;
case 'closed': break; // server finalized
}
}
} catch (err) {
// Transport drop: call live() again after a backoff.
} finally {
await stream.close();
}The SDK does not auto-reconnect; that policy belongs to your app (page-visibility, network-quality detection, etc. differ by use case).
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 to show a result screen: "your session ended, you placed 2nd."
The finalized SSE event tells you this live, but only while the player
is connected. A player who closed the app misses it and comes back 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:
- Live: the
finalizedSSE event (while subscribed). - Catch-up:
checkFinalizations(), which you call on app foreground / reconnect to detect boards that ended while away.
// Register once (e.g. at app boot). Returns an unsubscribe fn.
const off = kraty.onFinalized((result) => {
const placed = result.self ? `#${result.self.rank}` : '—';
showResultScreen(result.ref.leaderboardId, result.reason, placed);
// Acknowledge so it never resurfaces and leaves storage.
void kraty.dismiss(result.ref);
});
// On app foreground / reconnect: cheap when nothing ended.
const ended = await kraty.checkFinalizations();
console.log(`${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:
import { FinalizationReason } from '@kraty/sdk';
kraty.onFinalized((result) => {
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 localStorage in the browser (via
LocalStorageMembershipStore) and an in-memory store elsewhere. Pass your
own membershipStore in KratyClientOptions to override.
Grants and crates
// Manual loop.
const pending = await kraty.grants.listPending();
for (const g of pending) {
if (g.kind === 'crate') {
await kraty.grants.open(g.id);
} else {
await kraty.grants.claim(g.id);
}
}
// Or in one call:
const result = await kraty.grants.collectAll();
console.log(`Opened ${result.opened.length}, claimed ${result.claimed.length}`);
if (result.hasFailures) {
for (const f of result.failures) {
console.warn(`${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({
entries: [{ type: 'item', itemKey: 'gold_sword', quantity: 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.
const items = await kraty.inventory.list();
const wallet = await kraty.wallet.list();
// Spend.
await kraty.inventory.consume('health_potion', { quantity: 1 });
await kraty.wallet.debit('gold', { amount: 100 });Outside permissive mode, credit / grant flows are server-API-only
by design and clients cannot mint resources. (In permissive mode a
client may still grant only to its own player — see grants.grant()
above.) Server-side IAP fulfilment goes through
@kraty/server-sdk.
Push progress from the client
wallet.progress() is the one exception to "clients can't add
balance", and it's narrow on purpose. A resource must be a
progression item and flagged client writable on its page in
the dashboard; spendable currencies can never be opened up.
const res = await kraty.wallet.progress('xp', { amount: 250 });
res.balance; // 250
res.derived; // [{ economyKey: 'level', previous: 1, current: 3, delta: 2, grantIds: [...] }]Anything derived from that resource moves in the same request, and every level crossed pays its configured reward — so one call is enough to render "Level 3!" and the reward popup:
const levelUp = res.derived.find((d) => d.economyKey === 'level');
if (levelUp && levelUp.delta > 0) {
showLevelUp(levelUp.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. A 403 means the resource is a currency or
isn't client-writable; a 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:
const stop = await kraty.inventory.watch(
(ev) => {
if (ev.kind === 'inventory_changed') refreshBackpack();
if (ev.kind === 'wallet_changed') setBalance(ev.economyKey, ev.balance);
if (ev.kind === 'grant_created') showRewardPopup(ev.contents);
},
{ ignoreOwnWrites: true },
);
// on scene 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 "something changed, re-read
this" — the REST reads stay authoritative.
The stream does not auto-reconnect. Pass onError and re-subscribe
after a backoff if you need resumption:
const stop = await kraty.inventory.watch(onEvent, {
onError: (err) => scheduleReconnect(err),
});kraty.inventory.live() returns the raw handle ({ events, close })
if you'd rather drive the async iterable yourself.
Catalog
Use the catalog client to fetch the public display data for every item and currency in the game (names, icons, descriptions) without needing a player secret. Useful for rendering shop tiles, inventory slots, and reward previews.
const catalog = await kraty.catalog.read();
catalog.items; // [{ key, name, description, iconUrl, kind, rarity, tags }, ...]
catalog.currencies; // [{ key, name, description, iconUrl, kind, derived }, ...]
// A derived progression item (a level from XP) carries its curve:
const level = catalog.currencies.find((c) => c.key === 'level');
level?.derived; // { sourceKey: 'xp', levels: [{ level: 1, sourceRequired: 0 }, { level: 2, sourceRequired: 101 }, ...] }Cache the result client-side and refresh on a long interval: catalog rarely changes during a session, and a single read is enough to render the whole UI.
Friends
kraty.friends is the client-side social graph: friend codes,
username search, suggestions, requests, live presence, and blocking.
Every call is authorized by the active player's own secret, so a client
can only act on its own graph; friendships are scoped to one game +
environment. The full how-to (with the model and raw REST) lives in the
Friends guide.
// Your shareable 6-char code (stable for the player's lifetime).
const { friendCode } = await kraty.friends.getCode();
// Find players by display name; each hit carries your relationship.
const hits = await kraty.friends.search('shadow', { limit: 10 });
// "People you may want to add": recently-active non-friends, a weighted
// random sample so it varies each call (default 5, up to 25).
const suggested = await kraty.friends.suggestions({ limit: 5 });
// Add by code or by externalPlayerId (exactly one).
const sent = await kraty.friends.add({ friendCode: 'K7F2Q9' });
await kraty.friends.add({ externalPlayerId: 'player_88' });
// sent.status is 'pending' (they must accept) or 'accepted'
// (reciprocal auto-accept when they'd already requested you).
// Pending requests, both directions.
const { incoming, outgoing } = await kraty.friends.listRequests();
await kraty.friends.accept(incoming[0].requestId); // → Friend
await kraty.friends.decline(incoming[1]?.requestId);
await kraty.friends.cancelRequest(outgoing[0]?.requestId); // your own outgoing one
// Your friends, enriched with online / lastActiveAt / status.
const friends = await kraty.friends.list();
// Broadcast presence on a ~30s timer; it expires when beats stop.
await kraty.friends.heartbeat({ status: 'in_match' });
// Remove a friend by their externalPlayerId.
await kraty.friends.remove('player_88');
// Blocking tears down any friendship/request and hides you both.
await kraty.friends.block({ externalPlayerId: 'player_99' });
const blocked = await kraty.friends.listBlocks();
await kraty.friends.unblock('player_99');Gifting hangs off the same client. What can be sent is configured in the dashboard (per game, then per item), so read the allowlist rather than hardcoding one:
// The studio's allowlist + the game's cooldown / daily-limit config.
const catalog = await kraty.friends.giftCatalog();
// catalog.resources: { type: 'item' | 'currency', key, maxAmount, costsSender, … }
// 'currency' covers progression resources like XP too.
// Send to an accepted friend. Arrives as a PENDING grant they must claim.
const gift = await kraty.friends.sendGift({
to: { externalPlayerId: 'player_88' }, // or { friendCode: 'K7F2Q9' }
type: 'item',
resourceKey: 'energy_refill',
amount: 1,
message: 'good luck out there',
idempotencyKey: 'daily-2026-08-17-player_88', // retry-safe
});
// The inbox / outbox; { pendingOnly: true } for unclaimed rows only.
const inbox = await kraty.friends.gifts({ pendingOnly: true });
const outbox = await kraty.friends.sentGifts({ pendingOnly: true });
// Claiming is what actually credits the player.
await kraty.friends.claimGift(inbox[0].giftId);friends.list() marks anyone still on cooldown with
giftCooldownUntil, so a gift button can render its own timer without
extra calls.
The friend-specific error codes (cannot_friend_self,
already_friends, friend_code_invalid, player_blocked) and the
gift-specific ones (gifting_disabled, not_friends,
gift_not_allowed, gift_amount_invalid, gift_cooldown,
gift_limit_reached, gift_insufficient_balance, gift_expired)
surface as KratyApiError.code; switch on them the same way as any
other error. See Friends for the full gifting guide.
Guilds
kraty.guilds is the player's side of guilds: one guild per
player, up to 50 members, leader / co-leader / member. Build screens from
the config instead of hardcoding rules:
const config = await kraty.guilds.config(); // unlock gate, cost, role permissions, help + score settings
const mine = await kraty.guilds.mine(); // MyGuild | null: roster, role, permissions, week
const other = await kraty.guilds.get(guildId); // any guild with its roster
// Discovery
const hits = await kraty.guilds.search('owls', { country: 'US', limit: 20 }); // first page
const page = await kraty.guilds.searchPage('owls', { offset: 20 }); // { guilds, nextOffset }
const picks = await kraty.guilds.recommended({ limit: 10 }); // friends' guilds first
// Found or join
const created = await kraty.guilds.create({ name: 'Night Owls', joinType: 'request' });
const result = await kraty.guilds.join(guildId, { message: 'hi!' }); // { status: 'joined' | 'requested' }
const pending = await kraty.guilds.myRequests();
await kraty.guilds.cancelRequest(pending[0].requestId);
// Officers (permissions per role come from the config)
const inbox = await kraty.guilds.requests();
await kraty.guilds.acceptRequest(inbox[0].requestId);
await kraty.guilds.declineRequest(inbox[1].requestId);
await kraty.guilds.promote('player_88');
await kraty.guilds.demote('player_88');
await kraty.guilds.kick('player_99');
await kraty.guilds.transferLeadership('player_88');
await kraty.guilds.updateProfile({ description: 'Raids at 8 pm', joinType: 'open' });
await kraty.guilds.leave();
// Help requests
const help = await kraty.guilds.help();
const req = await kraty.guilds.requestHelp({ type: 'item', resourceKey: 'life', amount: 5 });
await kraty.guilds.donate(help.requests[0].requestId, { amount: 1 });
await kraty.guilds.cancelHelp(req.requestId);Guild error codes (guild_unlock_required, already_in_guild, guild_full,
guild_name_taken, guild_permission_denied, guild_rejoin_blocked,
guild_help_cooldown, …) surface as KratyApiError.code; the full list is on
the Guilds page.
Lobbies (matchmaking)
When you call events.start on a lobby-matched event, it may
throw KratyApiError with isLobbyForming === true. The SDK
exposes a ready-made polling helper:
import { pollLobbyUntilActive, KratyApiError } from '@kraty/sdk';
try {
const start = await kraty.events.start('quick_brawl');
} catch (err) {
if (err instanceof KratyApiError && err.isLobbyForming) {
const lobbyId = (err.details as { lobbyId: string }).lobbyId;
const lobby = await pollLobbyUntilActive(kraty.lobbies, lobbyId);
// Now safe to retry events.start
const 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 (or the lobbyFilledSlots helper) to render a
smooth "filling up" UI:
import { lobbyFilledSlots } from '@kraty/sdk';
const lobby = await kraty.lobbies.read(lobbyId);
console.log(`${lobby.participantCount} humans + ${lobby.botSlots ?? 0} bots / ${lobby.capacity}`);
console.log(`filled (clamped): ${lobbyFilledSlots(lobby)}`);Adaptive polling
import { pollPendingGrants } from '@kraty/sdk';
const ctrl = new AbortController();
void pollPendingGrants(kraty.grants, {
startMs: 2_000,
growMs: 1.5,
maxMs: 30_000,
signal: ctrl.signal,
onBatch: (batch) => { /* claim / open / queue for UI */ },
});
// later: ctrl.abort();Errors
Every non-2xx response throws KratyApiError with a code,
message, and HTTP status. Network failures (DNS, socket
reset, abort) throw KratyNetworkError.
import { KratyApiError, KratyNetworkError } from '@kraty/sdk';
try {
await kraty.events.start('bounty_hunt');
} catch (err) {
if (err instanceof KratyApiError) {
if (err.isLobbyForming) {
// matchmaking lobby still filling: poll and retry
} 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 'no_active_window':
// event is between windows
break;
case 'max_attempts_reached':
// player burned all attempts for this window
break;
}
}
} else if (err instanceof 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 (crypto.randomUUID() 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 the retry option (see
Configure).
Telemetry
new Kraty({
apiKey: '...',
onRequest: (info) => {
metrics.timing(`kraty.${info.url}`, info.durationMs);
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 an { as } option to address a different one.
| Client | Methods |
|---|---|
kraty.events | listForPlayer({ as }), start(eventKey, playerContext, { as }), progress(eventKey, attemptId, input, { as }), finish(eventKey, attemptId, { as }), teamMembers(eventTeamId, { cursor?, limit?, as }), matchTeam(eventKey, { as }), invitePartner(eventKey, { externalPlayerId } | { friendCode }, { as }), partnerInvites(eventKey, { direction?, as }), acceptInvite(eventKey, eventTeamId, { as }), declineInvite(eventKey, eventTeamId, { as }), cancelInvite(eventKey, eventTeamId, { as }), randomPartner(eventKey, { as }), partnerSuggestions(eventKey, { limit?, as }) |
kraty.leaderboards | list(), read(key, options), standings(key, options), submitScore(key, value, opts?), listPeriods(key, opts?). Read options take progression: ['level'] to attach per-player balances |
kraty.eventLeaderboards | read(id, options), live(id), subscribe(id, options) (per-event-window boards (UUID), includes SSE stream) |
kraty.grants | listPending({ as, limit }), claim(grantId, { as }), open(grantId, { as }), collectAll({ as }), grant(input, { 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.purchases | track(input, { as }) — record a real-money purchase. Also on the facade as kraty.trackPurchase(input, { as }). Write-only: spend totals are read from your backend, not the client. See purchases |
kraty.lobbies | read(lobbyId) |
kraty.friends | getCode({ progression, as }), search(query, { limit, progression, as }), add(target, { as }), listRequests({ progression, as }), accept(requestId, { as }), decline(requestId, { as }), cancelRequest(requestId, { as }), list({ progression, as }), remove(friendExternalId, { as }), heartbeat({ status, as }), block(target, { as }), listBlocks({ as }), unblock(blockedExternalId, { as }) |
kraty.catalog | read() (items + currencies for display) |
kraty.dailyLogin | read({ as }) (the game's daily login calendar; null when there is none) |
kraty.players | register(externalId, { force }) (power-user / dev only; the SDK calls this automatically inside ensureIdentity); setIdentity({ name, avatar? }) — set the active player's own display name (see player tools) |
Identity surface on Kraty:
activeExternalPlayerId: getter,nulluntil the first resolve.ensureIdentity(): resolve up-front (rare).signIn({ externalPlayerId, secret }): install + persist a server-issued identity.logout(): wipe the persisted identity.
Free functions / classes:
pollPendingGrants(grantsClient, options)pollLobbyUntilActive(lobbiesClient, lobbyId, options)InMemorySecretStore,LocalStorageSecretStore: for custom-store consumers; not needed by default.lobbyFilledSlots(lobby): helper for the UI fill projection.