CEEJAYDocs

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 404 with the code not_available and feature: "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 is https://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 403 with the code origin_not_allowed. A native build sends no Origin header 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/json and 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 Authorization and Content-Type and nothing else, and a third header stops a web game. Your HTTP library may add its own User-Agent and Accept-Encoding: that is fine for a native game.
  • Send no cookie. No cookie is read, and Access-Control-Allow-Credentials is never sent.
  • A success is 200 with { "success": true, "data": { … } }. Read data.
  • A failure has a status outside 200 to 299 and { "error": "A message", "code": "fixed_code", "requestId": "req_…" }. Some codes add reason, field, feature or retryAfter. Act on code: the error message 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-Remaining and X-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.

ValueStarts withStore itIt travels in
Public keyceejay_pk_In the gameThe Authorization header
Session secretceejay_cs_In the secure store, until the session endsThe body
Refresh tokenceejay_rt_In the secure store, until the connection endsThe body
Player sessioncjps1.In memory only. It lasts 15 minutesThe 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:

What a kit writes under the key ceejay.connect
{
  "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

RequestCalled withReturns
Start connect, POST /connect/startPublic keyThe link, the code, the QR code URL and the session secret
Get the result, POST /connect/resultPublic keyThe session status
Confirm connect, POST /connect/confirmPublic keyThe game's confirmation or refusal, and what the result would return next
Renew a player session, POST /player/sessionPublic keyA player session and the next refresh token
Get the player's profile, GET /player/mePlayer sessionThe connection and the member card
Disconnect, POST /player/disconnectPublic keydisconnected: true
Get connection status, POST /connect/statusPublic keyconnected, 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.

Start connect
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" }'
FieldWhat it is
playerIdRequired. 1 to 128 characters, starting with a letter or a digit, then letters, digits and . _ : @ | + = -. Always a string, also when your IDs are numbers
playerNameThe 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
returnToThe key of one of the return URLs saved for the game. A URL is never accepted from a request
localeThe language of the connect page, such as en or pt-BR
underAgeA game set up for a mixed audience must send it. true returns 403 and stores nothing
codeA code the player made in Discord and typed into the game. See A code from Discord
200 OK
{
  "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.

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.

DeviceDo this
Browser gameOpen 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 computerOpen url in the system browser
iPhoneOpen 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
AndroidOpen url in a browser tab (a Custom Tab)
Console, televisionOpen 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.

Get the result
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:

statusWhat it meansThe game
pending, needs_confirmationWaiting for the member. expiresAt is when the session expiresPoll again after the interval
needs_game_confirmationThe member authorised the connection. member contains displayName and avatarUrl, and alreadyConnected indicates whether this Discord account is connected to this player alreadyShow the name and ask the player: step 4
approvedBoth sides confirmedStore the tokens: step 5
declined, cancelled, expired, locked, refused, or any other valueThe session ended without a connection. reason says why, or is nullDiscard the session. Return to not connected

How to poll:

  • One read per session every 2 seconds is allowed; polling sooner returns 429 with the code rate_limited and retryAfter. Wait that many seconds.
  • A 429, a 5xx or 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 after expiresAt.
  • 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 the again fields, and a proof issued now if the game has one. A connection that exists is found by the new start, which returns approved immediately. 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.

Confirm connect
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: false ends 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_blocked and player_taken (all 409) mean the session changed while the player read it, or cannot be finished. Read the result again: its status shows 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.

200 OK, approved (shortened)
{
  "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:

  1. Write refreshToken and refreshExpiresAt to the secure store, and remove session from it.
  2. Keep playerSession.token in memory only, with its expiresAt.
  3. Show the member card. It is these fields and no others.
  4. Keep member.membership, member.joinUrl, sharing.needed and manageUrl for 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.

Renew a player session
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" }'
200 OK
{
  "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:

  1. Store the new refresh token before you use the new player session. It replaces the one you sent.
  2. Renew one call at a time. Two renewals at once can sign the device out.
  3. 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:

Get the player's profile
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.

Disconnect
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:

  1. An open session in the store. The state is waiting. Go back to step 3. A session whose expiresAt is more than 10 minutes in the past (the time its result can still be read) is over: discard it without polling.
  2. A refresh token. Renew it (step 6). The state follows the response.
  3. 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:
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:

StateWhenThe game shows
Not connectedNo refresh token and no open session, or a renewal reported that the connection endedConnect
WaitingA session is storedThe code, and the confirmation question when the game's confirmation is pending
ConnectedA refresh token is stored and membership is member and sharing.needed is emptyThe member card
Needs attentionmembership 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.

RequestSafe to retryWhy
Start connectNoA second start is a second session, and a code is used up by the first
Get the resultYesReading changes nothing
Confirm connectYesA confirmation that was recorded stands, and a refusal for a session that ended changes nothing
Get connection statusYesIt only reads
Renew a player sessionYesA refresh token whose response was lost is accepted once more
Get the player's profileYesIt only reads
DisconnectYesA 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.

codeStatusWhat the game does
not_available404This instance does not offer Connect yet. Hide the button
invalid_public_key, key_retired, key_suspended, wrong_key_kind401The key is wrong or was withdrawn. Ship the new key
origin_not_allowed403A web build is served from an origin that is not on the game's list
audience_not_allowed, under_age403The game cannot connect this player
age_answer_required400Send underAge with every start
proof_rejected401The proof was not accepted. reason says why
code_not_found, session_locked, too_many_attempts404, 409, 429A code the player typed does not match a pending request, is locked, or was attempted too many times
invalid_session_secret, result_gone401, 410Discard the session. For result_gone, start again once
nothing_to_confirm, session_changed, player_blocked, player_taken409Read the result again
connection_ended, refresh_reused, invalid_refresh_token401Discard the tokens. Return to not connected
invalid_player_session401Renew once and retry
rate_limited429Wait retryAfter seconds
proof_service_unavailable503The 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.

On this page

Community StudiosPrivacyTerms