Connect for any engine
Link a player of a game built with any engine to their Discord account in a community, with seven web requests and nothing else.
CEEJAY has kits for Swift, Kotlin, Godot, Flutter and TypeScript. An engine with no kit can do everything they do with seven web requests. This page gives the requests in the order a game sends them, with the exact headers, the bodies and the responses, and says what the game has to store and when it polls again.
It is for the game itself, with its public key. A studio's own server never uses these requests: they are rate limited per IP address, and a server has one. A server uses the requests under Connect from your server with a secret key. Connect explains the flow and the confirmation from both sides.
What a kit does for you
A kit stores the session and the tokens in the device's secure store, polls for the result every 3 seconds, renews the player session one call at a time, and never prints a secret in an error. Everything below is that work, written out as plain HTTP, so you can write it in your engine's language.
Before you start
- Connect is switched on for the community's instance. Where it is off, every request returns
404with the codenot_availableandfeature: "players": hide the button. - The game is set up for the community, and has a public key that starts with
ceejay_pk_. The key ships inside the game and is no secret. A secret key (ceejay_sk_) is never sent from a game. - The base URL is the dashboard URL of the community's instance followed by
/api/v1. The shared service ishttps://ceejay.communitystudios.xyz/api/v1. A dedicated instance uses its own dashboard URL. - A web build must be served from an origin on the game's list, or every request returns
403with the codeorigin_not_allowed. A native build sends noOriginheader and is not checked this way.
The rules of every request
- Send the header
Authorization: Bearer <value>. For six of the seven requests the value is the public key. For Get the player's profile it is the player session. - Send
Content-Type: application/jsonand a JSON object as the body on every request that has a body. Get the player's profile has none. - Send no other header of your own. The response to a browser's preflight request allows
AuthorizationandContent-Typeand nothing else, and a third header stops a web game. Your HTTP library may add its ownUser-AgentandAccept-Encoding: that is fine for a native game. - Send no cookie. No cookie is read, and
Access-Control-Allow-Credentialsis never sent. - A success is
200with{ "success": true, "data": { … } }. Readdata. - A failure has a status outside 200 to 299 and
{ "error": "A message", "code": "fixed_code", "requestId": "req_…" }. Some codes addreason,field,featureorretryAfter. Act oncode: theerrormessage text can change. - IDs are strings. Dates are ISO 8601 text in UTC, such as
2026-10-08T18:04:11.000Z. - Every response includes
X-Request-Id,X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset. Quote the request ID when you ask us about a request.
What the game stores
Four values have four prefixes, and each is accepted by one kind of request only. A value sent to another kind returns 401 with the code wrong_key_kind.
| Value | Starts with | Store it | It travels in |
|---|---|---|---|
| Public key | ceejay_pk_ | In the game | The Authorization header |
| Session secret | ceejay_cs_ | In the secure store, until the session ends | The body |
| Refresh token | ceejay_rt_ | In the secure store, until the connection ends | The body |
| Player session | cjps1. | In memory only. It lasts 15 minutes | The Authorization header |
Store the session secret and the refresh token in the platform's secure store: the Keychain on Apple platforms, the Keystore on Android. Write them as one JSON text under one key, so a restart finds both:
{
"v": 1,
"session": {
"sessionId": "AbCdEfGhIjKlMnOpQrStUv",
"sessionSecret": "ceejay_cs_4f8a2c1e9b7d3e41f8a6b0c1d25f0c2a9b7d3e41f8a6b0c1d24f8a2c1e9b7d3e",
"url": "https://ceejay.communitystudios.xyz/connect/s/AbCdEfGhIjKlMnOpQrStUv",
"qrUrl": "https://ceejay.communitystudios.xyz/connect/s/AbCdEfGhIjKlMnOpQrStUv/qr",
"code": "K7M-2QX",
"expiresAt": "2026-10-08T18:14:11.000Z",
"pollMs": 3000,
"again": { "playerId": "p_19", "playerName": "Nova77", "returnTo": "game", "locale": "en" }
},
"refreshToken": "ceejay_rt_5512.1.4f8a2c1e9b7d3e41f8a6b0c1d25f0c2a9b7d3e41f8a6b0c1d24f8a2c1e9b7d3e",
"refreshExpiresAt": "2027-01-06T18:04:11.000Z"
}session is present only while a session is open. again stores what the session was started with, without the proof, so it can be started again. Treat unparseable text as an empty store. A phone often closes a game while its player is in the browser, and a secret held only in memory would be lost: the player would go through Discord a second time.
The seven requests
| Request | Called with | Returns |
|---|---|---|
Start connect, POST /connect/start | Public key | The link, the code, the QR code URL and the session secret |
Get the result, POST /connect/result | Public key | The session status |
Confirm connect, POST /connect/confirm | Public key | The game's confirmation or refusal, and what the result would return next |
Renew a player session, POST /player/session | Public key | A player session and the next refresh token |
Get the player's profile, GET /player/me | Player session | The connection and the member card |
Disconnect, POST /player/disconnect | Public key | disconnected: true |
Get connection status, POST /connect/status | Public key | connected, for a game that can verify its player |
The walk-through below follows the order a game uses them in: start, open the link, poll for the result, the game's confirmation, store the tokens, renew and read the card, and disconnect. Get connection status is used once, when the game starts.
1. Start
Send the player's ID, the game's own, as a string. Add whatever else you have.
curl -X POST https://ceejay.communitystudios.xyz/api/v1/connect/start \
-H "Authorization: Bearer $CEEJAY_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{ "playerId": "p_19", "playerName": "Nova77", "returnTo": "game", "locale": "en" }'| Field | What it is |
|---|---|
playerId | Required. 1 to 128 characters, starting with a letter or a digit, then letters, digits and . _ : @ | + = -. Always a string, also when your IDs are numbers |
playerName | The player's name in the game, at most 80 characters |
proof | { "type": "pass", "firebase", "unity" or "steam", "token": "…" }, when the game can verify who the player is. Without it the session is at the basic level |
returnTo | The key of one of the return URLs saved for the game. A URL is never accepted from a request |
locale | The language of the connect page, such as en or pt-BR |
underAge | A game set up for a mixed audience must send it. true returns 403 and stores nothing |
code | A code the player made in Discord and typed into the game. See A code from Discord |
{
"success": true,
"data": {
"sessionId": "AbCdEfGhIjKlMnOpQrStUv",
"status": "pending",
"url": "https://ceejay.communitystudios.xyz/connect/s/AbCdEfGhIjKlMnOpQrStUv",
"qrUrl": "https://ceejay.communitystudios.xyz/connect/s/AbCdEfGhIjKlMnOpQrStUv/qr",
"code": "K7M-2QX",
"sessionSecret": "ceejay_cs_4f8a2c1e9b7d3e41f8a6b0c1d25f0c2a9b7d3e41f8a6b0c1d24f8a2c1e9b7d3e",
"expiresAt": "2026-10-08T18:14:11.000Z",
"pollIntervalSeconds": 3,
"proof": "basic",
"alreadyConnected": false,
"member": null
}
}Write the session to the secure store immediately: sessionId, sessionSecret, url, qrUrl, code, expiresAt, pollIntervalSeconds times 1000 as pollMs, and what you sent as again. The session secret is returned only this once.
Never send Start connect twice for one press. A second start is a second session. It is the one request a game does not retry after a failure.
When status is approved, the proof was verified and this player is already connected: alreadyConnected is true, there is no link, and the next request for the result returns the tokens immediately.
2. Open the link, or show the code
The session lasts 10 minutes. Always show the code as well while waiting, so a player whose phone does not come back can finish on any device.
| Device | Do this |
|---|---|
| Browser game | Open a window in the player's own click, and set its URL when Start connect responds. The page cannot be shown in a frame. An engine that handles input itself (Unity WebGL, Godot web) must call this from the browser's own click event |
| Game on a computer | Open url in the system browser |
| iPhone | Open url in the system's web sign-in sheet (ASWebAuthenticationSession) or in Safari. Discord's authorisation happens in the browser, so the player signs in to Discord there once unless it is signed in already |
| Android | Open url in a browser tab (a Custom Tab) |
| Console, television | Open nothing. Show code, the URL <dashboard>/connect, and the image at qrUrl, which is an SVG QR code for the link |
Never open the link in a web view inside the game. Discord refuses to sign in there.
The game never reads the window or the tab it opened. It always polls for the result, so nothing depends on a phone switching back to the game. The player authorises the connection on CEEJAY's page, which then offers "Return to the game" with the URL saved under your returnTo key.
3. Poll for the result
Poll every pollIntervalSeconds (3) while the game is in the foreground, and immediately when the player returns to it. On a phone, stop polling while the game is in the background and poll immediately on return.
curl -X POST https://ceejay.communitystudios.xyz/api/v1/connect/result \
-H "Authorization: Bearer $CEEJAY_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{ "sessionSecret": "ceejay_cs_4f8a2c1e9b7d3e41f8a6b0c1d25f0c2a9b7d3e41f8a6b0c1d24f8a2c1e9b7d3e" }'The response has one of four forms, identified by data.status:
status | What it means | The game |
|---|---|---|
pending, needs_confirmation | Waiting for the member. expiresAt is when the session expires | Poll again after the interval |
needs_game_confirmation | The member authorised the connection. member contains displayName and avatarUrl, and alreadyConnected indicates whether this Discord account is connected to this player already | Show the name and ask the player: step 4 |
approved | Both sides confirmed | Store the tokens: step 5 |
declined, cancelled, expired, locked, refused, or any other value | The session ended without a connection. reason says why, or is null | Discard the session. Return to not connected |
How to poll:
- One read per session every 2 seconds is allowed; polling sooner returns
429with the coderate_limitedandretryAfter. Wait that many seconds. - A
429, a5xxor no response says nothing about the session. Keep polling with exponential backoff, doubling the interval up to at most 30 seconds, for as long as the session can still complete: until 10 minutes afterexpiresAt. result_gone(410): the result of an approved session can be read for 10 minutes, and this is later. Discard the session and start again with theagainfields, and a proof issued now if the game has one. A connection that exists is found by the new start, which returnsapprovedimmediately. Do this once.invalid_session_secret(401): CEEJAY does not recognise the session. Discard it.
4. The game's confirmation
When the response is needs_game_confirmation, show the Discord name and avatar with Yes and No, and send the player's choice. There is no default. A link, a QR image or a code can be seen by someone else, on a stream or a shared screen, and whoever opens it can authorise with their own Discord account. This step is where the player sees a name that is not theirs and selects No, before anything is saved.
curl -X POST https://ceejay.communitystudios.xyz/api/v1/connect/confirm \
-H "Authorization: Bearer $CEEJAY_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{ "sessionSecret": "ceejay_cs_4f8a2c1e9b7d3e41f8a6b0c1d25f0c2a9b7d3e41f8a6b0c1d24f8a2c1e9b7d3e", "accept": true }'The response is what Get the result would return next: approved after a confirmation on a session the game started, an ended form after a refusal (declined with the reason game_declined). The request is idempotent: it is safe to retry.
accept: falseends an open session in any state. It is also the game's own Cancel button, so use it there.nothing_to_confirm,session_changed,player_blockedandplayer_taken(all409) mean the session changed while the player read it, or cannot be finished. Read the result again: itsstatusshows what happened. Do this at most 5 times in a row.
A code from Discord
A member can start in Discord. The community's button gives them a personal code. The member types it into the game, and the game sends Start connect with the code in code (and no returnTo). The response has status: "needs_game_confirmation", url, qrUrl and code as null, and member naming who generated the code. Show that name to the player, then follow step 4 as above. A code can be used once. Say beside the field: Only type a code you made yourself in Discord. A player who was talked into typing someone else's code sees a stranger's name and selects No.
5. Store the tokens
The approved response contains the connection, the member card and the tokens.
{
"success": true,
"data": {
"status": "approved",
"connection": { "connectionId": "8841", "game": "rugby_manager", "playerId": "p_19", "proof": "basic" },
"member": {
"displayName": "Nova",
"nickname": "Nova | EU",
"avatarUrl": "https://cdn.discordapp.com/avatars/409876543210987654/a1b2c3d4e5f60718293a4b5c6d7e8f90.png",
"level": 12,
"xp": 5420,
"coins": 1450,
"roles": [{ "id": "1402112233445566778", "name": "Founding Member" }],
"membership": "member",
"joinUrl": null
},
"sharing": { "agreed": ["progress", "activity"], "needed": [] },
"reward": { "status": "pending", "items": [] },
"manageUrl": "https://ceejay.communitystudios.xyz/connect/mine",
"playerSession": { "token": "cjps1.eyJ2IjoxfQ.pV3mQ0b6k1y8ZtL2c9XwR4s7Nf5aHj0uEoGdBiKq3M", "expiresAt": "2026-10-08T18:19:11.000Z" },
"refreshToken": "ceejay_rt_5512.1.4f8a2c1e9b7d3e41f8a6b0c1d25f0c2a9b7d3e41f8a6b0c1d24f8a2c1e9b7d3e",
"refreshExpiresAt": "2027-01-06T18:04:11.000Z"
}
}Then, in this order:
- Write
refreshTokenandrefreshExpiresAtto the secure store, and removesessionfrom it. - Keep
playerSession.tokenin memory only, with itsexpiresAt. - Show the member card. It is these fields and no others.
- Keep
member.membership,member.joinUrl,sharing.neededandmanageUrlfor the button's states.
Each read of an approved result returns a new refresh token and invalidates the previous one. If the response was lost, read it again within 10 minutes: always store the last one.
reward is pending for a new connection, because CEEJAY's bot grants the reward shortly after the member authorises the connection. Call Get the player's profile again if you want to show it.
6. Renew, and read the card
A player session lasts 15 minutes. Renew it a minute before it ends, and when the game starts.
curl -X POST https://ceejay.communitystudios.xyz/api/v1/player/session \
-H "Authorization: Bearer $CEEJAY_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "ceejay_rt_5512.1.4f8a2c1e9b7d3e41f8a6b0c1d25f0c2a9b7d3e41f8a6b0c1d24f8a2c1e9b7d3e" }'{
"success": true,
"data": {
"playerSession": { "token": "cjps1.eyJ2IjoxfQ.sN7aQ3xKp0wT9bHf4Zc2LmEu6RdYv8jI1oGtXnWy5A", "expiresAt": "2026-10-08T18:34:11.000Z" },
"refreshToken": "ceejay_rt_5512.2.e3d7b9e1c2a8f42d1c0b6a8f14e3d7b9a2c0f52d1c0b6a8f14e3d7b9e1c2a8f4",
"refreshExpiresAt": "2027-01-06T18:19:11.000Z",
"membership": "member",
"joinUrl": null,
"sharing": { "agreed": ["progress", "activity"], "needed": [] },
"manageUrl": "https://ceejay.communitystudios.xyz/connect/mine"
}
}Three rules keep a device signed in:
- Store the new refresh token before you use the new player session. It replaces the one you sent.
- Renew one call at a time. Two renewals at once can sign the device out.
- When a response was lost, send the token you still have. CEEJAY accepts the previous token until the device uses a session issued from the new one. A token that was replaced and then used again returns
refresh_reused, and the device is signed out.
A refresh token is valid for 90 days from its last use. At most 10 renewals a minute are allowed for one device.
If the response has playerSession: null, the Discord account is not in the server. membership is not_member or left, and joinUrl is where to send the player. Renew no more than once a minute. A renewal returns a player session once the player has joined.
connection_ended (401, with reason: disconnected, moved, signed_out or ended), refresh_reused and invalid_refresh_token (both 401) mean this device has no connection any more. Discard the refresh token, the player session and everything known about the member, and return to not connected.
To read the card again, send the player session:
curl https://ceejay.communitystudios.xyz/api/v1/player/me \
-H "Authorization: Bearer $CEEJAY_PLAYER_SESSION"The response contains connection, member, sharing, reward and manageUrl, as in the approved result. It is read from CEEJAY's stored data, never from Discord, and checked against the connection on every request. At most 60 reads a minute are allowed for one player session.
invalid_player_session(401): the session ended early. Renew once and read again.connection_ended(401): as above. Discard everything.
7. Disconnect
A player can always leave from the game, whether or not they are in the server.
curl -X POST https://ceejay.communitystudios.xyz/api/v1/player/disconnect \
-H "Authorization: Bearer $CEEJAY_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{ "refreshToken": "ceejay_rt_5512.2.e3d7b9e1c2a8f42d1c0b6a8f14e3d7b9a2c0f52d1c0b6a8f14e3d7b9e1c2a8f4" }'The response is { "success": true, "data": { "disconnected": true } }. It ends the connection on every device. It is idempotent, so it is safe to retry, and a token whose connection has ended returns connection_ended or 200: either way, discard the tokens. When CEEJAY cannot be reached, keep the tokens and ask the player to retry.
Start-up and the four states
When the game starts, look for these in order:
- An open session in the store. The state is waiting. Go back to step 3. A session whose
expiresAtis more than 10 minutes in the past (the time its result can still be read) is over: discard it without polling. - A refresh token. Renew it (step 6). The state follows the response.
- A proof token from the game's sign-in service (Firebase Authentication, Unity Player Authentication or a Steam ticket), if the game has one. Send Get connection status:
curl -X POST https://ceejay.communitystudios.xyz/api/v1/connect/status \
-H "Authorization: Bearer $CEEJAY_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{ "playerId": "p_19", "proof": { "type": "firebase", "token": "eyJhbGciOiJSUzI1NiJ9…" } }'The response is { "success": true, "data": { "connected": true } } and nothing else. The proof must be a token from the sign-in service, issued in the last 10 minutes: a connect pass is not accepted here, and nobody with only the shipped key can test which players are connected. At most 60 requests a minute are allowed from one IP address. When connected is true, start with the same proof (step 1): the session returns approved immediately with alreadyConnected: true, and the tokens are one read away. When the new session does not return approved, send a refusal (step 4 with accept: false) so no session is left open without the player's knowledge.
On Steam, use each ticket once: get a new ticket for any request that follows.
The button has four states:
| State | When | The game shows |
|---|---|---|
| Not connected | No refresh token and no open session, or a renewal reported that the connection ended | Connect |
| Waiting | A session is stored | The code, and the confirmation question when the game's confirmation is pending |
| Connected | A refresh token is stored and membership is member and sharing.needed is empty | The member card |
| Needs attention | membership is not_member, left or pending, or sharing.needed is not empty | "Join the server" or "Rejoin the server" with joinUrl, a notice that Discord's membership screening is pending, or a link to manageUrl |
manageUrl is a page where the member manages their connections with nothing but their Discord account: what is shared, which devices are signed in, and Disconnect.
Retries
After a 429, a 5xx or no response, wait and retry, up to two more times, for every request but Start connect. Wait for retryAfter seconds when the response includes it, and give up if the wait is longer than 60 seconds. Otherwise use exponential backoff with jitter: start at half a second and double each time, with a small random offset so many players do not retry at the same moment.
| Request | Safe to retry | Why |
|---|---|---|
| Start connect | No | A second start is a second session, and a code is used up by the first |
| Get the result | Yes | Reading changes nothing |
| Confirm connect | Yes | A confirmation that was recorded stands, and a refusal for a session that ended changes nothing |
| Get connection status | Yes | It only reads |
| Renew a player session | Yes | A refresh token whose response was lost is accepted once more |
| Get the player's profile | Yes | It only reads |
| Disconnect | Yes | A connection that has ended is ended |
Never put a session secret, a refresh token, a proof token or a player session in a log line or an error message.
The codes to act on
Errors lists every code. These are the ones the steps above depend on.
code | Status | What the game does |
|---|---|---|
not_available | 404 | This instance does not offer Connect yet. Hide the button |
invalid_public_key, key_retired, key_suspended, wrong_key_kind | 401 | The key is wrong or was withdrawn. Ship the new key |
origin_not_allowed | 403 | A web build is served from an origin that is not on the game's list |
audience_not_allowed, under_age | 403 | The game cannot connect this player |
age_answer_required | 400 | Send underAge with every start |
proof_rejected | 401 | The proof was not accepted. reason says why |
code_not_found, session_locked, too_many_attempts | 404, 409, 429 | A code the player typed does not match a pending request, is locked, or was attempted too many times |
invalid_session_secret, result_gone | 401, 410 | Discard the session. For result_gone, start again once |
nothing_to_confirm, session_changed, player_blocked, player_taken | 409 | Read the result again |
connection_ended, refresh_reused, invalid_refresh_token | 401 | Discard the tokens. Return to not connected |
invalid_player_session | 401 | Renew once and retry |
rate_limited | 429 | Wait retryAfter seconds |
proof_service_unavailable | 503 | The proof could not be checked. Retry |
Limits
The seven requests are counted per IP address: 600 a minute across all of them. Rate limits has the numbers and the ones a request has of its own.
Once the player is connected, the player session is also the bearer token for the eleven requests of the game loop, such as List the quests and Buy a shop item. They are plain HTTP too. This page does not cover them, and they share the per-IP-address count of 600 a minute.
Rules to keep
- Store the open session as well as the refresh token.
- Show the Discord name and ask the player before you send the game's confirmation. Never confirm on the player's behalf.
- Never open the link in a web view inside the game.
- Never send a secret key from a game.
- Disclose in your app store privacy details (Apple privacy label, Google Play Data safety) that the game sends the player ID and device sign-in data to CEEJAY, and nothing else. The kits page has the wording.

