New

Parties, friends and presence

What a game client speaks to be social — joining and running a party, the ready round, bookings, friend requests, and telling friends where a player is.

These are the routes a game client calls to be social: the party routes dot-party’s DotPartyBackendApp calls, and the friend and presence routes dot-friends’ DotFriendsBackendApp calls.

Every one is under /api/app/v1, takes the player’s own token, answers cross-origin (preflight included), and accepts a game token — the credential a game in the web player holds. A device token works too.

The game server’s half — reading a party’s roster, reporting its state, learning it has been booked — is the integration API: see Parties.

One implementation, two front doors

Every route is a thin wrapper over the function the website’s own party or friend procedure calls, with that procedure’s gates and its rate limit — under the same bucket name. A member alternating between a browser tab and a game gets what either gets alone, not double.

That is also why a refusal looks the way it does.

code is the site's own translation key

party.join.deny.full, friends.request.deny.cooldown — exactly the key the website throws and its page translates. message is the English sentence behind it. Branch on code; dot-party and dot-friends put it in DotError.detail.

A refusal with no key of its own

Falls back to a generic code for its kind: not_found, forbidden, conflict, invalid, rate_limited.

The pipeline's own refusals are unchanged

unauthorized, token_expired, wrong_credential, bad_request (the body did not match), rate_limited with retryAfter, and internal_error. See the envelope.

A game credential reaches only its own game

Every route addressing a party refuses one in another app with party.deny.otherApp; party/mine and party/invites only list its own game’s; party/create takes the app off the token and refuses an appId that disagrees. A device credential is bound to no app and is not narrowed.

Parties

Ids are decimal strings — they can pass 2^53. Dates are ISO strings. For what the stages and roles mean, see the party lifecycle.

Route Body / query data
GET party/mine — the party, or null
GET party/{id} — the party
POST party/create the website’s create input; appId optional on a game token the party
POST party/join { id, password?, token? } { partyId, role, leftPartyId }
POST party/leave { id, silent? } { left, ended }
POST party/heartbeat { id } null
POST party/kick { id, userId, ban? } null
POST party/role { id, userId, role } — CO_HOST or MEMBER null
POST party/transfer { id, userId } null
POST party/invite { id, userIds[1..25], message? } the invite ids made
GET party/invites — [{ id, partyId, partyName, inviterId, message }]
POST party/invite/respond { inviteId, accept } null, or as join when accepted
POST party/start { id, serverId? } the ready view
GET party/{id}/ready — the ready view
POST party/ready { id, ready? } — ready defaults to true the ready view
POST party/connected { id } null
POST party/force-start { id } null
POST party/cancel { id } null
POST party/end { id } null
GET party/{id}/reserve/offer?serverId= — the booking offer
POST party/reserve { partyId, serverId, minutes, private?, note? } the reservation
POST party/reserve/cancel { reservationId } the reservation

The party object

Flat: the integration API’s party with stage and members inside it.

{
  "id": "4471", "name": "Scrim", "type": "PUBLIC", "techType": "NATIVE",
  "maxUsers": 10, "users": 4, "stage": "LOBBY",
  "startTime": "2026-09-23T20:00:00.000Z", "endTime": null,
  "mapName": null, "gameMode": null, "hostId": "clx…", "appId": 3, "serverId": null,
  "members": [
    {
      "userId": "clx…", "displayName": "Ashley", "gameName": null,
      "role": "HOST", "state": "JOINED", "presence": "NONE",
      "joinedAt": "…", "leftAt": null, "readyAt": null, "connectedAt": null,
      "score": 0, "kills": 0, "deaths": 0, "assist": 0
    }
  ]
}

It is gated exactly as the party page is. Whether the party is visible at all, whether its roster travels, and each member through the viewer’s own privacy resolution — an anonymous member, one who opted out of listings, and one either side has blocked are left out rather than named. hostId is null when the host is somebody this viewer may not name.

Where it differs from the website

heartbeat

Refuses with party.heartbeat.deny.member when the player is no longer in the party — kicked, or reaped after enough silence — where the website’s procedure answers { alive: false }. A client treats the refusal as “you are out”.

invite/respond

Accepting also joins, through the ordinary join, so capacity and bans still decide. A party that filled since the invite answers party.join.deny.full, and the invite stays usable.

start

