Authentication
Four credentials open the API. Each one opens one group of routes and nothing else. The reference says, under every route, which one it takes.
| Credential | Looks like | Sent as | Valid on |
|---|---|---|---|
| Player token | fp1.<payload>.<hmac> | Authorization: Bearer | /v1/sdk/*, /v1/account/*, ratings, comments, feedback |
| Game token | fg1.<payload>.<hmac> | Authorization: Bearer | Five /v1/sdk/* routes, for its own game |
| Console session | cookie filudi_console | the cookie, with credentials: 'include' | /v1/console/*, /v1/admin/* |
| Server key | fsk_<site slug>_<32 base62> | Authorization: Bearer | /v1/server/* |
Player token
Section titled “Player token”A player is a guest from the first page view. POST /v1/sdk/players creates one for a site and answers
{ player, token }; the magic link, its 6-digit code and the password sign-in answer the same shape for an
account. The token has no expiry. It opens the player’s saves, scores, events, profile and the whole
/v1/account/* group, deletion included.
curl -X POST 'https://api.filudi.com/v1/sdk/players' \ -H 'content-type: application/json' \ -d '{ "site": "filudi", "locale": "en" }'
curl 'https://api.filudi.com/v1/sdk/players/me' \ -H "authorization: Bearer $FILUDI_PLAYER_TOKEN"const created = await fetch('https://api.filudi.com/v1/sdk/players', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: 'filudi', locale: 'en' }),});const { token } = await created.json();localStorage.setItem('filudi:player:filudi', token);
const me = await fetch('https://api.filudi.com/v1/sdk/players/me', { headers: { authorization: `Bearer ${token}` },});import requests
# From a server, every visitor shares your IP and the 21st guest of the hour is refused.# Create guests in the browser, or use a server key and POST /v1/server/players.created = requests.post( 'https://api.filudi.com/v1/sdk/players', json={'site': 'filudi', 'locale': 'en'},)token = created.json()['token']me = requests.get( 'https://api.filudi.com/v1/sdk/players/me', headers={'authorization': f'Bearer {token}'},)Rules for the token:
- It lives in the browser, in
localStorage.PlayerClientof@filudi/hub-coreuses the keyfiludi:player:<site>. Never a cookie, never a URL, never a log. - It never enters a game frame. A game gets a game token instead.
- A 401 means the API refused it: forget it and create a new guest. When the owner rotates the API’s token secret, every player is signed out this way.
- A player belongs to one site. The same email on two sites makes two players.
Game token
Section titled “Game token”A game is untrusted. The page that frames it exchanges the player token with POST /v1/sdk/game-tokens { game }
and passes the result in the filudi:init message of the handshake. The token is fg1, valid 24 hours, and
opens these routes for the game it names:
| Route | With a game token |
|---|---|
GET /v1/sdk/players/me | Allowed; emailMasked is null and hasPassword false |
GET, PUT, DELETE /v1/sdk/saves/{gameId} | Its own game |
POST /v1/sdk/sessions | Its own game |
/v1/sdk/leaderboards/{gameId}/* | Its own game |
POST /v1/sdk/events | An item that names another game refuses the batch with 403 |
Every other route answers 401 to a game token. The exchange is limited to 240 per player per hour. A hub sends
a fresh token to the frame every 12 hours as filudi:token; the SDK swaps it without a reload. A portal backend
gets the same token for one of its players with POST /v1/server/game-tokens.
curl -X POST 'https://api.filudi.com/v1/sdk/game-tokens' \ -H "authorization: Bearer $FILUDI_PLAYER_TOKEN" \ -H 'content-type: application/json' \ -d '{ "game": "gloop" }'const response = await fetch('https://api.filudi.com/v1/sdk/game-tokens', { method: 'POST', headers: { authorization: `Bearer ${playerToken}`, 'content-type': 'application/json' }, body: JSON.stringify({ game: 'gloop' }),});const { token, expiresAt } = await response.json();// Post `token` to the frame in filudi:init; keep playerToken on your origin.import requests
# A backend uses its server key instead: POST /v1/server/game-tokens { playerId, game }.response = requests.post( 'https://api.filudi.com/v1/server/game-tokens', headers={'authorization': f'Bearer {site_key}'}, json={'playerId': player_id, 'game': 'gloop'},)game_token = response.json()['token']Console session
Section titled “Console session”The developer console and the back office sign in with POST /v1/console/sign-in (email, password), then
POST /v1/console/sign-in/totp when the account has an authenticator. The answer sets the cookie
filudi_console: HttpOnly, Secure, SameSite Strict, host-only on the API, 14 days. The browser sends it on its
own; a script adds credentials: 'include' to each fetch.
Every non-GET request under /v1/console/* and /v1/admin/* must carry an Origin header listed in the
API’s console origins (developers.filudi.com and admin.filudi.com). A browser sets it. A request without one,
or with another, answers 403 forbidden.
Staff without an authenticator gets 403 on every /v1/admin/* route except GET /v1/admin/me. A staff member
who opens an impersonation gets a second cookie, filudi_impersonation, valid one hour; a request with the
header X-Filudi-Impersonation: 1 is read from that cookie instead.
curl -X POST 'https://api.filudi.com/v1/console/sign-in' \ -H 'origin: https://developers.filudi.com' \ -H 'content-type: application/json' \ -c cookies.txt \ -d '{ "email": "you@example.com", "password": "string" }'
curl 'https://api.filudi.com/v1/console/me' -b cookies.txtconst signIn = await fetch('https://api.filudi.com/v1/console/sign-in', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }),});const { totpRequired, me } = await signIn.json();// totpRequired: finish with POST /v1/console/sign-in/totp { code }
const account = await fetch('https://api.filudi.com/v1/console/me', { credentials: 'include' });import requests
# The console API is made for a browser. A script keeps the cookie in a session.session = requests.Session()session.headers['origin'] = 'https://developers.filudi.com'session.post( 'https://api.filudi.com/v1/console/sign-in', json={'email': 'you@example.com', 'password': password},).raise_for_status()me = session.get('https://api.filudi.com/v1/console/me').json()Server key
Section titled “Server key”A portal backend calls /v1/server/* with a key created in the console, under the site, Keys. The key is
fsk_<site slug>_<32 base62 characters>, shown once; the API stores a hash and the console shows the first 12
characters. Each key carries scopes (catalog, players, tokens, leaderboards, stats) and each route says
which one it needs. 600 requests a minute per key; over that, 429 with Retry-After. Only an approved site has
keys, and a revoked key answers 403.
A server key never reaches a browser: /v1/server/* sends no CORS headers, so a page cannot call it. The key
sits in your backend’s environment.
curl 'https://api.filudi.com/v1/server/catalog?locale=en' \ -H "authorization: Bearer $FILUDI_SITE_KEY"// Node, in your backend. siteKey comes from the environment.const response = await fetch('https://api.filudi.com/v1/server/catalog?locale=en', { headers: { authorization: `Bearer ${siteKey}` },});const catalog = await response.json();import osimport requests
response = requests.get( 'https://api.filudi.com/v1/server/catalog', params={'locale': 'en'}, headers={'authorization': f"Bearer {os.environ['FILUDI_SITE_KEY']}"},)catalog = response.json()Public routes
Section titled “Public routes”GET /v1/catalog/{site}, GET /v1/catalog/{site}/games/{slug}, the reviews of a game, the bundle and media
files, POST /v1/sdk/players, POST /v1/feedback, POST /v1/console/invitation-requests and the health routes
take no credential. A few routes accept a player token and answer without one too; the reference marks them
“Credential, optional”.