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'll actually catch (idempotency conflicts, not-found, rate limits), and zero runtime dependencies.

Targets Node 18+. Pure-fetch — 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

pnpm add @kraty/server-sdk
# or
npm install @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 don't
// 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.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 don't 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 can't 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, don't 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);

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[]

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 doesn't 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 doesn't 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 } },   // free-form per-instance attributes
    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 doesn't 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 doesn't have to hand-roll the HMAC verification — and doesn't 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.lobbiespush(gameId, eventKey, input), read(gameId, lobbyId)
kraty.playersget(externalId), delete(externalId, { reason? }), export(externalId), ban(externalId, { reason }), unban(externalId), merge(fromExternalId, toExternalId)
kraty.migrateplayers(rows), wallet(rows), inventory(rows) — bulk import, 1,000 rows max
kraty.healthping()

See also