Kraty

Webhooks

Every meaningful event in your studio backend, signed. Full event catalog, verification helpers, retry semantics.

Kraty pushes every interesting event to your servers via webhooks. Use them to keep your inventory, fraud, analytics, and onboarding systems in sync with what happens on the platform.

Subscribing

Add an endpoint under your game's Webhooks tab. Pick which event kinds you care about, paste a URL, save. The signing secret is shown once — copy it into your secret manager immediately. Pause / unpause / archive / replay-delivery all live in the same tab.

Live vs test environments

Each endpoint is tagged live or test. Events triggered by a live API key fire only live endpoints; test keys fire only test endpoints. Build with a test endpoint pointed at your dev backend, then create a live one for production.

Worker-driven events (window open/close, grant expiry sweeps) always fire live.

Envelope

Every delivery is a JSON POST with this shape:

{
  "id": "evt_01HZX7…",
  "eventName": "grant.created",
  "studioId": "stu_…",
  "gameId": "game_…",
  "occurredAt": "2026-06-08T12:00:00.000Z",
  "deliveryAttempt": 1,
  "data": { /* per-kind payload — see below */ }
}
  • id is regenerated per delivery (so retries are deduped at the receiver if you key by it).
  • deliveryAttempt starts at 1 and increments on retry; you'll see the same id and data across attempts.

Signing

Every delivery carries an X-Signature header:

X-Signature: t=<unix-seconds>,v1=<hmac-sha256-hex>

The v1 value is HMAC_SHA256(secret, "<t>.<raw-body>"). Verify it within ±5 minutes of t to reject replays. Use the raw bytes of the request body — re-stringifying the parsed JSON will change byte order or whitespace and break the HMAC.

Both server SDKs ship a verifier so you don't have to roll your own crypto:

Node (@kraty/server-sdk)

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

const app = express();
// Capture the raw body BEFORE any JSON parser runs.
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'));
  // … handle event by event.eventName …
  res.json({ ok: true });
});

Python (kraty-admin)

from fastapi import FastAPI, Header, HTTPException, Request
from kraty_admin import verify_webhook

app = FastAPI()

@app.post("/kraty/webhook")
async def kraty_webhook(
    request: Request,
    x_signature: str = Header(...),
):
    raw = await request.body()
    if not verify_webhook(
        raw_body=raw,
        signature_header=x_signature,
        secret=os.environ["KRATY_WEBHOOK_SECRET"],
    ):
        raise HTTPException(status_code=401, detail="bad signature")
    event = await request.json()
    # … handle event by event["eventName"] …
    return {"ok": True}

Both verifiers:

  • Constant-time compare under the hood (no timing-leak signature recovery).
  • Reject timestamps more than 300s in the past (replay defense) or more than 60s in the future (forged-clock defense).
  • Tolerance is configurable via toleranceSeconds / tolerance_seconds.
  • Accept the body as a string, Buffer (Node), or bytes / str (Python).

Retries and circuit breaker

Failed deliveries retry on an exponential schedule: 30s, 2m, 10m, 1h, 6h, 24h. After the sixth retry the delivery is marked failed. You can replay any delivery from the portal.

If an endpoint hits five consecutive failed deliveries in a row, Kraty auto-pauses it — new events stop queuing until you click Unpause in the portal. This protects you from runaway noise during onboarding when your URL or signature verification is still being worked out. The circuit-trip itself fires webhook.endpoint.failed so your own monitoring endpoint can page on it.

Walkthrough: wire your first endpoint end-to-end

The full path from "blank webhooks tab" to "your backend is receiving signed deliveries from a real attempt":

  1. Open Webhooks under your game and click + New endpoint.
  2. Enter a key (e.g. prod_economy) — used in audit logs and delivery metadata. Pick an environment (live or test) that matches the API key your game client will use; cross-env deliveries are intentionally not fired.
  3. Paste your HTTPS receiver URL. In non-production environments you can also use http://localhost for a tunnel like ngrok.
  4. Subscribe to the events you care about. At a minimum: event.completed, grant.created, and shared_leaderboard.period_finalized. Add lobby.* and wallet.changed if matchmaking or in-game purchases land on your backend.
  5. Click Create. The portal shows the signing secret once — copy it into your secret manager (Vault, AWS Secrets Manager, etc.). Subsequent reads of the endpoint never expose the secret.
  6. Wire signature verification on your receiver. The @kraty/server-sdk (Node) snippet at the top of Signing does this in three lines.
  7. On the endpoint's Deliveries view, click Send sample and pick event.completed. Kraty fires a synthesised but fully-signed delivery — exactly the shape your code will see in production.
  8. Trigger a real event: start an attempt with the SDK, finish it, and watch the deliveries list. Each row links to the request/response payload + headers and a one-click Replay.
  9. If your receiver returns 5xx repeatedly, Kraty auto-pauses the endpoint after 6 failures (see Retries and circuit breaker). Fix the receiver and click Unpause to resume — any events that queued during the pause replay automatically.

