Webhooks
Every meaningful event in your studio backend, signed. Full event catalog, verification helpers, retry semantics, and circuit breaker behavior.
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
Create the endpoint
Add an endpoint under your game's Webhooks tab. Pick which event kinds you care about, paste a URL, save.
Capture the signing secret
The signing secret is shown once, so copy it into your secret manager immediately. Subsequent reads of the endpoint never expose the secret.
Manage from the same tab
Pause, unpause, archive, and replay-delivery all live in the same Webhooks 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 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, because re-stringifying the parsed JSON
changes byte order or whitespace and breaks the HMAC.
Both server SDKs ship a verifier so you do not have to roll your own crypto.
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 });
});from fastapi import FastAPI, Header, HTTPException, Request
from kraty_server_sdk 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.
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 (live or test) 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://localhost for a tunnel like ngrok.
Subscribe to the events you care about. At a minimum:
event.completed, grant.created, and
leaderboard.period_finalized. Add lobby.* and
wallet.changed if matchmaking or in-game purchases land on your
backend.
Click Create. The portal shows the signing secret once, so 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 Node snippet above 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 sees 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.
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 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:
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 do not 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 are 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 is
not 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, or 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, so alert if you did not 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 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, or 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 and 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 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 or admin
API. Distinct from event.completed so studio analytics can
tell real finishes from interventions. Payload carries the actor
and 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 do not 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 is 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, which is 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 did not start, this webhook did not 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 and 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.
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 do not have
to re-fetch the most common payload they care about. Snapshots
for every participant land in
core.leaderboard_periods so historical ranks survive
the rollover.
Session lifecycle
session.terminated
A sudden-death session ended before its window closed: a
configured session-end trigger fired (e.g. "first to score ≥ N",
"first N to finish", elapsed time). Carries the lobby + event
window + leaderboard IDs, a human-readable firedTrigger label,
the machine-readable firedTriggerType, the resolution (freeze
or close), and (when freeze) the count of still-active
seats that were frozen. Fires once per session. Downstream: use
this to mint main-board rewards, unfreeze cohorts, or transition
your session UI to "results".
session.frozen
A single player's attempt froze: they hit the session's per-player finish condition (max score, max wall time, or their attempt otherwise completed) while the session was still live. The player is out of the current session but can still watch the board finalize. Fires once per (attempt, session) pair; safe to use as the trigger for "you're locked in" push notifications.
Grants and 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 do not 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": "Crazy Panda" },
{ "id": "bot_b", "botId": "bot_easy", "name": "Swift Falcon" }
]
}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.
Social graph (friends)
friend.request_sent
A player sent a friend request that is now pending the other side's
acceptance. Fires for the pending case only; a reciprocal add (the
target had already requested the sender) skips straight to
friend.request_accepted. Use it to drive "you have a new friend
request" push notifications.
{
"requesterPlayerId": "plr_…",
"requesterExternalPlayerId": "player_42",
"targetPlayerId": "plr_target_…",
"targetExternalPlayerId": "player_88",
"requestId": "frq_…",
"createdAt": "2026-07-17T12:00:00.000Z"
}friend.request_accepted
A friendship became active — either the target explicitly accepted, or a reciprocal add auto-accepted. Fires once per new friendship. Mirror it into your own social graph or fire a "you're now friends" toast.
{
"requestId": "frq_…",
"playerId": "plr_…",
"externalPlayerId": "player_88",
"friendPlayerId": "plr_target_…",
"friendExternalPlayerId": "player_42",
"reciprocal": false,
"friendsSince": "2026-07-17T12:00:01.000Z"
}friend.removed
A friendship ended, via an explicit remove or because one side
blocked the other. Carries both external ids so you can tear down the
relationship on your side too.
{
"playerId": "plr_…",
"externalPlayerId": "player_42",
"friendPlayerId": "plr_target_…",
"friendExternalPlayerId": "player_88",
"reason": "removed",
"removedAt": "2026-07-17T13:00:00.000Z"
}reason is "removed" for a direct removal or "blocked" when the
removal was a side effect of a block.
Inventory and 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 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 theplayer.registereddouble-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.
Full payload schemas
Every webhook kind's payload schema is part of the
Server API reference under the
webhook tag. Use it with any OpenAPI codegen to get typed
payloads in your stack.