Kraty

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:PedroTrincheiras/kraty-sdk-typescript#v0.18.1
# or with pnpm:
pnpm add github:PedroTrincheiras/kraty-sdk-typescript#v0.18.1

Browse releases at github.com/PedroTrincheiras/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:

CredentialIdentifiesWhere 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 playerGenerated 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:

  1. Generates a kp_<uuid> external player id.
  2. Calls POST /sdk/v1/players/:id/register to mint the player secret.
  3. Persists both to a platform-appropriate store (localStorage in browsers / React Native, in-memory elsewhere; see Persistence backends).
  4. 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:

RuntimeDefault backend
Browser / React Native (globalThis.localStorage)LocalStorageSecretStore
Node / workers / SSRInMemorySecretStore (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.

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 / requests / presence / blocks
kraty.catalog           // items + currencies for display

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 alias

The 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 icon

Every 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.

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 one events.start(...) returned in attempt.leaderboardId. Includes SSE live streaming.

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, and self so your UI can label the section correctly.

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,
});

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 finalized SSE 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.

Inventory and wallet

Only meaningful when the game has settings.inventoryManagement === 'platform'. 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 });

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

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 }, ...]

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, 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 });

// 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');

The friend-specific error codes (cannot_friend_self, already_friends, friend_code_invalid, player_blocked) surface as KratyApiError.code; switch on them the same way as any other error.

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 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 (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.

ClientMethods
kraty.eventslistForPlayer({ as }), start(eventKey, playerContext, { as }), progress(eventKey, attemptId, input, { as }), finish(eventKey, attemptId, { as })
kraty.leaderboardsread(key, options), submitScore(key, value, opts?), listPeriods(key, opts?) (dashboard-configured cross-event boards)
kraty.eventLeaderboardsread(id, options), live(id), subscribe(id, options) (per-event-window boards (UUID), includes SSE stream)
kraty.grantslistPending({ as, limit }), claim(grantId, { as }), open(grantId, { as }), collectAll({ as })
kraty.inventorylist({ as }), consume(itemKey, input, { as })
kraty.walletlist({ as }), debit(economyKey, input, { as })
kraty.lobbiesread(lobbyId)
kraty.friendsgetCode({ as }), search(query, { limit, as }), add(target, { as }), listRequests({ as }), accept(requestId, { as }), decline(requestId, { as }), cancelRequest(requestId, { as }), list({ as }), remove(friendExternalId, { as }), heartbeat({ status, as }), block(target, { as }), listBlocks({ as }), unblock(blockedExternalId, { as })
kraty.catalogread() (items + currencies for display)
kraty.playersregister(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, null until 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.
TypeScript SDK · Kraty