Walkthrough: mint and rotate API keys

The matching SDK-key flow on the API keys tab. Two roles to mint:

  1. client_sdk keys — embedded in the game client. Tag them test for staging builds and live for shipped builds; the backend refuses cross-env writes from a test key against a live event.
  2. server_integration keys — used by your backend (for server-API endpoints like grant issuance, player merge, GDPR erasure). Never ship a server_integration key in a game client — it can mint currency and grant items, and an attacker who extracts it from your APK owns your economy.

To rotate without dropping requests:

  1. Click Rotate on the existing key. Kraty mints a successor immediately. The old key continues to work for a grace period (60 days) so deployments roll over cleanly.
  2. Update your deployment pipeline to use the new presented secret.
  3. Wait for every deployment to use the new key (check delivery metadata or your own request logs).
  4. Click Revoke on the old key. The old secret stops authenticating immediately.

Testing your receiver

The webhook delivery modal in the portal has a Send sample panel. Pick any registered event kind from the dropdown, click Send, and Kraty synthesizes a sample payload and delivers it to your endpoint through the normal pipeline — real HMAC signature, real retry schedule, real circuit-breaker counters. That makes it a fair test of your receiver's production path (signature verification, idempotency handling, response time) without waiting for organic game traffic.

Two markers let you tell samples apart from real events:

  • Wire: the dispatcher adds an X-Kraty-Webhook-Test: 1 header on sample deliveries. Filter your own logs on this so samples don't pollute production analytics.
  • Portal: sample deliveries get a sample badge next to the event name in the delivery log so you can tell at a glance which deliveries came from your test runs vs real traffic.

Sample payloads use obvious placeholder values (sample_player_001, zeroed-out UUIDs, fixed 2026-01-01T00:00:00.000Z timestamps) so they're also distinguishable at the payload level.

Permission: webhook.send_sample (GAME_ADMIN, GAME_DEVELOPER). Audit-logged so you can see who sent which kind.

Per-attempt debouncing

event.progress_updated is high-frequency by design. Deliveries are debounced per attempt (~5 second window) so your receiver isn't flooded by a player sending progress every frame. The last progress_updated you receive in a burst reflects the latest state.

Event catalog

Every kind below fires with the standard envelope; the table lists the kind-specific data payload shape.

Player lifecycle

player.registered

A player row was created for the first time — either via the SDK register call, an attempt start, a server-side push lobby, or a manual grant. Use this to onboard the player into your CRM / analytics / welcome-email pipeline.

{
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "firstSeenAt": "2026-06-08T12:00:00.000Z",
  "contextSnapshot": { "country": "PT", "level": 1 }
}

player.secret_set

First-time secret minting via /sdk/v1/players/:id/register. Use this as your "player completed onboarding" signal — the player now has a working per-device credential.

{
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "secretPrefix": "abcd1234",
  "registeredAt": "2026-06-08T12:00:00.000Z",
  "secretRotatedAt": null,
  "via": "sdk_register"
}

player.secret_rotated

A previously-registered player's secret was replaced. Fires on ?force=true register (dev/test only) and on admin-side rotation. Security-relevant — alert if you didn't expect a rotation.

{
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "secretPrefix": "efgh5678",
  "registeredAt": "2026-06-08T12:00:00.000Z",
  "secretRotatedAt": "2026-06-08T13:00:00.000Z",
  "via": "force_rotate"
}

player.merged

Two player records were folded into one. Fires once with the original external ids of both source and target so your downstream systems can update — e.g. point CRM records at the target id and mark the source id as inactive.

After this webhook, the source row's external id is rewritten to __merged_<uuid>__ and no further events fire for it. The target row continues normally with the source's data folded in.

{
  "fromPlayerId": "plr_…",
  "fromExternalPlayerId": "guest_device_001",
  "toPlayerId": "plr_target_…",
  "toExternalPlayerId": "player_42",
  "anonymizedExternalPlayerId": "__merged_4f8a-sample-uuid__",
  "mergedAt": "2026-06-12T10:00:00.000Z",
  "actor": { "kind": "api_key", "prefix": "sUUVdrM8" }
}

player.deleted

A studio invoked GDPR erasure via POST /server/v1/players/:externalId/delete. Fires once, with the player's ORIGINAL external id, BEFORE the Kraty-side row is anonymized. Use this as the trigger to wipe the player from your own systems (CRM, analytics, BI) so your downstream stays in sync.

After this webhook lands, the player row in Kraty carries the anonymizedExternalId placeholder; no further events will fire for them.

