Documentation
Everything under /v1.
Responses are JSON. Errors carry an error field in plain words, not a code you have to look up.
The machine-readable description is at/v1/openapi.json — OpenAPI 3.1, and a test fails the build if it ever describes an endpoint this service does not have, or misses one it does.
Try it
A real account, on a real instance, right now.
This runs against a sandbox app on the live service. The key below is a public key, and public keys are meant to be public: it can read boards and create throwaway accounts, and nothing that carries authority.
Not signed in.
Press a button. Every call is shown exactly as it goes out.Five rules that explain most answers
- The ledger is the truth.Leaderboards, qualifications, streaks, badges, ratings and titles are derived.
- A qualification is the entrance.No exam passed, no place on a board.
- A title never reaches higher than its trust tier.Tier 0 awards nothing, tier 1 reaches city level at most.
- Two keys per app.
pk_may live in a client,sk_never. - A block cuts contact, never results.Otherwise a leaderboard would have different truths per viewer.
Authentication
| header | who | unlocks |
|---|---|---|
| X-App-Key: pk_... | any client | accounts, catalog, entries, leaderboards, social |
| X-App-Key: sk_... | your server | disciplines, duels, collectibles, invites |
| Authorization: Bearer | a player | anything tied to a person |
| X-Admin-Key | the operator | apps, regions, seasons, moderation, retention |
A player token is platform-wide: the same token identifies the same person in every app. To move an identity to another device or domain, mint a one-time code with POST /v1/me/link-code and redeem it with POST /v1/auth/redeem.
Submitting an entry
POST /v1/entries
X-App-Key: pk_live_...
Authorization: Bearer ...
{
"discipline": "kilometres",
"value": 7.2,
"occurred_at": "2026-08-22T21:40:00+02:00",
"idem_key": "run-2026-08-22-a"
}Habit apps, read this: the day slice comes from occurred_at. Send the timestamp with its local offset and 23:40 local falls on the right day instead of the UTC day after. Without it, server time in UTC applies.
{
"entry_id": "ent_...",
"value": 7.2,
"aggregate": 107.2,
"aggregation": "sum",
"qualified": true,
"qualified_now": false,
"rank": { "region": { "rank": 2, "of": 9 },
"global": { "rank": 41, "of": 260 } },
"streak_days": null,
"settled_challenges": [],
"badges_earned": []
}Returns 202 with status: "review" above the plausibility limit, 200 with duplicate: truefor a repeated idem_key, 413 whenmeta exceeds 4 KB.
Client X-App-Key: pk_… + Bearer
Everything that may live in a browser or a game build.
| POST | /v1/auth/anonymous | account in one call (invite_code when the app is closed) |
| POST | /v1/me/link-code | one-time code to move the identity |
| POST | /v1/auth/redeem | redeem it on another device or app |
| GET | /v1/me | profile, qualifications, badges, titles |
| GET | /v1/me/export | complete data export |
| DELETE | /v1/me | irreversible deletion |
| GET | /v1/regions/resolve | ?lat= ?lon= — which district a position is in |
| PATCH | /v1/me/region | home district, locked for the season |
| PATCH | /v1/me/profile | display name, avatar, locale, featured title or badge |
| PATCH | /v1/me/handle | change handle, 30-day lock afterwards |
| GET | /v1/catalog | disciplines, regions, season, collections |
| POST | /v1/entries | submit an entry |
| GET | /v1/disciplines/:d/me | own value, rank, streak, exam status |
| GET | /v1/leaderboards/:d | ?region= ?scope=friends ?limit= |
| GET | /v1/daily/:d | the day seed, identical worldwide |
| POST | /v1/challenges | create an async challenge |
| POST | /v1/challenges/:id/accept | accept one |
| GET | /v1/challenges | your challenges |
| GET | /v1/ratings/:d | rating list |
| GET | /v1/collections/:slug | a collection with your holdings |
| POST | /v1/me/follows/:handle | follow a rival |
| POST | /v1/me/blocks/:handle | block a person |
| POST | /v1/reports | report a person |
| POST | /v1/waitlist/:region | wait for a closed region |
| GET | /v1/events | event stream (?since=) |
Developer X-App-Key: sk_…
Everything that carries authority. Belongs on a server, never in a client.
| POST | /v1/disciplines | aggregation, trust tier, exam bar, title reach |
| POST | /v1/badges | a badge scoped to your app |
| POST | /v1/collections | create a collection |
| POST | /v1/collections/:slug/items | define collectibles |
| POST | /v1/collections/:slug/grant | grant one to a player |
| POST | /v1/matches | duel result with placements, updates Glicko-2 |
| POST | /v1/invites | invite codes, shown exactly once |
Developer console session cookie
Sign-in authenticates the console, never an API request. A game presents a key and nothing else, so an outage at the sign-in provider cannot take a game down.
| GET | /v1/dev/auth/github | start GitHub sign-in (?redirect= back to your console) |
| POST | /v1/dev/auth/email | send a magic link and a six-digit code |
| POST | /v1/dev/auth/email/verify | the code, when the mail opens on another device |
| GET | /v1/dev/me | account, two-factor status, app quota |
| GET | /v1/dev/sessions | list sessions; revoke-others ends the rest |
| POST | /v1/dev/apps | create your own app, keys included |
| GET | /v1/dev/apps/:slug/keys | prefix, name, last used, expiry, revocation |
| POST | /v1/dev/apps/:slug/keys | mint a key (kind, name, expires_in_days) |
| POST | /v1/dev/keys/:id/revoke | revoke one, with a reason |
| GET | /v1/dev/audit | what you did, in order |
Operator X-Admin-Key
Platform operations: apps, regions, seasons, moderation, retention.
| POST | /v1/admin/apps | create an app, both keys shown once |
| PATCH | /v1/admin/apps/:slug | access mode, invite allowance |
| GET | /v1/admin/apps/:slug | disciplines, activity, review cases |
| POST | /v1/admin/regions | unlock a region, optionally closed with a threshold |
| POST | /v1/admin/regions/:id/unlock | open it and notify the waitlist |
| GET | /v1/admin/regions/density | contenders per region (?season=) |
| POST | /v1/admin/seasons/:id/close | award titles, open the next (?dry_run=1) |
| POST | /v1/admin/entries/:id/review | decide a held entry |
| GET | /v1/admin/reports | moderation queue |
| POST | /v1/admin/reports/:id/resolve | rename, suspend or ban |
| POST | /v1/admin/maintenance | retention sweep (?dry_run=1) |
Keys, and what happens when one leaks
A key is an object with a life, not a column on your app. It is shown once, only its hash and prefix are stored, and it records when it was last seen.
Rotation without a gap
Mint, deploy, then revoke. Both keys work in between — the only kind of rotation anyone actually performs. Revoking the last live key of a kind is refused, because that is not rotation, that is an outage.
A second, recent proof for secrets
Minting a secret key needs two-factor at the provider for a GitHub account, or a sign-in from the last 15 minutes for an email account. Public keys stay available either way, so nobody is locked out of their own app.
Two ways in, no password
GitHub, or an emailed link and code. The email path answers the same for known and unknown addresses, expires in 15 minutes, works once, and kills the code after five wrong guesses.
A revoked key says so
It answers 401 with app key revoked and a timestamp, instead of a silent failure that costs somebody an hour.
Distinctive prefixes
chapi_sk_ is deliberately greppable so a secret scanner can recognise it in a public repository. Registering the pattern with GitHub is phase 1b on the roadmap.
Trust tiers
A leaderboard is worth what its weakest claim is worth. Every discipline declares who vouches for a result, and the ladder is capped by it.
| tier | vouched for by | highest title |
|---|---|---|
| 0 client | nobody | none |
| 1 replay | server re-runs the input trace | city |
| 2 server | the app's own signing server | world |
| 3 witnessed | a scheduled, recorded final | world |
Disciplines at tier 2 and above refuse entries submitted with the public key. Tier 1 verification itself is phase 6 on theroadmap — until then, tier 1 means the shape is ready, not that a run has been re-simulated.