Kraty

Node Server SDK

@kraty/server-sdk: server-side Node.js client for the /server/v1 admin surface. Manual grants, IAP fulfilment, inventory grant/revoke, wallet credit/debit, lobby push.

@kraty/server-sdk is the server-side SDK for the Kraty platform. Use it from your studio's backend services: IAP fulfilment workers, support tooling, scheduled make-good jobs, external matchmakers pushing pre-matched lobbies. Auto-stamped idempotency keys (preserved across retries), exponential backoff with jitter, typed error helpers for the codes you will actually catch (idempotency conflicts, not-found, rate limits), and zero runtime dependencies.

Targets Node 18+. Pure-fetch, so it works in any modern JS runtime (Bun, Deno, edge functions) that ships fetch and crypto.randomUUID().

Server-side only. Authenticated with a server_integration API key that can mint currency and items. Embedding this SDK or its key in a web bundle / mobile app / Unity build is a security incident: an attacker who dumps the binary extracts the key and prints unlimited gold. For game clients use @kraty/sdk (TS / JS), @kraty/sdk-flutter, or @kraty/sdk-unity.

Install

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

Browse releases at github.com/PedroTrincheiras/kraty-sdk-server-typescript/releases. Once we publish to npm (planned for v1.0) you'll be able to swap to pnpm add @kraty/server-sdk.

Quickstart

import { KratyServer } from '@kraty/server-sdk';

const kraty = new KratyServer({
  apiKey: process.env.KRATY_SERVER_KEY!, // server_integration key
});

// IAP fulfilment, idempotent on the receipt id: replays do not
// double-mint.
await kraty.wallet.credit('player_42', 'gold', {
  amount: 500,
  reason: 'iap',
  sourceRefId: 'apple_receipt_abc',
  idempotencyKey: 'apple_receipt_abc',
});

await kraty.inventory.grant('player_42', 'starter_chest', {
  quantity: 1,
  reason: 'iap',
  idempotencyKey: 'apple_receipt_abc',
});

// Or one atomic mixed grant: currencies + items + crates together:
await kraty.grants.create('player_42', {
  idempotencyKey: 'apple_receipt_abc',
  entries: [
    { type: 'currency', currencyKey: 'gold', amount: 500 },
    { type: 'item',     itemKey: 'starter_chest', quantity: 1 },
  ],
  sourceKind: 'api',
  sourceRefId: 'apple_receipt_abc',
});

Resource clients

kraty.grants        // create (manual mint) / ack
kraty.inventory     // grant / revoke
kraty.wallet        // credit / debit
kraty.leaderboards  // submitScore (server-authoritative, ungated)
kraty.events        // reportProgress (server-authoritative) / finish
kraty.lobbies       // push (pre-matched) / read
kraty.players       // get (unified snapshot)
kraty.migrate       // bulk-import players / wallet / inventory
kraty.health        // ping

Idempotency

Every POST is auto-stamped with an idempotencyKey (UUID) if you do not supply one, but for server-side fulfilment you almost always want to provide your own key (typically the IAP receipt id or your internal fulfilment record id):

  • Replays of the same fulfilment (network retries, crash recovery, webhook redelivery) return the original grant.
  • A misconfigured retry that ships a different body returns KratyServerError with isIdempotencyConflict === true, so duplicate mints cannot sneak through silently.
import { KratyServerError } from '@kraty/server-sdk';

try {
  await kraty.wallet.credit('p', 'gold', {
    amount: 500,
    idempotencyKey: receiptId,
  });
} catch (err) {
  if (err instanceof KratyServerError && err.isIdempotencyConflict) {
    // Same receipt, different payload: investigate, do not retry.
    alertOps({ receiptId });
  } else {
    throw err;
  }
}

Cache TTL is 24 hours per key.

Manual grants

The single richest endpoint: mints a grant that combines any of currency, items, and crates atomically. Use this when an IAP needs to deliver multiple resource types in one player-visible payout:

