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 }] }
}Commerce
purchase.tracked
A real-money purchase was recorded (see Purchases & spend). Fires once per store transaction: a replayed receipt is deduplicated before this webhook, so it is safe to fulfil an order or grant goods directly from it.
amountMinor + currency are what the player was charged;
normalizedAmountMinor is the USD-cent equivalent that spend
thresholds and revenue reporting use. lifetimeAmountMinor is the
player's total after this purchase, so you can detect a tier
crossing without a follow-up read.
{
"purchaseId": "pur_…",
"playerId": "plr_…",
"externalPlayerId": "player_42",
"transactionId": "1000000987654321",
"store": "google_play",
"productId": "com.studio.game.gems_500",
"productType": "consumable",
"amountMinor": 2990,
"currency": "BRL",
"normalizedAmountMinor": 520,
"fxRate": 0.174,
"validationStatus": "unverified",
"purchasedAt": "2026-01-01T00:00:00.000Z",
"lifetimeAmountMinor": 4180,
"purchaseCount": 6,
"metadata": { "offerId": "starter_pack_v2" }
}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. A co-op team board fires it
too, with participantScope: 'team' (its final standings rank teams;
the per-team results arrive as event_team.finalized).
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.
Team events
Every team webhook carries the team's window environment, so test-window teams only reach test endpoints.
event_team.formed
A team appeared in an event window (for studio-owned teams: the first
roster call for that team key in that window). Carries the team id and
key, display name, formation, window, member count, resolved goal and
your attributes.
event_team.member_joined
A player took a seat on a team (roster call, or a rejoin). Carries the seat id, the player and the team's new member count.
event_team.member_left
A player left or was kicked (reason: 'left' | 'kicked'). Carries the
seat's contribution and contributionRemoved (true when the event's
onLeave is remove_contribution, i.e. the team score dropped).
event_team.milestone_unlocked
The team score crossed a team milestone. Fires once per (team,
milestone). paidSeatCount is how many members were paid inline;
payState: 'pending' means more eligible members are paid shortly by
the payout phase. Members below the milestone's minContribution are
paid when their own contribution reaches it, until the window closes.
{
"eventTeamId": "etm_…",
"teamKey": "red_dragons",
"eventKey": "team_chest",
"milestoneKey": "step1",
"threshold": 100,
"teamScore": 104,
"seatCount": 4,
"paidSeatCount": 3,
"payState": "inline_done"
}event_team.goal_completed
The team reached its goal (the boss was defeated). Fires once per
team. With onComplete: 'stop_contributions' the team score and
milestones freeze from here on, while members' own contributions keep
counting toward member-rank rewards.
event_team.finalized
The team's window closed: its final score is fixed. Carries rank
(the team's final place on its team board: score, then who reached it
first), boardSize (teams and rival bot teams on that board) and
segmentValues (the board's segment, e.g. { regatta_league: '2' }),
all null for teams that never entered a team board,
the final score, goalTarget, completed and memberCount. Team
progression (promotion, relegation) is yours: drive it from this event.
Team rewards are paid shortly after, as grant.created with
sourceKind: 'event_team_rank' (and event_team_milestone for
milestones).
partner.invite_sent
Partner events: a player invited another to a pair. Carries the pair's
eventTeamId, both players (fromExternalPlayerId,
toExternalPlayerId) and when the invite expires.
partner.invite_accepted
The invited player accepted (or invited back): the pair's bar is live.
Carries the same players and the bar's resolved goalTarget. An
event_team.formed for the pair follows.
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.
Social graph (gifting)
gift.sent
One player sent another a gift. Fires alongside a grant.created for
the pending grant that delivers it, so a backend that already consumes
grants needs no gifting-specific handler to hand over the goods — this
kind is the one that tells you who it came from.
{
"giftId": "gft_…",
"grantId": "grt_…",
"senderPlayerId": "plr_…",
"senderExternalPlayerId": "player_42",
"recipientPlayerId": "plr_target_…",
"recipientExternalPlayerId": "player_88",
"type": "item",
"resourceKey": "energy_refill",
"amount": 1,
"deductedFromSender": false,
"message": "good luck out there",
"sentAt": "2026-08-17T09:00:00.000Z",
"expiresAt": null
}type is "item" or "currency"; "currency" covers progression
resources like XP as well as spendable currencies.
deductedFromSender records whether the send debited the sender's own
balance (a transfer) or minted the gift.
gift.claimed
The recipient opened a gift. This is also the moment the contents are
actually credited — a gift grant defers its payout to the claim — so
fires alongside the usual grant.claimed.
{
"giftId": "gft_…",
"grantId": "grt_…",
"senderPlayerId": "plr_…",
"senderExternalPlayerId": "player_42",
"recipientPlayerId": "plr_target_…",
"recipientExternalPlayerId": "player_88",
"type": "item",
"resourceKey": "energy_refill",
"amount": 1,
"claimedAt": "2026-08-17T10:30:00.000Z"
}Guilds
Every guild webhook carries guildId and guildName, and fires in the
guild's own environment (a test guild only reaches test endpoints). by
says who did it: { "kind": "player", "playerId", "externalPlayerId" }, a
dashboard member ({ "kind": "member", "memberId" }), a server key
({ "kind": "api_key", "apiKeyPrefix" }) or the platform itself
({ "kind": "system" }, e.g. the inactive-leader hand-over). See
Guilds.
guild.created
A player founded a guild (they lead it). cost lists the founding cost and
costsChargedBy who takes it: platform means Kraty already debited it;
studio means your game manages inventory itself and Kraty charged nothing,
so charge the founder from your backend here (or from the client before the
call). A guild.member_joined with via: "created" follows.
{
"guildId": "gld_…",
"guildName": "Night Owls",
"joinType": "open",
"country": "PT",
"leaderPlayerId": "plr_…",
"leaderExternalPlayerId": "player_42",
"cost": [{ "type": "currency", "key": "coins", "amount": 100 }],
"costsChargedBy": "platform",
"createdAt": "2026-10-08T10:00:00.000Z"
}guild.updated
The profile changed (name, description, badge, country, join type or
metadata), by a guildmate with edit_profile or by a moderator. changes
holds before / after per field.
guild.disbanded
The guild closed: reason is last_member_left or admin (a moderator).
On admin, a guild.member_left with reason: "disbanded" fires for every
member first.
guild.member_joined
A player joined. via is created, open, request (an officer accepted
their request), admin (test tools) or merge (a merged player's stint
moved to the surviving account).
{
"guildId": "gld_…",
"guildName": "Night Owls",
"playerId": "plr_…",
"externalPlayerId": "player_88",
"role": "member",
"via": "request",
"memberCount": 12,
"joinedAt": "2026-10-08T10:00:00.000Z",
"by": { "kind": "player", "playerId": "plr_…", "externalPlayerId": "player_42" }
}guild.member_left
A player left the guild. reason is left, kicked, removed (a
moderator), disbanded, erased (GDPR deletion) or merged. When the
leader leaves, a guild.role_changed for the new leader follows.
guild.role_changed
A member's role changed: from / to among leader, co_leader,
member. reason is promoted, demoted, leadership_transferred,
succession_leader_left, succession_leader_inactive or operator.
guild.join_requested
A player asked to join a request-only guild (requestId, message).
Declines and cancellations are not webhooked.
guild.help_requested
A member asked guildmates for help: type, resourceKey, amount,
expiresAt.
guild.help_donated
A guildmate donated to a help request. Fires alongside the grant.created
that delivers it to the requester (sourceKind: "guild_help") and, when the
game pays one, the donor's reward grant (guild_help_reward). When
costsDonor is true and costsChargedBy is studio, Kraty took nothing
from the donor: take amount of resourceKey from them in your backend.
{
"guildId": "gld_…",
"guildName": "Night Owls",
"requestId": "ghr_…",
"donationId": "ghd_…",
"grantId": "grt_…",
"rewardGrantId": "grt_…",
"donorPlayerId": "plr_…",
"donorExternalPlayerId": "player_88",
"recipientPlayerId": "plr_…",
"recipientExternalPlayerId": "player_42",
"type": "item",
"resourceKey": "life",
"amount": 1,
"costsDonor": true,
"costsChargedBy": "platform",
"amountReceived": 3,
"amountRequested": 5,
"filled": false,
"donatedAt": "2026-10-08T10:00:00.000Z"
}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.