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.0Browse 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 // pingIdempotency
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
KratyServerErrorwithisIdempotencyConflict === 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_conflictisNotFound: 404 not_foundisForbidden: 403 forbiddenisRateLimited: 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
| Client | Methods |
|---|---|
kraty.grants | create(externalId, input), ack(externalId, grantId, input?) |
kraty.inventory | grant(externalId, itemKey, input), revoke(externalId, itemKey, input) |
kraty.wallet | credit(externalId, economyKey, input), debit(externalId, economyKey, input) |
kraty.leaderboards | submitScore(externalId, key, value, opts?): server-authoritative, ungated |
kraty.events | reportProgress(externalId, eventKey, attemptId, input) (alias: progress): server-authoritative progress; finish(externalId, eventKey, attemptId) finalizes an attempt |
kraty.lobbies | push(gameId, eventKey, input), read(gameId, lobbyId) |
kraty.players | get(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.migrate | players(rows), wallet(rows), inventory(rows): bulk import, 1,000 rows max |
kraty.health | ping() |