const grant = await kraty.grants.create('player_42', {
  idempotencyKey: 'iap_starter_pack',
  entries: [
    { type: 'currency', currencyKey: 'gold',         amount: 500 },
    { type: 'currency', currencyKey: 'gems',         amount: 50 },
    { type: 'item',     itemKey: 'starter_chest',    quantity: 1 },
    { type: 'crate',    crateItemKey: 'legendary_box', quantity: 2 },
  ],
  sourceKind: 'api',
  sourceRefId: 'apple_receipt_abc',
  metadata: { receipt: receiptBody, attribution: 'campaign_42' },
});

Reward grants (kind: 'reward', the default) land in the player's pending-grants queue, waiting for the client SDK's claim (or your server-side ack). Crate grants need open before their contents materialise.

For server-side claim (no client round-trip needed, e.g. a consumable already applied server-side), use ack:

await kraty.grants.ack('player_42', grant.id);

Records ackedBy='server_api' on the audit row.

Inventory grant / revoke

Single-item versions of the above. Useful when your fulfilment pipeline is item-by-item (one IAP per row) and the grants.create shape would be overkill:

await kraty.inventory.grant('player_42', 'health_potion', {
  quantity: 10,
  reason: 'iap_potion_pack',
  idempotencyKey: 'apple_receipt_xyz',
});

// Refund / chargeback path:
await kraty.inventory.revoke('player_42', 'health_potion', {
  quantity: 10,
  reason: 'chargeback',
  idempotencyKey: 'chargeback_xyz',
});

revoke returns 409 on insufficient quantity; the audit ledger never goes negative.

Wallet credit / debit

Same shape for currencies:

await kraty.wallet.credit('player_42', 'gold', {
  amount: 500,
  reason: 'iap',
  idempotencyKey: 'apple_receipt_abc',
});

await kraty.wallet.debit('player_42', 'gold', {
  amount: 100,
  reason: 'refund',
  idempotencyKey: 'refund_xyz',
});

The client SDK can ALSO debit (kraty.wallet.debit in @kraty/sdk); only the server SDK can credit. Mint money server-side; let clients spend.

Push lobbies

When your studio's own matchmaker (Steam, GameLift, Photon) already chose a roster and you want Kraty to host the event window + scoring, push the lobby up:

const lobby = await kraty.lobbies.push('game_1', 'quick_brawl', {
  key: 'matchmaker_lobby_123',   // idempotency key
  externalPlayerIds: ['alice', 'bob', 'carol'],
  capacity: 4,                    // override event default
  fillBots: false,
});

Requires the event's leaderboardMode to be 'lobby_matched'. Returns 409 on mode mismatch or duplicate key with a different roster.

Read server-side state for support tooling:

const lobby = await kraty.lobbies.read('game_1', lobbyId);

Server-authoritative scoring

The server key is trusted: it bypasses the acceptClientScores gate that can lock client SDKs out of scoring. Use these methods when scoring lives on your backend (anti-cheat verification, simulation results, server match outcomes), or when a board / event has client scoring turned off.

Submit a leaderboard score

const result = await kraty.leaderboards.submitScore(
  'player_42',        // external player id
  'daily_steps',      // board key
  8421,               // value
  { segment: 'EU', idempotencyKey: 'steps_2026_06_29' },
);
console.log(result.leaderboardId, result.score, result.rank);

