Kraty

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 */ }
}
  • 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 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, 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), 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.

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_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.
  • 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:

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: 1 header on sample deliveries. Filter your own logs on this so samples do not 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 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_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.

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 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.

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.

On this page

Subscribing
Create the endpoint
Capture the signing secret
Manage from the same tab
Live vs test environments
Envelope
Signing
Retries and circuit breaker
Wire your first endpoint end-to-end
Mint and rotate API keys
Testing your receiver
Per-attempt debouncing
Event catalog
Player lifecycle
player.registered
player.secret_set
player.secret_rotated
player.merged
player.deleted
player.banned
player.unbanned
Event and attempt lifecycle
event.attempt_started
event.attempt_restarted
event.progress_updated
event.completed
event.force_completed
event.expired
event.attempt_flagged
event.milestone_granted
entry_cost.charged
Commerce
purchase.tracked
Window and leaderboard
window.opened
window.closed
leaderboard.finalized
leaderboard.period_finalized
Session lifecycle
session.terminated
session.frozen
Team events
event_team.formed
event_team.member_joined
event_team.member_left
event_team.milestone_unlocked
event_team.goal_completed
event_team.finalized
partner.invite_sent
partner.invite_accepted
Grants and crates
grant.created
grant.claimed
grant.expired
crate.opened
Lobbies (matchmaking)
lobby.created
lobby.player_joined
lobby.bot_filled
lobby.started
lobby.fill_timeout
lobby.closed
Social graph (friends)
friend.request_sent
friend.request_accepted
friend.removed
Social graph (gifting)
gift.sent
gift.claimed
Guilds
guild.created
guild.updated
guild.disbanded
guild.member_joined
guild.member_left
guild.role_changed
guild.join_requested
guild.help_requested
guild.help_donated
Inventory and wallet (platform-managed)
inventory.changed
wallet.changed
Operational
webhook.endpoint.failed
Idempotency at the receiver
Full payload schemas
Webhooks · Kraty