{
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "reason": "gdpr_erasure",
  "anonymizedExternalId": "__deleted_4f8a…__"
}

reason is one of gdpr_erasure, studio_request, test. See Common integration tasks → GDPR.

player.banned

A studio soft-banned a player. Their subsequent SDK writes now return 403 player_banned; existing scores and grants stay intact. Use this to mirror the ban into your own CRM / matchmaking pool / in-game UI ("this account is suspended").

{
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "reason": "score anomaly: gained 5000 in 2s",
  "bannedAt": "2026-06-11T12:00:00.000Z",
  "actor": { "kind": "api_key", "prefix": "sUUVdrM8" }
}

actor.kind is 'api_key' for server-SDK-driven bans (with the key prefix) or 'member' for portal-operator bans (with the member id). Fires once per state transition — re-banning an already-banned player just refreshes the reason on the audit row and does NOT re-fire this webhook.

player.unbanned

A ban was lifted. The player can resume SDK writes immediately.

{
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "unbannedAt": "2026-06-11T13:00:00.000Z",
  "actor": { "kind": "member", "memberId": "mem_…" }
}

Only fires on a real transition out of the banned state — unbanning a non-banned player is a no-op (no webhook).

Event & attempt lifecycle

event.attempt_started

A player called events.start and a fresh attempt was inserted. Payload includes attemptCount: 1 for first attempts in a window, 2 for replays (a dedicated event.attempt_restarted also fires for replays).

{
  "attemptId": "att_…",
  "eventId": "evt_…",
  "eventKey": "bounty_hunt",
  "eventWindowId": "win_…",
  "leaderboardId": "lb_…",
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "startedAt": "2026-06-08T12:00:00.000Z",
  "endsAt": "2026-06-08T12:10:00.000Z",
  "attemptCount": 1
}

event.attempt_restarted

The player started another attempt in the same event window after a prior one finished. Fires alongside event.attempt_started. Use this for retry-funnel analytics without joining started ↔ completed.

{
  "attemptId": "att_new",
  "priorAttemptId": "att_old",
  "priorScore": 42,
  "priorStatus": "completed",
  "eventId": "evt_…",
  "eventKey": "bounty_hunt",
  "eventWindowId": "win_…",
  "leaderboardId": "lb_…",
  "playerId": "plr_…",
  "externalPlayerId": "player_42"
}

event.progress_updated

Per-attempt metric update. Debounced ~5s per attempt — you'll see the latest state after each cooldown, not every individual progress call.

event.completed

Player hit the metric target naturally.

event.force_completed

An operator force-completed an attempt via the portal / admin API. Distinct from event.completed so studio analytics can tell real finishes from interventions. Payload carries the actor + reason.

{
  "attemptId": "att_…",
  "eventId": "evt_…",
  "eventWindowId": "win_…",
  "leaderboardId": "lb_…",
  "playerId": "plr_…",
  "score": 100,
  "priorScore": 60,
  "completedAt": "2026-06-08T12:30:00.000Z",
  "actorMemberId": "mem_…",
  "reason": "support_make_good",
  "milestonesFired": ["kills_15"]
}

event.expired

Attempt timed out before completion (the window closed or the per-attempt timer elapsed).

event.attempt_flagged

