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-sdkQuickstart
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 // pingIdempotency
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
KratyServerErrorwithisIdempotencyConflict === 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_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 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
| 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.lobbies | push(gameId, eventKey, input), read(gameId, lobbyId) |
kraty.players | get(externalId), delete(externalId, { reason? }), export(externalId), ban(externalId, { reason }), unban(externalId), merge(fromExternalId, toExternalId) |
kraty.migrate | players(rows), wallet(rows), inventory(rows) — bulk import, 1,000 rows max |
kraty.health | ping() |
See also
- Python server SDK — same surface, Python.
- Authentication — the two-key model.
- REST API — raw endpoints if you don't want a wrapper.
Server SDKs
Per-game server-side SDKs — IAP fulfilment, grant currency / items, push pre-matched lobbies, verify incoming webhooks, look up player state. Available in Node and Python.
Python Server SDK
kraty-admin — server-side Python client for the /server/v1 admin surface. Manual grants, IAP fulfilment, inventory grant/revoke, wallet credit/debit, lobby push.