Authentication
Every request is authenticated with a scoped secret key sent as a bearer token.
Authorization: Bearer ceejay_sk_4f8a2c...Write Bearer, with a capital B, then a space, then the key. The word is case-sensitive:
bearer and BEARER are refused. So is a key sent in the query string, such as ?api_key=…,
which CEEJAY does not read. Each of these returns 401, with a code that says what was wrong: see Authentication errors.
For example, to read a member's XP with your key. The base URL is your
dashboard's URL followed by /api/v1.
export CEEJAY_BASE_URL="https://ceejay.communitystudios.xyz/api/v1"
export CEEJAY_API_KEY="ceejay_sk_..."
curl "$CEEJAY_BASE_URL/xp/balance?discordId=409876543210987654" \
-H "Authorization: Bearer $CEEJAY_API_KEY"On a dedicated instance, set CEEJAY_BASE_URL to your dashboard's URL followed by
/api/v1.
To see which community a key belongs to and what it may do, call Community details. It needs no scope: any valid key can call it.
Keep the key on your server
Secret keys are for servers only. Never put one in browser code, a mobile app or a game client:
anyone who opens it can read the key and use everything its scopes allow. A browser on another
website cannot call these requests anyway: its preflight request carries no key and is refused with 401.
A game has a key of its own kind for the few requests it makes itself: see Two kinds of key.
Create a key
Open Settings → Developers → API Keys and press Generate Key.
Give the key a Name that says where it will run, such as Roblox Game Server.
Under Scopes, Full Access is ticked when the dialog opens. Tick only what the key needs instead: ticking a scope clears Full Access. A key needs at least one scope: with none ticked, Generate is switched off. Press Generate.
The dialog closes. Copy the key from the banner above the list. It is shown only once.
The list on that tab shows each active key with its name, the start of the key, its scopes and when it was last used (or Never used).
Key format
Keys are prefixed ceejay_sk_ followed by 64 hexadecimal characters. The prefix makes
keys stand out in logs and gives a secret scanner a pattern to look for.
We store only a SHA-256 hash of the key. The dashboard shows the first characters
(ceejay_sk_4f8a2c1...) so you can tell keys apart.
Two kinds of key
Everything on this page is about the secret key, which starts with ceejay_sk_ and stays on your server.
A game that uses Connect also has a public key, which starts with ceejay_pk_. It ships inside the game and is not a secret.
| Secret key | Public key | |
|---|---|---|
| Starts with | ceejay_sk_ | ceejay_pk_ |
| Where it runs | On your server | Inside the game |
| Belongs to | One Discord server | One game of one Discord server |
| Allowed requests | Every request its scopes allow | Only the requests under "Connect: from the game" in the reference |
| Scopes | Yes | No: the allowed requests are a fixed list |
| Rate limit | Per key | Per IP address |
The two are never mixed. A request that takes a secret key returns 401 with the code invalid_api_key for a public key, and a request a game makes returns 401 with the code wrong_key_kind for a secret key.
A public key is made when a game is set up for your community, which we do for you for now. Send it the same way, as Authorization: Bearer ceejay_pk_…. What a public key can call, and what else a game stores, is on the Connect page.
Server binding
A key belongs to exactly one Discord server. You never pass a server ID: every request is automatically scoped to the key's server, and a key cannot read or write another server's data. A server can have as many keys as it needs.
A key also belongs to one CEEJAY instance, the one it was created on. It does not work on another instance.
An organisation's key
A studio that runs several communities in one organisation can use one key for all of them. The organisation's owner or an admin creates the key on the API keys tab of the organisation's settings and chooses which communities it covers: selected communities, or all communities, including any added later. Each covered community sees the key on its own API Keys page and can switch the organisation's keys off there.
Send the community each request is for in the CEEJAY-Community header, as its Discord server ID.
To copy a server ID, turn on Developer Mode in Discord (User Settings → Advanced), then
right-click the server and choose Copy Server ID.
curl "$CEEJAY_BASE_URL/xp/balance?discordId=409876543210987654" \
-H "Authorization: Bearer $CEEJAY_API_KEY" \
-H "CEEJAY-Community: 123456789012345678"Once the community is named, the request works as that community's own key would: it is limited to that community's data, its rate limit is counted for each community separately, and it is in that community's request log. A community's own key ignores the header.
| Status | code | Cause |
|---|---|---|
400 | community_required | An organisation's key was sent without the CEEJAY-Community header, or with a value that is not a server ID |
403 | community_not_covered | The community is not in the organisation, is not covered by the key, or has turned off the organisation's keys. The message is the same in all three cases |
Both count toward the key's rate limit. Revoking the key on the organisation's page revokes it in every community.
Scopes
Each key has a list of scopes. A request that requires a scope the key
does not have returns 403 with the code insufficient_scope, along with the required and granted scopes. The request
still counts toward the key's rate limit.
One request needs no scope at all: Community details accepts any valid key, and lists the scopes the key has.
| Scope | Checkbox in the dialog | Allows |
|---|---|---|
members:read | Members Read | Read member profiles, XP, balances, linked accounts |
members:link | Members Link | Link an external platform account to a Discord user |
xp:read | XP Read | Read a member's XP and level |
xp:grant | XP Grant | Award XP |
xp:deduct | XP Deduct | Remove XP |
xp:program:grant | Program XP Grant | Award XP inside a community program |
xp:program:deduct | Program XP Deduct | Remove XP inside a community program |
programs:read | Read programs | List community programs |
economy:read | Economy Read | Read currency balances |
economy:grant | Economy Grant | Credit currency |
economy:deduct | Economy Deduct | Debit currency |
quests:read | Quests Read | List quests |
quests:complete | Quests Complete | Mark a quest complete and grant its rewards, or report that a member finished a quest task |
roles:manage | Roles Manage | List your server's roles, and grant and revoke them |
messages:send | Messages Send | List your channels, and send messages and embeds to them |
events:emit | Events Emit | Emit custom events to your webhook subscribers |
shop:fulfil | Confirm shop deliveries | List the shop deliveries awaiting fulfilment by your server, and mark one as delivered or failed |
players:read | Players Read | Read the players of a game and its field map: Get a player, List players and Get the field map. It also decides whether Get a member lists the game accounts a member connected through Connect |
players:write | Players Write | Send player data: Set player fields, Log player events and Propose to the field map |
players:connect | Act for a player | Make a connect pass, start, read and confirm connect sessions, disconnect a player, and flag a player as under age in Set player fields and Log player events. Never part of * |
players:delete | Delete a player's data | Delete a player: delete the data CEEJAY stores for a player. Never part of * |
* | Full Access | All of the above except players:connect and players:delete. Avoid in production. |
{
"error": "Insufficient scopes",
"code": "insufficient_scope",
"requestId": "req_5f0c2a9b7d3e41f8a6b0c1d2",
"required": ["economy:grant"],
"granted": ["xp:grant", "xp:read"]
}Recommended
Issue one key per integration with the narrowest scope set that works. A game server
that only awards XP should have xp:grant and nothing else, so a leaked key cannot
post messages or move currency.
Two scopes Full Access never gives
players:connect and players:delete are never part of *. A key has them only when they are ticked by name, as Act for a player and Delete a player's data. A Full Access key that calls a request which needs one of them returns 403 with the code insufficient_scope, like any other key without it.
The reason is what they allow. A server with a key that has players:connect can authenticate any player of a game: with a pass, a player who is already connected at the verified level is signed in without further verification. It can also end any connection, and flag a player as under age, which ends their connection. A server with a key that has players:delete can delete all stored data for a player. Most keys in use are Full Access keys, and none of them should gain these scopes automatically.
In the Generate API Key dialog, ticking a scope clears Full Access. So a key for Connect is made by ticking Act for a player and each other scope the key needs.
Authentication errors
A key that cannot be used returns 401. The code says why:
code | error | Cause |
|---|---|---|
missing_api_key | Missing or invalid Authorization header. Expected: Bearer ceejay_sk_xxx | No Authorization header, or it does not start with Bearer |
invalid_api_key | Invalid API key format. Expected: ceejay_sk_xxx | The key does not start with ceejay_sk_ |
invalid_api_key | Invalid API key | No such key |
api_key_revoked | API key has been revoked | The key was revoked in the dashboard |
api_key_expired | API key has expired | The key had an expiry date and it has passed |
A 401 is not counted against any key, and carries no rate limit headers.
Revoking a key
Press the delete icon at the end of the key's row in Settings → Developers → API Keys.
The delete icon asks for no confirmation, and a revoked key cannot be restored. Revocation takes
effect on the next request; there is no caching delay. A revoked key returns 401 with
the code api_key_revoked, and it leaves the list.
Expiry
Keys made in the dashboard do not expire. A key that has been given an expiry date returns
401 with the code api_key_expired once the date has passed. Community details reports a key's expiry as key.expiresAt. For long-running integrations,
rotate by creating the new key first, deploying it, then revoking the old one. The two can
run side by side.
Not the same as an agent token
Tokens for AI agents start with ceejay_mcp_. They only work on the MCP
endpoint, and an API key does not work there.

