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 — 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 — so the common patterns are 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 — those 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 — install directly from GitHub against a tagged release. Each release ships compiled dist/ artefacts so no build step is needed on your side.

# Pin to a specific version (recommended):
npm install github:PedroTrincheiras/kraty-sdk-typescript#v0.1.0
# or with pnpm:
pnpm add github:PedroTrincheiras/kraty-sdk-typescript#v0.1.0

Browse releases at github.com/PedroTrincheiras/kraty-sdk-typescript/releases. Migrate to pnpm add @kraty/sdk once we publish to npm (planned for v1.0).

Integration in three steps

  1. Install with the command above.

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

  3. 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's 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's 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();

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 doesn't 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 eight resource clients, all sharing one KratyClient:

kraty.events        // event list / start / progress
kraty.leaderboards  // read + live SSE stream
kraty.grants        // pending / claim / open / collectAll
kraty.lobbies       // read (with botSlots projection)
kraty.inventory     // list / consume
kraty.wallet        // list / debit
kraty.players       // register / rotate
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 don't 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.

Run an event end to end

// 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 can't afford it.
const start = await kraty.events.start(
  available[0].eventKey,
  { country: 'PT', level: 7 },
);

// 3) Push progress. `set` writes; `increment` adds.
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`);
}

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

Leaderboards

Snapshot read

// `includeSelf` resolves to the active player by default — no
// need to repeat the id.
const board = await kraty.leaderboards.read(start.leaderboardId, {
  limit: 50,
  includeSelf: true,
});

for (const e of board.entries) {
  console.log(`#${e.rank} ${e.name} ${e.score} (${e.kind})`); // kind: player | bot
}
if (board.self) {
  console.log(`You: #${board.self.rank} score ${board.self.score}`);
}

Shared leaderboards

Shared leaderboards are cross-event boards configured by the studio (weekly / monthly / all-time, optionally segmented). They're addressed by their game-scoped key — pick a stable string at design time and the client can hardcode it.

// Top 50 of an unsegmented all-time board. The active player's
// rank is included automatically.
const allTime = await kraty.leaderboards.readShared('all_time_global', {
  limit: 50,
  includeSelf: true,
});

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.readShared('season_league', {
  segment: 'diamond',          // the player's bucket
  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.listSharedPeriods('weekly_global');
const lastWeek = periods[0]; // newest snapshot
const snap = await kraty.leaderboards.readShared('weekly_global', {
  period: lastWeek.periodStartedAt,
  limit: 10,
});

readShared returns the same row shape as read plus the segment and period fields so your UI can label the section correctly.

Live SSE stream

const stream = await kraty.leaderboards.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).

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 can't 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.

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 can't 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 can't 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 doesn't 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 })
kraty.leaderboardsread(id, options), readShared(key, options), listSharedPeriods(key), live(id)
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.catalogread() — items + currencies for display
kraty.playersregister(externalId, { force }) — power-user / dev only; the SDK calls this automatically inside ensureIdentity

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