Only score-ranked boards accept this; a progression-ranked board returns 400 score_not_supported (adjust the progression item via a grant instead). Segmentation: pass segment for context boards, omit it for progression boards (the server derives the bucket from the player's progression state). Unknown player or board → 404 (KratyServerError with isNotFound).

Report event progress

Drives an in-flight attempt the player started client-side, by attemptId:

const result = await kraty.events.reportProgress(
  'player_42',     // external player id
  'bounty_hunt',   // event key
  attemptId,
  { mode: 'increment', metricValue: 1 }, // 'set' writes; 'increment' adds
);
for (const fired of result.milestonesFired) {
  // same milestone payload the client `events.progress` returns
}

This is the server counterpart to the client SDK's events.progress, required when the event has Accept client scores off. See Server-authoritative scoring on the events page. reportProgress is also available under the shorter progress alias (kraty.events.progress(...)) with an identical signature.

Finish an attempt

Finalizes an in-flight attempt at its current score: the server counterpart to the client SDK's events.finish. Use it to end an untimed score-attack run from your backend (that kind has no completion target, so nothing else ends it before the window closes):

const { attempt, outcome } = await kraty.events.finish(
  'player_42',     // external player id
  'score_attack',  // event key
  attemptId,       // the attempt the player started client-side
);
// outcome: 'completed' (no target, or target met, completion
//           rewards rolled) | 'expired' (had a target, not met,
//           participation only, same as a timeout).

Rejected on session / lobby events (those end via their session rules) and on an already-finished attempt. See How an attempt ends on the events page.

Player snapshot

Unified view for support tools: player row + inventory + wallet

  • recent grants in one call:
const snap = await kraty.players.get('player_42');
console.log(snap.player.externalPlayerId);
console.log(snap.inventory);      // PlayerItemHolding[]
console.log(snap.wallet);         // PlayerWalletHolding[]
console.log(snap.recentGrants);   // Grant[]

Friends (read-only)

Read any player's social graph for support / moderation tooling. These are read-only: the player's own add / accept / block actions happen through the client SDK's friends client, not the server key. Each method takes an externalPlayerId and returns 404 (KratyServerError with isNotFound) for an unknown player.

// Accepted friends, enriched with display identity + live presence:
const friends = await kraty.players.friends('player_42');
for (const f of friends) {
  console.log(f.externalPlayerId, f.online, f.status, f.lastActiveAt);
  console.log(f.displayIdentity?.name); // PlayerIdentity | null
}

// Pending requests, both directions:
const { incoming, outgoing } = await kraty.players.friendRequests('player_42');
console.log(incoming.length, outgoing.length);
// each request: { requestId, direction, player: { externalPlayerId, displayIdentity }, createdAt }

// Players this player has blocked:
const blocked = await kraty.players.blocks('player_42');
for (const b of blocked) {
  console.log(b.externalPlayerId, b.blockedAt);
}

The returned Friend, FriendRequest, FriendRequests, and BlockedPlayer types (and the shared PlayerIdentity) are exported from the package for annotating your own handlers.

Override a player's synthetic identity

Synthetic identities are auto-generated from the game's identity pool on first contact. Call kraty.players.setIdentity to override with a custom display name + optional avatar (e.g. the player's chosen handle). The value shows on leaderboards and in the client SDK's snapshot. The player must already be registered — 404 (KratyServerError with isNotFound) otherwise.

await kraty.players.setIdentity('player_42', {
  name: 'CaptainAlice',
  avatar: 'https://cdn.example.com/avatars/alice.png',
});

Pass avatar: null to clear a previously-set avatar and fall back to the pool default.

GDPR delete + export

kraty.players.delete honours an Article 17 right-of-erasure request. Anonymizes the player row + cascades through attempts, lobbies, and the Redis leaderboard meta. The financial ledger is retained per audit requirements but points at an anonymized row whose external id is a __deleted_<uuid>__ placeholder.

const out = await kraty.players.delete('player_42', { reason: 'gdpr_erasure' });
if (out.status === 'erased') {
  // Cascade ran; player.deleted webhook fired with the original
  // external id so your own systems can mirror the deletion.
}
// `no_op_never_existed` is also a success; there was no data
// for this externalId in the first place.

kraty.players.export returns the full machine-readable bundle (profile, attempts, grants, inventory, wallet, lobbies) for an Article 15 right-of-access request. Each list is hard-capped at 1,000 rows. Returns 404 (KratyServerError with isNotFound) when the player is unknown.

const bundle = await kraty.players.export('player_42');
fs.writeFileSync('player-42-export.json', JSON.stringify(bundle, null, 2));

Full flow walkthrough: Common integration tasks → GDPR.

