Two services, shared by our apps

A ranked-board service and a key-value store. Both are generic: they hold what an app gives them and never interpret it.

Authentication

Every call carries an app key in X-Api-Key. Calls made on a player's behalf also carry that player's token as Authorization: Bearer ….

  • Publishable key (pk_…) ships inside a client binary. It is not a secret and is not treated as one: it can register a player, and act for the player whose token accompanies it.
  • Secret key (sk_…) is server-side only. It adds the operations that would be abuse in a client's hands — writing app-scoped values, and acting for a named player.

A key is shown once, when it is created; only its hash is stored. The same is true of a player token. Neither can be recovered — they are replaced.

Registering a player

curl -X POST https://www.outworldapps.com/api/v1/players \
  -H "X-Api-Key: pk_…" \
  -H "Content-Type: application/json" \
  -d '{"displayName":"Ada","deviceLabel":"Pixel 8"}'

The response carries the player and a token. Send externalId as well if the app has its own stable id for the player and wants a second device to join the same identity — but only if that id is unguessable, because it is not authenticated.

POST/api/v1/playersregister a device
GET/api/v1/players/me
PUT/api/v1/players/me/name
GET/api/v1/players/me/deviceswhich devices can act as you
DELETE/api/v1/players/me/devices/{id}revoke one; the others keep working
DELETE/api/v1/players/meerase everything, immediately

Leaderboards

A board's rules — direction, aggregation, period, rollover zone — belong to the board and are set in the back office. A submission carries a score and an opaque blob, and cannot influence how it is ranked. The instant a score was achieved is the server's clock, never the caller's, because it is also the tie-break.

Server-verified boards. A board can be set to refuse anything but a secret key. Use it when your own server decides whether a result is real — a replay it re-simulates, a purchase it checks with a store, a match it refereed. On an ordinary board the publishable key is in the client binary and the player token is on the device, so whatever score a client claims is what the board records; that is fine for a casual high-score table and worthless for anything you intend to mean something. Submit with the secret key and ?playerId= to name the player the score belongs to. Reads are unaffected — a client still renders the board with its publishable key.

GET/api/v1/leaderboardsthe app's boards
GET/api/v1/leaderboards/{board}a page — period, offset, limit
GET/api/v1/leaderboards/{board}/around-methe rows either side of you
GET/api/v1/leaderboards/{board}/meyour standing alone
POST/api/v1/leaderboards/{board}/scoressubmit — ?playerId= with a secret key
curl -X POST https://www.outworldapps.com/api/v1/leaderboards/all-time/scores \
  -H "X-Api-Key: pk_…" \
  -H "Authorization: Bearer pt_…" \
  -H "Content-Type: application/json" \
  -d '{"score":18452,"metadata":{"highestTile":2048,"moves":712}}'

The response says whether the standing actually changed (improved), what it was before, the new rank, and where that rank sits in the board as a percentage. improved: false is a success — it is what a player who did not beat their own best gets.

Aggregation

  • Best — keep the player's best submission for the period. A high-score table.
  • Latest — keep the most recent, better or worse. A rating that can fall.
  • Cumulative — add submissions up. Totals: steps, coins, kills this week.

Periods

AllTime uses the single key all. Daily is 2026-08-10, Weekly is the ISO week 2026-W33, and Monthly is 2026-08. Boundaries fall in the board's rollover time zone, not the caller's and not necessarily UTC. Pass ?period= to read a past board.

Limits: page size up to 100 (default 25); metadata up to 4 KB of valid JSON.

Key-value store

Namespaced storage for what is not a score: cloud saves, settings, an app-wide configuration blob. Values are opaque and optionally expire.

GET/api/v1/kv/{namespace}/{key}
PUT/api/v1/kv/{namespace}/{key}
DELETE/api/v1/kv/{namespace}/{key}
GET/api/v1/kv/{namespace}keys, without their values

?scope=player (the default) is the calling player's own space. ?scope=app is shared by every installation: readable with any key, writable only with a secret one. A secret key may also pass ?playerId= to act for a named player.

Conflicts are the point

Two devices syncing one save is the normal case. Every value carries a revision, returned as an ETag; send it back as If-Match and a write that would clobber somebody else's is refused with 409 — and the refusal carries the current value, so the client can merge rather than re-fetch. If-Match: 0 means "only if nothing is there".

curl -X PUT https://www.outworldapps.com/api/v1/kv/saves/slot-1 \
  -H "X-Api-Key: pk_…" \
  -H "Authorization: Bearer pt_…" \
  -H "If-Match: 4" \
  -H "Content-Type: application/json" \
  -d '{"value":{"seed":91117,"actions":"uldr…"}}'

Limits: 256 KB per value; 200 keys per player; 5000 app-scoped keys. Namespaces and keys are letters, digits and - _ . :; keys may also contain /.

Errors and limits

Every failure is the same shape: a stable code to branch on and a message for whoever is reading the log. A 429 always carries Retry-After — use it rather than guessing, because a guessing client is how a rate limit becomes a retry storm.

{ "code": "revisionMismatch", "message": "The stored revision does not match …" }

Rate limits are per player where one is known and per app-and-address otherwise: 300 reads and 60 writes per 60 seconds.

Health

GET/api/healthno credential; 503 when the database is unreachable