Kraty

Friends

Add a social graph to your game: friend codes, username search, suggested players, accept/decline requests, live online presence, blocking, and gifting — all from the client SDK.

Friends gives your game a social graph without you standing up a friends service of your own. Players find each other by name or by a short shareable code, send requests, see who's online right now, and block anyone they don't want to hear from. Everything is driven from the client SDK with the calling player's own secret, so a client can only ever act on its own social graph.

The model

  • Scoped to one game + environment. A friendship lives inside a single game and a single environment (test or live, derived from the API key). Friends made in your test build never leak into live, and players in one game are invisible to another.
  • Three ways to find someone. A player can be found by a username search (display-name match within the game), by their friend code — a short, 6-character shareable string that's stable for the life of the player, handy for "add me" links, QR codes, or reading out loud — or from suggestions, a server-picked sample of recently-active players they have no relationship with yet. All three end in the same add call.
  • Requests must be accepted. add creates a pending request the other player has to accept. The one exception is a reciprocal add: if the target had already requested the caller, the second add auto-accepts and the friendship is live immediately.
  • Live presence via heartbeat. While a player is active the client sends a lightweight heartbeat (~every 30s). Friends see their online flag, lastActiveAt, and an optional free-form status label ("in_match", "lobby", …). Presence has a short TTL: stop beating and the player quietly goes offline.
  • Blocking. Blocking a player tears down any friendship or pending request between the two and hides each from the other's search and presence.
  • Gifting. Friends can send each other items or economy resources (including XP), on a cooldown you control. You decide in the dashboard exactly which items are sendable — nothing is by default. A gift arrives as a pending grant the recipient has to claim.

Friends is a client-SDK feature. Every call is authorized by the client_sdk API key (Authorization: Bearer <key>) plus the player's own secret (X-Player-Secret: <secret>), so a player can only read and modify their own friends, requests, and blocks. The acting player is the :externalId in the path. Server-side tooling can address a different player by passing { as } on any SDK method.

End to end with the TypeScript SDK

Everything below hangs off kraty.friends.*. The SDK resolves the active player automatically (see the TypeScript SDK guide), so you never pass the player id yourself.

Get and share your friend code

import { Kraty } from '@kraty/sdk';

const kraty = new Kraty({ apiKey: '<your-client-sdk-key>' });

// Generated on first use, then stable forever. Show it in a
// "your code" UI or drop it into a share sheet.
const { friendCode, displayIdentity } = await kraty.friends.getCode();
console.log(`Add me: ${friendCode}`); // e.g. "K7F2Q9"

Find someone by name

// Search other players by display name within this game + environment.
// Blocked players are omitted; each hit reports your relationship.
const results = await kraty.friends.search('shadow', { limit: 10 });

for (const r of results) {
  // relationship: 'none' | 'friends' | 'request_incoming' | 'request_outgoing'
  console.log(r.displayIdentity?.name, r.externalPlayerId, r.relationship);
}

Suggest people to add

Search only helps a player who already knows a name to type. Suggestions fill the empty state: recently-active players in the same game + environment the caller has no relationship with — not already a friend, no pending request in either direction, not blocked, and not themselves.

// A weighted-random sample biased toward the most recently active,
// so the same player gets a different set each time they pull to
// refresh — there is no cursor to keep.
const suggested = await kraty.friends.suggestions({ limit: 5 });

for (const s of suggested) {
  console.log(s.displayIdentity?.name, s.online, s.lastActiveAt);
}

// Turn one into a request with the id you already have.
await kraty.friends.add({ externalPlayerId: suggested[0]!.externalPlayerId });

