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.messageis the English sentence behind it. Branch oncode; dot-party and dot-friends put it inDotError.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_limitedwithretryAfter, andinternal_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.
The caller's own row is always there
Whatever state it is in, and even after the party has become invisible to them.
A game polls this to learn what happened to its player, and kicked, banned and
left are different things to tell them. A member kicked from a PRIVATE party
could no longer see it by the ordinary rule — without this, the kick would read
as the party vanishing.
Where it differs from the website
- heartbeat
Refuses with
party.heartbeat.deny.memberwhen 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 —
serverIdif given (it must be the party’s game), else the one the party already has, andparty.search.err.noServerwhen 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
GETthat 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.
/api/app/v1/presenceTokenReport where the player is. Answers { ttlSec }.
{ "status": "in_game", "serverId": 4821, "partyId": "4471", "joinable": true, "detail": "Round 3" }
/api/app/v1/presence?userIds=a,bTokenFriends’ 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
offlineclears 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
serverIdthat is hidden or another game’s, and apartyIdthe player is not in, are dropped from the post rather than refused.joinablemeans 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
statusanddetailand dropsserverId,partyIdandjoinable— 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 isaway, 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.
Friends only — and not "offline" for strangers
GET presence leaves out anybody who is not the caller’s friend, rather
than answering offline for them. “Offline” would still confirm the id is
somebody.
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 |
