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 */ }
}idis regenerated per delivery (so retries are deduped at the receiver if you key by it).deliveryAttemptstarts at 1 and increments on retry; you'll see the sameidanddataacross 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), orbytes/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":
- Open Webhooks under your game and click + New endpoint.
- Enter a key (e.g.
prod_economy) — used in audit logs and delivery metadata. Pick an environment (liveortest) that matches the API key your game client will use; cross-env deliveries are intentionally not fired. - Paste your HTTPS receiver URL. In non-production environments
you can also use
http://localhostfor a tunnel like ngrok. - Subscribe to the events you care about. At a minimum:
event.completed,grant.created, andshared_leaderboard.period_finalized. Addlobby.*andwallet.changedif matchmaking or in-game purchases land on your backend. - 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.
- Wire signature verification on your receiver. The
@kraty/server-sdk(Node) snippet at the top of Signing does this in three lines. - 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. - 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.
- 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:
client_sdkkeys — embedded in the game client. Tag themtestfor staging builds andlivefor shipped builds; the backend refuses cross-env writes from atestkey against a live event.server_integrationkeys — used by your backend (for server-API endpoints like grant issuance, player merge, GDPR erasure). Never ship aserver_integrationkey 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:
- 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.
- Update your deployment pipeline to use the new presented secret.
- Wait for every deployment to use the new key (check delivery metadata or your own request logs).
- 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: 1header on sample deliveries. Filter your own logs on this so samples don't pollute production analytics. - Portal: sample deliveries get a
samplebadge 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_bydeadline elapsed without filling; bots backfilled."external_push"— created via/server/v1/.../lobbiespush.
{
"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
idarriving 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.