A server-side anti-cheat validator returned flag on a progress write. The write still applied (flag verdicts don't roll back — only reject verdicts do), and an anomaly row was recorded in the audit trail. Use this to feed your own anti-cheat pipeline: count flags per player over a rolling window and auto-ban via kraty.players.ban when the count exceeds your threshold.

reject verdicts do NOT fire this webhook — those return 422 anti_cheat_rejected synchronously to the client and never apply the write, so there's nothing for your backend to react to.

{
  "attemptId": "att_…",
  "eventId": "evt_…",
  "eventKey": "bounty_hunt",
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "validatorKey": "max_metric_rate",
  "reason": "metric 'score' average rate 2500/s exceeds limit 1000/s",
  "metricSnapshot": {
    "metricKey": "score",
    "before": 0,
    "after": 5000,
    "elapsedSec": 2,
    "avgRate": 2500,
    "limit": 1000
  },
  "flaggedAt": "2026-06-11T16:00:00.000Z"
}

See Events → Anti-cheat hooks for the per-event config.

event.milestone_granted

A mid-attempt milestone threshold was crossed. The fired milestone's rewards also emit their own grant.created, but this kind tells you WHICH milestone tripped — useful for attribution.

entry_cost.charged

A paid event's entry cost was atomically debited. The debit and the attempt creation succeed (or fail) together — if the player's attempt didn't start, this webhook didn't fire either, and no balance was touched. Use for IAP-adjacent audit trails.

{
  "eventId": "evt_…",
  "eventKey": "premium_race",
  "eventWindowId": "win_…",
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "cost": { "currencies": [{ "key": "gold", "amount": 50 }] }
}

Window & leaderboard

window.opened

Scheduler opened a new event window.

window.closed

Scheduler closed an event window (worker-driven; fires live only).

leaderboard.finalized

The final ranks for a leaderboard, computed when its window closes. Fires once per leaderboard.

shared_leaderboard.period_finalized

A shared (cross-event) leaderboard rolled to the next period (weekly or monthly cadence). Carries the period bounds, total entry count, and the top-10 ranks inline so receivers don't have to re-fetch the most common payload they care about. Snapshots for every participant land in core.shared_leaderboard_periods so historical ranks survive the rollover.

Grants & crates

grant.created

A reward grant landed for a player. Includes manual server-API mints, milestone-triggered grants, and the "rolled contents" grant produced when a crate is opened.

grant.claimed

A grant transitioned pending → claimed. Fires for both SDK claim and server-side /ack. Also fires for the crate row itself when a crate is opened (the open path is the crate's claim).

grant.expired

A grant aged past its expiresAt (sweep-driven; fires live).

crate.opened

A crate grant's reward table was rolled. Carries the produced contents-grant id so consumers can correlate.

Lobbies (matchmaking)

lobby.created

A new lobby formed — either via SDK start (auto-matchmaking) or via server-API /server/v1/.../lobbies push.

lobby.player_joined

A player successfully joined a forming lobby. Fires per player.

{
  "lobbyId": "lob_…",
  "eventId": "evt_…",
  "playerId": "plr_…",
  "externalPlayerId": "player_42",
  "participantCount": 3,
  "capacity": 4,
  "joinedAt": "2026-06-08T12:00:00.000Z",
  "source": "server_push"
}

source is omitted for SDK joins and set to "server_push" for admin-API pushes.

lobby.bot_filled

Bots were added to a lobby to fill empty slots. Fires once per fill event (not per bot) — the payload carries the full list of added bots so you don't have to fan out across many deliveries on a large lobby.

{
  "lobbyId": "lob_…",
  "leaderboardId": "lb_…",
  "botCount": 2,
  "bots": [
    { "id": "bot_a", "botId": "bot_easy", "name": "Alpha" },
    { "id": "bot_b", "botId": "bot_easy", "name": "Bravo" }
  ]
}

lobby.started

Lobby promoted to active. Always fires. Payload carries the promotionReason:

  • "capacity" — lobby filled with real players (or natural drip-fill).
  • "timeout"fill_by deadline elapsed without filling; bots backfilled.
  • "external_push" — created via /server/v1/.../lobbies push.
{
  "lobbyId": "lob_…",
  "leaderboardId": "lb_…",
  "capacity": 4,
  "participantCount": 2,
  "botCount": 2,
  "startedAt": "2026-06-08T12:00:30.000Z",
  "endsAt": "2026-06-08T12:10:30.000Z",
  "promotionReason": "timeout"
}

lobby.fill_timeout

Distinct kind that fires in addition to lobby.started when the promotion was triggered by the fill_by deadline (not by hitting capacity). Subscribe specifically to this if you want to alert on matchmaking pool starvation. Payload mirrors lobby.started.

lobby.closed

A lobby finalized at window-end.

Inventory & wallet (platform-managed)

inventory.changed

A platform-managed item quantity changed. Fires from SDK consume, server-API grant / revoke, and crate-opened deposits.

wallet.changed

A platform-managed currency balance changed. Fires from SDK debit, server-API credit / debit, and entry-cost charges.

Operational

webhook.endpoint.failed

The dispatcher just tripped the circuit breaker on an endpoint (5 consecutive terminal failures by default). The failing endpoint itself is auto-paused; this kind fires to OTHER endpoints on the same game so an internal ops endpoint can page.

{
  "endpointId": "wep_…",
  "endpointKey": "studio_analytics",
  "url": "https://hooks.studio.example/kraty",
  "consecutiveFailures": 5,
  "threshold": 5,
  "lastStatus": 503,
  "lastError": "5xx response: 503",
  "reason": "consecutive_failures"
}

Idempotency at the receiver

Webhooks deliver at least once. Two failure modes you'll see in practice:

  • The same id arriving twice (retry after your 2xx ack got lost in transit).
  • Two different ids describing the same logical event (rare; only in scoped race conditions like the player.registered double-emit when two requests upsert the same new player concurrently).

Dedupe by id for the first case; if your downstream is fully idempotent on data (e.g. grant.created keyed by grantId), the second case sorts itself out.

OpenAPI schemas

The full payload schema for every webhook kind ships in openapi.server.json under the webhook tag — point any OpenAPI codegen at it to get typed payloads in your stack.