Opens the ready round on a server — serverId if given (it must be the party’s game), else the one the party already has, and party.search.err.noServer when there is none. The website’s criteria-driven search is not offered here: a game that wants a server found picks one itself.

{id}/ready

A GET that advances the round, as the website’s is: reading it is what fires an auto-start whose countdown lapsed. It is idempotent, so polling it is safe.

create

Skips the website form’s captcha — the caller holds a token minted for a signed-in member, which is what a captcha stands in for — and keeps every other gate, including the per-day party budget.

The reservation is { id, partyId, serverId, status, private, startsAt, endsAt, releasedAt, requestedById, note } — the same shape the booked server reads from GET /party/reservation.

Polling budgets

Two reads have no website procedure to borrow a limit from, so they have app-only buckets:

Route Bucket
GET party/mine app:party-mine
GET party/invites app:party-read

party/mine is sized for three clients on one account polling the ready round every 1.5 seconds. The two used to share one bucket, until a player with the web player open in a tab and the desktop game running 429’d themselves mid-round.

Friends

The website’s friend flow, rule for rule:

  • One row per pair, whoever asked first.
  • Asking somebody who already asked you accepts them.
  • After a decline, only the declined sender waits seven days; a re-request re-uses the row, turned round to face the new asker.
  • Only the addressee may answer. Removing deletes the row.
Route Body / query data
GET friends — [{ userId, displayName, avatarUrl, presence }]
GET friends/requests — { incoming: [Request], outgoing: [Request] }
POST friends/request { userId } { id }
POST friends/respond { requestId, accept } null
POST friends/cancel { requestId } — an outgoing request’s id null
POST friends/remove { userId } null

A Request is { id, userId, displayName, createdAt }. id is an integer and goes back as requestId; userId is always the other person.

GET friends lists who the website’s friend dock lists: a block either way, or a member who opted out of social listings, is left out.

Refusal codes
code When
friends.request.deny.self Asking yourself
friends.request.deny.notFound No such member, or blocked — never distinguished
friends.request.deny.closed The member has turned friend requests off
friends.request.deny.already Already friends
friends.request.deny.pending You already asked and they have not answered
friends.request.deny.cooldown They declined you less than seven days ago
friends.respond.deny.notFound Not a pending request addressed to you (a blocked requester reads the same)
friends.cancel.deny.notFound Not a pending request you sent — already answered, withdrawn, or somebody else’s
friends.remove.deny.notFriends Not friends

Not found and blocked read the same on purpose

A distinct “blocked” answer would tell anybody who has been blocked that they have been.

Presence

What a game reports about where its player is, so their friends can follow them in.

POST/api/app/v1/presenceToken

Report where the player is. Answers { ttlSec }.

{ "status": "in_game", "serverId": 4821, "partyId": "4471", "joinable": true, "detail": "Round 3" }
GET/api/app/v1/presence?userIds=a,bToken

Friends’ presence, keyed by user id. At most 100 ids, comma-separated.

{
  "status": "in_game",
  "appId": 3, "appName": "Arena",
  "serverId": 4821, "serverName": "EU #1",
  "partyId": "4471",
  "joinable": true,
  "detail": "Round 3",
  "updatedAt": "2026-09-23T20:00:10.000Z"
}

status is offline, online, in_game or away.

A post lives ttlSec after the last one

Then it reads as offline; posting offline clears it at once. Heartbeat well inside the window — dot-friends posts often enough that two lost posts do not blink a player offline.

Each post replaces the whole presence

A field left out is cleared, not kept.

The app is the token's, never the post's

A serverId that is hidden or another game’s, and a partyId the player is not in, are dropped from the post rather than refused. joinable means nothing without one of them and is dropped with them.

Only a game token places its player

A device token is bound to no game, so its post keeps status and detail and drops serverId, partyId and joinable — with no game to check against, it could otherwise name any game’s server.

Without a fresh game post

A friend reads the website’s own presence: active on the site is online, idle is away, anything else offline.

Offline is blank: every nullable field null, joinable false, updatedAt null. That is the answer for an expired post, a posted offline, and a member whose privacy settings hide their activity — who reads as offline to everybody, friends included.

What a reader is told is checked against what they could see on the site: a PRIVATE party, or one that has ended, is not named, nor is a hidden server.

Refusal code When
presence.deny.status status is not one of the four
presence.deny.detail detail over 128 characters
presence.deny.batch More than 100 ids