limit defaults to 5 and is clamped to 1–25. Each suggestion carries the same identity and live-presence fields as a friend row (minus friendsSince / giftCooldownUntil — there's no edge yet), so a suggestion card and a friend card can share one component.

Because the sample is re-drawn per call, a player who ignores a suggestion will usually see someone else next time without you tracking any "already suggested" state. It also means the endpoint is not stable to paginate — ask for the number you want to show.

Send a friend request

You can add by friend code or by externalPlayerId — pass exactly one.

// By the code they shared with you:
const byCode = await kraty.friends.add({ friendCode: 'K7F2Q9' });

// Or by a player id you already have (e.g. from a search hit):
const byId = await kraty.friends.add({ externalPlayerId: 'player_88' });

if (byCode.status === 'pending') {
  // They must accept. `byCode.request` is the outgoing request row.
  console.log('Request sent:', byCode.request?.requestId);
} else {
  // Reciprocal auto-accept: they'd already requested you.
  console.log('Instant friend:', byCode.friend?.externalPlayerId);
}

The call throws KratyApiError on the friend-specific error codes — cannot_friend_self (400), already_friends (409), friend_code_invalid (404), or player_blocked (403):

import { KratyApiError } from '@kraty/sdk';

try {
  await kraty.friends.add({ friendCode: userInput });
} catch (err) {
  if (err instanceof KratyApiError) {
    switch (err.code) {
      case 'friend_code_invalid': return toast('That code doesn’t exist.');
      case 'already_friends':     return toast('You’re already friends.');
      case 'cannot_friend_self':  return toast('You can’t add yourself.');
      case 'player_blocked':      return toast('Unable to add this player.');
    }
  }
  throw err;
}

Review and respond to requests

// Both directions in one call.
const { incoming, outgoing } = await kraty.friends.listRequests();

// Accept an incoming request → returns the new Friend.
if (incoming.length > 0) {
  const friend = await kraty.friends.accept(incoming[0].requestId);
  console.log('Now friends with', friend.externalPlayerId);
}

// Decline an incoming request you don't want.
await kraty.friends.decline(incoming[1]?.requestId);

// Cancel an outgoing request you sent by mistake.
await kraty.friends.cancelRequest(outgoing[0]?.requestId);

List friends with live presence

const friends = await kraty.friends.list();

for (const f of friends) {
  const where = f.status ? ` — ${f.status}` : '';
  const seen = f.online
    ? 'online'
    : f.lastActiveAt
      ? `last seen ${new Date(f.lastActiveAt).toLocaleString()}`
      : 'offline';
  console.log(`${f.displayIdentity?.name ?? f.externalPlayerId}: ${seen}${where}`);
  console.log(`  friends since ${f.friendsSince}`);
}

Show each friend's level

Friend lists usually want a number next to the name — a level, a rank, a trophy count. Pass progression with the economy keys you want and each row comes back with those balances, in the same round-trip:

const friends = await kraty.friends.list({ progression: ['level', 'trophies'] });

for (const f of friends) {
  const lvl = f.progression?.level ?? 0;
  console.log(`Lv ${lvl} ${f.displayIdentity?.name ?? f.externalPlayerId}`);
}

It's a parameter rather than a fixed field because "the level" is a different progression item in every game — pass whichever keys your economy defines. A resource the friend has never earned reads 0 rather than being missing, so there's nothing to null-check.

The same option works on friends.search(...), friends.listRequests(), and friends.getCode() (which returns the caller's own balances, handy for a profile card that shows your code and your level together). Up to 8 keys per request; omit the option entirely and nothing extra is read.

All three client SDKs take it — progression: ['level'] in Dart, progression: new[] { "level" } in C# — and expose the result as a nullable string→int map on each row.

Broadcast your own presence

Call heartbeat on a timer (about every 30 seconds) while the player is active. Presence expires automatically once the heartbeats stop, so there's no explicit "go offline" call — closing the app is enough.

// Kick off a heartbeat loop for as long as the player is in-app.
const beat = setInterval(() => {
  void kraty.friends.heartbeat({ status: 'lobby' });
}, 30_000);

// Update the status label whenever the player's activity changes.
await kraty.friends.heartbeat({ status: 'in_match' });

// Clear the label but stay online.
await kraty.friends.heartbeat({ status: null });

// Stop beating when the player leaves; presence lapses on its own.
clearInterval(beat);

Remove a friend

await kraty.friends.remove('player_88'); // pass the friend's externalPlayerId

Block and unblock

// Block by code or by id. Removes any friendship / pending request
// and hides you both from each other's search + presence.
await kraty.friends.block({ externalPlayerId: 'player_99' });

// See who you've blocked.
const blocked = await kraty.friends.listBlocks();
for (const b of blocked) {
  console.log(b.displayIdentity?.name, 'blocked at', b.blockedAt);
}

// Lift the block.
await kraty.friends.unblock('player_99');

Send a friend a gift

Gifting is off until you turn it on, and then it moves only what you allow. Two switches:

  1. Game → Settings → Gifting. Turn the feature on and set the cooldown (how long before a player can gift the same friend again — 24h by default), an optional daily send limit, and how long an unclaimed gift stays claimable.
  2. The item or resource itself. Open any item (or currency / progression resource) and tick Gifting → Players can gift this to friends. Set the max per gift, and choose whether sending costs the sender. Enabling gifting on the game alone sends nothing: the allowlist starts empty.

Because the list is server-driven, ask for it rather than hardcoding it:

const catalog = await kraty.friends.giftCatalog();

console.log(catalog.cooldownSeconds); // 86400 — once a day per friend
console.log(catalog.dailySendLimit);  // null = unlimited
console.log(catalog.sentToday);       // how many they've already sent

for (const r of catalog.resources) {
  // r.type is 'item' or 'currency' ('currency' includes XP and other
  // progression resources). r.maxAmount caps a single gift, and
  // r.costsSender says whether it comes out of the sender's own balance.
  console.log(r.type, r.key, `≤${r.maxAmount}`, r.costsSender ? '(costs you)' : '(free)');
}

Then send one:

const gift = await kraty.friends.sendGift({
  to: { externalPlayerId: 'player_88' },   // or { friendCode: 'K7F2Q9' }
  type: 'item',
  resourceKey: 'energy_refill',
  amount: 1,
  message: 'good luck out there',
  // Optional: retrying with the same key returns the original gift
  // instead of failing the cooldown.
  idempotencyKey: 'daily-gift-2026-08-17-player_88',
});

console.log(gift.status); // 'pending' — waiting for them to claim it

Gifting XP works identically — XP is a currency-type resource:

await kraty.friends.sendGift({
  to: { externalPlayerId: 'player_88' },
  type: 'currency',
  resourceKey: 'xp',
  amount: 250,
});

The friends list tells you who's still on cooldown, so you can render the button state without any client-side bookkeeping:

const friends = await kraty.friends.list();
for (const f of friends) {
  // Only present while this friend is still on cooldown.
  const readyAt = f.giftCooldownUntil ? new Date(f.giftCooldownUntil) : null;
  console.log(f.displayIdentity?.name, readyAt ? `giftable at ${readyAt}` : 'giftable now');
}

"Costs the sender" needs platform-managed inventory. A gift that debits the sender is a real transfer, so we need to be holding their balance to take it from. If your game is studio-managed (the default), leave that box unticked and the gift is minted instead — the classic "send a friend a free life". See Inventory for the ownership modes.

Receive and claim a gift

A gift lands as a pending grant on the recipient. Unlike other rewards it deliberately does not pay out on arrival — it stays unopened until the player claims it, so you can build an inbox with a real "open" moment.

// The inbox. Pass { pendingOnly: true } for just the unclaimed ones.
const inbox = await kraty.friends.gifts({ pendingOnly: true });

for (const g of inbox) {
  console.log(
    `${g.player.displayIdentity?.name} sent you ${g.amount}× ${g.resourceKey}`,
    g.message ?? '',
  );
}

// Opening it is what actually credits the player.
const claimed = await kraty.friends.claimGift(inbox[0].giftId);
console.log(claimed.status); // 'claimed'

Each gift also carries the grantId that delivers it, so claiming through the ordinary pending-grants flow works too and pays out exactly the same way. Use whichever fits your UI.

To show a player what they've sent (and what's still unopened):

const outbox = await kraty.friends.sentGifts({ pendingOnly: true });

Refusals are explicit, so you can map them straight to UI copy:

Error codeWhat happened
gifting_disabledThe game hasn't switched gifting on.
not_friendsThe target isn't an accepted friend.
gift_not_allowedThat item isn't on your giftable list (or it costs the sender in a studio-managed game).
gift_amount_invalidAmount was zero, negative, or over the item's max.
gift_cooldownToo soon to gift this friend again — the message carries the exact time.
gift_limit_reachedThe sender hit the game's daily send limit.
gift_insufficient_balanceThe gift costs the sender and they can't afford it.
gift_expiredThe gift sat unclaimed past its expiry.

The same flow over raw REST

Every endpoint lives under /sdk/v1, takes the client_sdk bearer key plus the player's secret, and wraps success in { "data": … }. The :externalId segment is the acting player.

Send a request

POST /sdk/v1/players/{externalId}/friends/requests
Authorization: Bearer <your-client-sdk-key>
X-Player-Secret: <player-secret>
Content-Type: application/json

{ "friendCode": "K7F2Q9" }

A brand-new request returns 201:

{
  "data": {
    "status": "pending",
    "request": {
      "requestId": "frq_…",
      "direction": "outgoing",
      "player": {
        "externalPlayerId": "player_88",
        "displayIdentity": { "name": "ShadowStrike", "avatar": null, "country": "PT" }
      },
      "createdAt": "2026-07-17T12:00:00.000Z"
    }
  }
}

If the target had already requested you, it auto-accepts and returns 200 with the friendship instead:

{
  "data": {
    "status": "accepted",
    "friend": {
      "externalPlayerId": "player_88",
      "displayIdentity": { "name": "ShadowStrike", "avatar": null, "country": "PT" },
      "friendsSince": "2026-07-17T12:00:01.000Z",
      "online": true,
      "lastActiveAt": "2026-07-17T12:00:00.000Z",
      "status": "lobby"
    }
  }
}

Accept a request

POST /sdk/v1/players/{externalId}/friends/requests/{requestId}/accept
Authorization: Bearer <your-client-sdk-key>
X-Player-Secret: <player-secret>
{
  "data": {
    "friend": {
      "externalPlayerId": "player_88",
      "displayIdentity": { "name": "ShadowStrike", "avatar": null, "country": "PT" },
      "friendsSince": "2026-07-17T12:00:01.000Z",
      "online": false,
      "lastActiveAt": null,
      "status": null
    }
  }
}

List friends

GET /sdk/v1/players/{externalId}/friends
Authorization: Bearer <your-client-sdk-key>
X-Player-Secret: <player-secret>
{
  "data": {
    "friends": [
      {
        "externalPlayerId": "player_88",
        "displayIdentity": { "name": "ShadowStrike", "avatar": null, "country": "PT" },
        "friendsSince": "2026-07-17T12:00:01.000Z",
        "online": true,
        "lastActiveAt": "2026-07-17T12:34:56.000Z",
        "status": "in_match"
      }
    ]
  }
}

Suggest people to add

GET /sdk/v1/players/{externalId}/friends/suggestions?limit=5
Authorization: Bearer <your-client-sdk-key>
X-Player-Secret: <player-secret>
{
  "data": {
    "suggestions": [
      {
        "externalPlayerId": "player_88",
        "displayIdentity": { "name": "ShadowStrike", "avatar": null, "country": "PT" },
        "online": true,
        "lastActiveAt": "2026-07-17T12:34:56.000Z",
        "status": "in_match"
      }
    ]
  }
}

Add ?progression=level to attach balances, exactly like GET /friends and GET /friends/search.

Refresh presence

POST /sdk/v1/players/{externalId}/presence
Authorization: Bearer <your-client-sdk-key>
X-Player-Secret: <player-secret>
Content-Type: application/json

{ "status": "in_match" }
{
  "data": {
    "online": true,
    "lastActiveAt": "2026-07-17T12:34:56.000Z",
    "status": "in_match"
  }
}

Send a gift

POST /sdk/v1/players/{externalId}/friends/gifts
Authorization: Bearer <your-client-sdk-key>
X-Player-Secret: <player-secret>
Content-Type: application/json

{
  "externalPlayerId": "player_88",
  "type": "item",
  "resourceKey": "energy_refill",
  "amount": 1,
  "message": "good luck out there"
}
{
  "data": {
    "gift": {
      "giftId": "9f1c0f6e-2b3a-4c5d-8e7f-0a1b2c3d4e5f",
      "grantId": "3e2d1c0b-9a8f-7e6d-5c4b-3a2f1e0d9c8b",
      "direction": "outgoing",
      "player": {
        "externalPlayerId": "player_88",
        "displayIdentity": { "name": "ShadowStrike", "avatar": null, "country": "PT" }
      },
      "type": "item",
      "resourceKey": "energy_refill",
      "amount": 1,
      "message": "good luck out there",
      "status": "pending",
      "sentAt": "2026-08-17T09:00:00.000Z",
      "claimedAt": null,
      "expiresAt": null
    }
  }
}

Claim a gift

POST /sdk/v1/players/{externalId}/friends/gifts/{giftId}/claim
Authorization: Bearer <your-client-sdk-key>
X-Player-Secret: <player-secret>

The response is the same gift object with status: "claimed" and a claimedAt timestamp. This is the call that actually credits the player — before it, the gift is only a pending grant.

The full endpoint list — friend code, search, requests (list / accept / decline / cancel), friends (list / remove), gifts (catalog / send / inbox / sent / claim), and blocks (list / add / remove) — is in the SDK API reference.

React to the social graph from your backend

Three webhook kinds let your studio backend follow the social graph (for analytics, anti-abuse, or push notifications) without polling:

Event kindFires when
friend.request_sentA player sends a friend request (the pending case; a reciprocal auto-accept fires friend.request_accepted instead).
friend.request_acceptedA request is accepted — whether explicitly via accept or through a reciprocal auto-accept.
friend.removedA friendship ends, via remove or because one side blocked the other.
gift.sentA player sends a gift. Fires alongside a grant.created for the pending grant that delivers it.
gift.claimedThe recipient opens a gift. Fires alongside the usual grant.claimed.

Subscribe to them from your game's Webhooks tab exactly like any other kind; see Webhooks → Social graph for the per-kind payloads plus the shared envelope, signing, and retry model.

SDK availability

The Friends surface is mirrored across the client SDKs so the method names line up 1:1:

  • TypeScript (@kraty/sdk) — kraty.friends.*, documented above.
  • Flutter and Unity already ship the same surface (kraty.friends.getCode() in Dart, kraty.Friends.GetCodeAsync() in C#).
  • iOS and Android native SDKs will mirror this exact surface when they land; the wire contract is stable, so anything you build against REST today keeps working.
// Flutter client SDK
final code = await kraty.friends.getCode();
await kraty.friends.add(FriendTarget.code('K7F2Q9'));
final friends = await kraty.friends.list();

// With progression balances attached (see "Show each friend's level").
final withLevels = await kraty.friends.list(progression: ['level']);
final lvl = withLevels.first.progression?['level'] ?? 0;

// "People you may want to add" — a different sample on each call.
final suggested = await kraty.friends.suggestions(limit: 5);

// Gifting.
final giftCatalog = await kraty.friends.giftCatalog();
await kraty.friends.sendGift(SendGiftInput.toExternalPlayerId(
  'player_88',
  type: 'item',
  resourceKey: 'energy_refill',
  amount: 1,
));
final inbox = await kraty.friends.gifts(pendingOnly: true);
await kraty.friends.claimGift(inbox.first.giftId);
// Unity client SDK
var code = await kraty.Friends.GetCodeAsync();
await kraty.Friends.AddAsync(FriendTarget.Code("K7F2Q9"));
var friends = await kraty.Friends.ListAsync();

// With progression balances attached.
var withLevels = await kraty.Friends.ListAsync(progression: new[] { "level" });
var lvl = withLevels[0].Progression?.GetValueOrDefault("level") ?? 0;

// "People you may want to add" — a different sample on each call.
var suggested = await kraty.Friends.SuggestionsAsync(limit: 5);

// Gifting.
var giftCatalog = await kraty.Friends.GiftCatalogAsync();
await kraty.Friends.SendGiftAsync(
    SendGiftInput.ToPlayerId("player_88", "item", "energy_refill", 1));
var inbox = await kraty.Friends.GiftsAsync(pendingOnly: true);
await kraty.Friends.ClaimGiftAsync(inbox[0].GiftId);