# Connect a game server Support works without a game server: the SDK starts every player anonymously, bound to the app install. Connecting your game server adds verified player identity, an attribute catalog for badges, gifts, conversation events and push requests. Settings live per project under **Settings → Server connection**. Every token exchanged between your game server and Cluefolk is an ES256 (P-256) JWT. Your private keys stay on your game server; Cluefolk stores only their public halves. Cluefolk's own private command keys never leave Cluefolk. ## Tokens and keys | Token | Signed by | `typ` | `iss` | `aud` | Max lifetime | | --- | --- | --- | --- | --- | --- | | Player identity | Game identity key | `cluefolk-identity+jwt` | `game:` | `cluefolk-identity` | 5 minutes | | Profile push / snapshot | Game profile key | `cluefolk-profile+jwt` | `game:` | `cluefolk-profile` | 60 seconds | | Connection receipt | Game profile key | `cluefolk-integration+jwt` | `game:` | `cluefolk-integration` | 60 seconds | | Command to the game | Cluefolk command key | `cluefolk-command+jwt` | `cluefolk:` | `game:` | 60 seconds | `projectId` is the public project ID (for example `space-farm-1a2b3c4d`), not the project name. Every token carries `kid` in its header, and these claims: `iss`, `sub` (1–256 characters), a single-value `aud`, `iat`, `nbf`, `exp`, `jti` and `projectId`. Clock skew of 30 seconds is tolerated. A `kid` is the RFC 7638 SHA-256 thumbprint of the public key (base64url). Register your public keys as a PEM `PUBLIC KEY` or a JWK (`kty: EC`, `crv: P-256`). Private key material is refused. Each purpose (identity, profile) holds up to five keys. To rotate one, add the new key, switch your server to it, then remove the old key. ## Setup 1. Create a project. A project is an isolated space with its own players, conversations, keys, catalog and settings. To keep dev and QA builds apart from real players, create a separate project for them: it has its own project ID, keys and `cluefolk-server.json`, so dev builds and the game server they talk to use that project's values. Players can write to support immediately. 2. **Players and sign-in**: add an identity public key under **Identity signing keys**. Under **Who can contact support**, keep *Everyone* (anonymous players allowed; signed-in players are verified) or choose *Only players signed in through the game*. The second option requires an identity key; anonymous sessions are then refused with `403 IDENTITY_REQUIRED`. 3. **Game server**: enter the HTTPS origin of your game server in **Game server URL**, for example `https://api.example.game`, without a path, query string or credentials. Add a key under **Profile signing keys**. 4. Click **Download server config** and add `cluefolk-server.json` to your game server's configuration. It contains identifiers and Cluefolk's public keys only, no secrets. 5. Implement `POST /cluefolk/connect` (below). The other endpoints use the same origin: `/cluefolk/profile`, `/cluefolk/gifts`, `/cluefolk/events` and `/cluefolk/push`. 6. Click **Check connection**. Cluefolk sends a signed request, verifies the signed receipt and imports the attribute catalog. Later, **Refresh catalog** repeats the check after a backend update. There is no background polling. Gifts, profile sync, game events and push run only while the project is connected: a game server URL, a successful check and at least one profile key. Changing the URL clears the connection until the next check. A failed check never blocks the support desk. ## `cluefolk-server.json` ```json { "protocolVersion": 2, "cluefolkUrl": "https://cluefolk.com", "projectId": "", "identityIssuer": "game:", "identityAudience": "cluefolk-identity", "profileAudience": "cluefolk-profile", "integrationAudience": "cluefolk-integration", "commandIssuer": "cluefolk:", "commandAudience": "game:", "jwksUrl": "https://cluefolk.com/v1/jwks/", "commandKeys": [{"kty": "EC", "crv": "P-256", "x": "…", "y": "…", "kid": "…", "alg": "ES256", "use": "sig"}] } ``` Downloading the file is safe to repeat; it changes only after a command key rotation. ## Verifying commands from Cluefolk Cluefolk calls your game server with `POST`, an empty body and `Authorization: Bearer `. All data is in the signed claims. Verify: - header `alg: ES256`, `typ: cluefolk-command+jwt` and a `kid` from Cluefolk's published keys; - `iss` = `commandIssuer`, `aud` = [`commandAudience`] and `projectId` equal to your configuration; - `iat`, `nbf`, `exp`, with at most 60 seconds between `iat` and `exp`; - the `action` expected by that endpoint. Public keys are published at `GET {cluefolkUrl}/v1/jwks/{projectId}` as `{"keys":[…]}` with `Cache-Control: public, max-age=300`. Start from `commandKeys` in the config file and refresh from `jwksUrl`. **Rotate command key** signs new requests with a new key at once, while the previous key stays published, so a server that refreshes the JWKS keeps working. At most the current and the previous key are published. | Endpoint | `action` | `sub` | Extra claims | | --- | --- | --- | --- | | `/cluefolk/connect` | `integration.connect` | project ID | `version: 2`, `nonce` | | `/cluefolk/profile` | `profile.pull` | game player ID | — | | `/cluefolk/gifts` | `gift.grant` | game player ID | `grantId`, `conversationId`, `giftId`, `version`, `command`, `attributes` ([gifts.md](gifts.md)) | | `/cluefolk/events` | `event.deliver` | game player ID | `eventId`, `event` | | `/cluefolk/push` | `push.notify` | game player ID | `eventId`, `push` ([push-notifications.md](push-notifications.md)) | Cluefolk follows no redirects, applies a 10-second timeout and reads a bounded response. Production destinations must be public: loopback, private, link-local and metadata addresses are refused when connecting, so DNS changes cannot bypass the check. HTTP to loopback is allowed only in explicit local development. ## Player identity After your game authenticates a player, your backend issues a short-lived identity token and the game passes it to the SDK (an identity provider callback or `loginWithIdentity`). Never ship a signing key in the game client. ```json { "iss": "game:", "sub": "", "aud": "cluefolk-identity", "projectId": "", "iat": 1793190000, "nbf": 1793190000, "exp": 1793190300, "jti": "" } ``` Header: `{"alg":"ES256","typ":"cluefolk-identity+jwt","kid":""}`. A token is accepted once (`409 IDENTITY_TOKEN_REPLAYED` on reuse). The SDK sends it to `POST /v1/sdk/sessions` together with the install secret. If that install was used anonymously, its conversations, attachments, drafts and ban move to the verified player; when both had an active conversation, the older one is resolved with an internal note, because a player has exactly one active conversation. Anonymous players have no game ID. They may type the game ID they believe is theirs; staff see it as unverified. Gifts, profile sync, events and push apply to verified players only. ## Connection request and catalog `POST {gameUrl}/cluefolk/connect` carries a command with `action: integration.connect`, `version: 2`, a random `nonce` (UUID) and `sub` = project ID. Return HTTP 200, `Content-Type: application/json` and exactly `{"token":""}` (at most 80 KiB, no other fields). Sign the receipt with a registered profile key, `typ: cluefolk-integration+jwt`: ```json { "iss": "game:", "sub": "", "aud": "cluefolk-integration", "projectId": "", "action": "integration.ready", "version": 2, "nonce": "", "jti": "", "iat": 1793190000, "nbf": 1793190000, "exp": 1793190060, "catalog": { "version": "1", "attributes": [ {"key":"level","displayName":"Level","type":"integer","freshnessTtlSeconds":3600}, {"key":"lifetimeSpend","displayName":"Lifetime spend","type":"decimal","isSensitive":true}, {"key":"currency","displayName":"Currency","type":"enum","enum":["USD","EUR"]}, {"key":"purchasesComplete","displayName":"Purchases complete","type":"boolean"}, {"key":"createdAt","displayName":"Game account created","type":"datetime"} ] } } ``` The nonce binds the receipt to this request. A different nonce, scope, key, purpose or version fails the check (`GAME_SERVER_AUTH_FAILED`); an unreachable server or malformed body gives `GAME_SERVER_UNREACHABLE` or `GAME_SERVER_INVALID_RESPONSE`. The catalog has a nonempty `version` (up to 64 bytes) and an `attributes` array; an empty array is valid. At most 256 unique fields, and the normalized definitions must fit 32 KiB. A key starts with a letter and contains at most 100 letters, digits, dots, underscores or hyphens; `sys.`, `cf.` and reserved identity names are not allowed. Types: `string`, `integer`, `decimal`, `boolean`, `datetime`, `enum`. Optional metadata: `freshnessTtlSeconds` (0 = no expiry, up to one year), `isSensitive`, `enum`, `min`, `max`, `maxLength`. A field is server-only unless the catalog sets `sourcePolicy`/`allowedSources` to let the SDK report it; SDK values always count as unverified. Sensitive values are shown to owners and admins only. The import is transactional and keeps field IDs. Removed fields are retired: they stop driving badges, while stored observations remain for history and privacy export. Reintroducing a key restores its ID. Changing the type of an existing key fails the import (`409 ATTRIBUTE_SCHEMA_TYPE_CHANGED`); publish a new key instead. A changed catalog queues badge recomputation. ## Player attributes The catalog defines fields; values come separately, keyed by the game player ID: - **Pull.** Cluefolk calls `POST {gameUrl}/cluefolk/profile` with `action: profile.pull`. Reply `200 {"token":"…"}` signed with a profile key: `typ: cluefolk-profile+jwt`, `aud: cluefolk-profile`, the same `sub`, `action: profile.snapshot`. A snapshot is complete: server fields it omits are removed. Pulls are queued when a verified player signs in and by **Sync from game** in the player profile; failures retry with backoff. - **Push.** `POST {cluefolkUrl}/v1/game/attributes` with `{"projectId":"…","token":"…"}`; the token uses the same type, key and audience with `action: profile.push`. `attributes` is a partial patch and `removed` lists keys to clear. The response is `200 {"ok":true,"applied":…}`. Pushes are limited to 3000 per minute per sending address (`429 RATE_LIMITED`, `Retry-After: 60`). Both carry `observedAt` (RFC 3339), a positive `revision` and an idempotent `batchId` (8–128 characters); together at most 256 fields and 16 KiB. Order is `(observedAt, revision)`, so an older observation never overwrites a newer one. Reusing a `batchId` with different content returns `409`. Decimals are JSON strings (`"99.95"`), integers are JSON integers, datetimes RFC 3339. Unknown keys and wrong types are rejected. After a privacy erase, pushes and snapshots for that game ID are refused with `410 PLAYER_ERASED` for 35 days, until the player signs in again. ## Events `POST {gameUrl}/cluefolk/events` delivers public conversation changes of verified players: `action: event.deliver`, a stable `eventId` (UUID, also sent as `Idempotency-Key`) and `event: {type, id, createdAt, data}`, where `data` holds `conversationId`, `status` and `priority` — never message text or private notes. Store the event atomically and reply `200 {"eventId":"…","externalUserId":"","status":"accepted"}`. Any other answer is retried with backoff for 24 hours with the same payload and a fresh JWT. ## Reference implementation `server/cmd/reference-game` is a local development backend that implements every endpoint above. It reads the downloaded config (`-config`, default `.local/cluefolk-server.json`), keeps its private keys in a local 0600 PEM file (`-keys`, created on first run) and prints the public keys to register with `-print-public-keys` (`make game-keys`). The source is in `server/internal/referencegame`. It demonstrates the protocol; it is not a production game server.