Soft-ban a player

kraty.players.ban flags a player as banned. Subsequent player-scoped SDK writes for that player return 403 player_banned: events.start, events.progress, grants.claim, crates.open, wallet.debit, inventory.consume, players.register. Existing scores, lobby memberships, and grants stay intact (soft ban). The studio's server SDK is unaffected; administrative writes against the banned account still work.

Typical use case: your own anti-cheat pipeline detects an anomaly and bans the player automatically.

await kraty.players.ban('player_42', {
  reason: 'score anomaly: gained 5000 in 2s (max plausible: 200)',
});

// Lift the ban later:
await kraty.players.unban('player_42');

Both methods are idempotent: re-banning refreshes the reason on the audit row but does not re-fire the player.banned webhook; unbanning a non-banned player is a no-op returning applied: false.

Portal operators can ban / unban from the Player Lookup screen. The audit row records whoever acted (member id for portal, API key prefix for server SDK).

Merge two players

The classic guest-to-authenticated flow: a player starts as a guest (generated externalPlayerId), plays for a while, then signs in via OAuth. The studio backend now wants to fold the guest's progress (attempts, grants, inventory, wallet) under the authenticated account.

const out = await kraty.players.merge('guest_device_001', 'player_alice');

// out.counts shows what moved:
//   { attemptsReassigned: 12, grantsReassigned: 4,
//     itemsMerged: 3, walletsMerged: 2,
//     lobbiesTouched: 1, leaderboardsScrubbed: 2 }

// The original external id `guest_device_001` is now free to be
// re-registered by a different player (e.g. a different guest on
// the same device).

Conflict rules:

  • Identity (display name, snapshot): the target wins; the source's PII is being erased anyway.
  • Wallet balance, inventory quantity: SUM. Guest had 300 gold, authenticated player had 100 → final balance 400.
  • Leaderboard score: the source's participantId is dropped from Redis on merge (the engine recomputes from the next attempt). Studios needing score-preserving merges should call between attempts, not mid-game.

A player.merged webhook fires with the original external ids one last time so your own systems can mirror the merge.

Idempotent: replaying the same call after the merge returns 404 on the source (the original external id is gone), which the SDK surfaces as a KratyServerError with code: 'not_found'.

Migrating from another platform

When you bring players in from PlayFab, Firebase, Lootlocker, or your own backend, kraty.migrate does bulk import in batches of up to 1,000 rows per call.

Each row carries its own idempotencyKey (typically your stable id for the player / wallet entry / inventory holding) so retries are safe at the row level. Bad rows are captured in outcome.failures; the rest of the batch still applies, so a single malformed row does not take out the whole import.

const out = await kraty.migrate.players([
  { externalPlayerId: 'p_1', idempotencyKey: 'p_1' },
  { externalPlayerId: 'p_2', idempotencyKey: 'p_2', contextSnapshot: { country: 'PT' } },
]);
console.log(`${out.applied} created, ${out.skipped} replayed, ${out.failed} failed`);

await kraty.migrate.wallet([
  { externalPlayerId: 'p_1', economyKey: 'gold', amount: 1500, idempotencyKey: 'p_1:gold' },
]);

await kraty.migrate.inventory([
  {
    externalPlayerId: 'p_1',
    itemKey: 'starter_chest',
    quantity: 1,
    parameters: { rolled: { atk: 4 } },
    idempotencyKey: 'p_1:starter_chest',
  },
]);

Webhooks are not emitted during migration: a 100k-player import would otherwise flood your own backend with player.registered / inventory.changed / wallet.changed deliveries. Run any onboarding side-effects yourself after the import completes.

For larger datasets, loop client-side:

for (const chunk of chunked(allPlayers, 1000)) {
  const out = await kraty.migrate.players(chunk);
  if (out.failed > 0) collectForRetry(out.failures);
}

Retries

Every transient failure (408 / 425 / 429 / 5xx + network crash) is retried with exponential backoff + jitter, preserving the same idempotencyKey across attempts so the server's idempotency check dedupes the replay.

new KratyServer({
  apiKey: '...',
  retry: {
    attempts: 5,
    initialDelayMs: 500,
    maxDelayMs: 30_000,
    jitter: 0.25,
  },
});

Retry-After headers (used by 429 responses) are honored: the SDK sleeps for the server-supplied duration before the next attempt.

Errors

Non-2xx responses throw KratyServerError. Network failures throw KratyNetworkError.

import { KratyServerError, KratyNetworkError } from '@kraty/server-sdk';

try {
  await kraty.grants.create('player_42', { /* ... */ });
} catch (err) {
  if (err instanceof KratyServerError) {
    if (err.isIdempotencyConflict) {
      // duplicate fulfilment with different body
    } else if (err.isNotFound) {
      // player or item key does not exist in this game
    } else if (err.isForbidden) {
      // wrong key for this game / studio
    } else if (err.isRateLimited) {
      // 429, retry budget exhausted
    }
  } else if (err instanceof KratyNetworkError) {
    // backend unreachable
  }
}

Typed getters on KratyServerError:

  • isIdempotencyConflict: 409 idempotency_conflict
  • isNotFound: 404 not_found
  • isForbidden: 403 forbidden
  • isRateLimited: 429 rate_limited

Full code reference: Error codes.

Verify incoming webhooks

The SDK ships a verifyWebhook helper so your receiver does not have to hand-roll the HMAC verification, and does not accidentally introduce a timing leak or replay window bug in the process.

import express from 'express';
import { verifyWebhook, KratyServerError } from '@kraty/server-sdk';

const app = express();
// CRITICAL: capture the raw body BEFORE any JSON parser runs.
// Re-serialising the parsed JSON can change byte order /
// whitespace and break the HMAC.
app.use('/kraty', express.raw({ type: 'application/json' }));

app.post('/kraty/webhook', (req, res) => {
  const ok = verifyWebhook({
    rawBody: req.body,
    signatureHeader: req.header('x-signature') ?? '',
    secret: process.env.KRATY_WEBHOOK_SECRET!,
  });
  if (!ok) return res.status(401).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));
  switch (event.eventName) {
    case 'grant.created':       /* … */ break;
    case 'player.registered':   /* … */ break;
    case 'event.completed':     /* … */ break;
    // see /docs/webhooks for the full kind catalog
  }
  res.json({ ok: true });
});

Defaults: 5-minute replay window, 60-second forward-clock tolerance, constant-time compare. Pass toleranceSeconds to widen the replay window for delivery-queue backlog scenarios. See Webhooks for the full event catalog and signature format.

Telemetry

new KratyServer({
  apiKey: '...',
  onRequest: (info) => {
    metrics.timing(`kraty_server.${info.url}`, info.durationMs);
    if (!info.ok) metrics.increment(`kraty_server.error.${info.status}`);
  },
});

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

Resource reference

ClientMethods
kraty.grantscreate(externalId, input), ack(externalId, grantId, input?)
kraty.inventorygrant(externalId, itemKey, input), revoke(externalId, itemKey, input)
kraty.walletcredit(externalId, economyKey, input), debit(externalId, economyKey, input)
kraty.leaderboardssubmitScore(externalId, key, value, opts?): server-authoritative, ungated
kraty.eventsreportProgress(externalId, eventKey, attemptId, input) (alias: progress): server-authoritative progress; finish(externalId, eventKey, attemptId) finalizes an attempt
kraty.lobbiespush(gameId, eventKey, input), read(gameId, lobbyId)
kraty.playersget(externalId), setIdentity(externalId, identity), delete(externalId, { reason? }), export(externalId), ban(externalId, { reason }), unban(externalId), merge(fromExternalId, toExternalId), friends(externalId), friendRequests(externalId), blocks(externalId) (read-only social graph)
kraty.migrateplayers(rows), wallet(rows), inventory(rows): bulk import, 1,000 rows max
kraty.healthping()

See also