velven
Docs
Menu

Server scores

A server board takes scores only from your own server, with the space's secret and the player's token. It is as honest as the checks your server runs.

View guide as Markdown

Choose a tier

Choose server when your server has something to check, and client otherwise.

Tier
client
Who posts
Your space's page, through the Velven page
What it proves
The player's word, within the board's range, cooldown and caps.
Tier
server
Who posts
Your server, with the secret and the player's token
What it proves
That the score came through your server.

A server board is only worth more when your server checks something: it runs the game, or checks the run against its own record. A server that relays the number the page sent proves no more than client, and costs you a function.

The server can live anywhere. Velven checks the secret and the token, not where the post came from. A space on a static host, such as a ChatGPT site or GitHub Pages, can post through a small function on Vercel, Netlify, Cloudflare or Replit. With no server at all, declare the board client.

Note: A function on another origin needs CORS. The page sends it a JSON body, so the browser sends a preflight first. Answer OPTIONS with Access-Control-Allow-Origin set to your page's origin and Access-Control-Allow-Headers: content-type. Send the same Access-Control-Allow-Origin on the POST's answer.

Issue the secret

Declare the board with "trust":"server", the default, in the page's block:

index.html
<script type="application/velven+json">{"boards":[{"key":"main","trust":"server","metric":"time","sort":"asc","cooldown":10}]}</script>

Then issue the secret: on the Leaderboards tab of the space's edit page, open “For developers” and press “Make a key” under “Server key”. Or issue it through the REST API with your API token. It is shown once and only its hash is kept; issuing again replaces it.

Terminal
curl -s -X POST https://velven.ai/api/spaces/<slug>/secret -H "authorization: Bearer $VELVEN_TOKEN"# { "secret": "…", "message": "Shown once. Put it in the host's server environment …" }

Warning: Put the secret in your server's environment, never in the page.

Send the run from the page

When a run ends, get the player's token with signIn() and send it to your server with the run. Use a plain signIn(), not a silent one: the run's end is the player's own action, so a guest may be shown the sign-in card.

game.js
const auth = await Velven.signIn();if (auth.ok && Velven.environment === "local") {  await Velven.scores.submit(score); // the in-memory board, so the page is built and drawn locally} else if (auth.ok) {  await fetch("https://your-function.example.com/api/score", {    method: "POST",    headers: { "content-type": "application/json" },    body: JSON.stringify({ token: auth.token, value: score, request_id: crypto.randomUUID() }),  });}

On your laptop there is no function and the token is fake, so in the local environment the example posts to the in-memory board instead. The SDK takes it with a console note; add ?velven_strict=1 to refuse it as Velven does.

Post from your server

On your server, POST the run to https://velven.ai/api/v1/scores with the secret in x-velven-secret and the player's token in the body. Velven verifies the token and its space on every post, so a server that only forwards the score needs nothing else. postScore from @velven/sdk/server does it with no dependencies, on Node 20 and later, Deno, Bun and Cloudflare Workers. The package has no local mode: a token is Velven's or it is refused.

api/score.js
import { postScore } from "@velven/sdk/server";export default async (request) => {  const { token, value, request_id } = await request.json();  // Check the run here, such as by replaying its inputs: a bare relay proves no more than a client board.  const result = await postScore({ secret: process.env.VELVEN_BOARD_SECRET, token, value, requestId: request_id });  return Response.json(result); // { ok: true, rank, total, improved, ... } or { ok: false, error }, for the page to read};

Verify the token yourself only when your server needs the player's identity: to check the run against what it holds for that player, to key your own records by player, or to refuse a bad token before posting. Otherwise skip it: a cold start saves fetching Velven's keys. To verify, check the token against https://velven.ai/.well-known/jwks.json: the issuer is https://velven.ai, the audience is your space's origin, the algorithm is ES256, and the space claim is your space's id. verifyToken does that, or use any JWT library, such as jose:

import { postScore, verifyToken } from "@velven/sdk/server";export default async (request) => {  const { token, value, request_id } = await request.json();  // Your space's id (Velven.space.id in the page), checked when the variable is set.  const space = process.env.VELVEN_SPACE_ID ? Number(process.env.VELVEN_SPACE_ID) : undefined;  const player = await verifyToken(token, { audience: "https://your-space.netlify.app", space });  if (!player.ok) return Response.json({ error: player.error }, { status: 401 });  // Check the run against your own record for player.user.id: this is what makes a server board worth more than a client one.  const result = await postScore({ secret: process.env.VELVEN_BOARD_SECRET, token, value, requestId: request_id });  return Response.json(result); // { ok: true, rank, ... } or { ok: false, error }, for the page to read};

Either way, Velven checks the token itself, so a leaked secret alone cannot post as a player.

Note: Moving your space to a new URL (Move to a new address, on the Settings tab of its edit page) keeps its id, boards and secret, but tokens from then on carry the new origin as their audience. Update the audience your server checks when you move.

Request body

POST /api/v1/scores with the header x-velven-secret and a JSON body:

tokenString
Required. The player's token, from signIn() in the page.
boardBoard key
Required. The board to post to. postScore sends main when you leave it out; the raw API has no default.
valueNumber
Required. The score.
request_id1 to 64 letters, digits, - or _
Required. Makes the post idempotent: a retry with the same id answers as the first did and is entered once. crypto.randomUUID() fits. The same rule holds for requestId in the page and in postScore.
metaObject, up to 1 KB as JSON
Optional. Returned with every read.

Responses

Every answer is JSON. A refusal carries its code in error:

Status
200
Body
{ "ok": true, "board", "value", "total", "improved", "rank" }
Meaning
Entered. rank, total and improved mean what they do for submit: the player's standing now, not this run's rank, except on a run board.
Status
400
Body
{ "error": "bad_request" }
Meaning
The body was not { token, board, value, request_id } with an optional meta.
Status
401
Body
{ "error": "invalid_secret" }
Meaning
No space has that secret. Issue a new one and put it in the environment.
Status
401
Body
expired_token or invalid_token
Meaning
The token is past its hour, or Velven did not sign it. Ask the page for a fresh one.
Status
403
Body
wrong_space
Meaning
The token was made for another space.
Status
403
Body
banned, server_only or client_only
Meaning
You blocked the player on the space's boards, or Velven banned their account, or the board's trust does not take this path.
Status
404
Body
unavailable or no_board
Meaning
The space is not published with a verified owner, or no board has that key.
Status
422
Body
{ "error": "out_of_range", "min", "max" } or invalid_value
Meaning
Outside the board's range, not a finite number, or meta over 1 KB.
Status
429
Body
{ "error": "cooldown", "retry_after" } or rate_limited
Meaning
Too soon for this player, or more than 600 posts from this space in a minute.
Status
503
Body
{ "error": "failed" }
Meaning
Velven could not store it just now. Retry with the same request_id.

Unlock achievements from your server

With the same secret and the player's token, your server unlocks an achievement or writes a stat. An unlock from your server is marked trusted, and raises one the page made earlier.

JavaScript
import { setStat, unlockAchievement } from "@velven/sdk/server";await unlockAchievement({ secret: process.env.VELVEN_SECRET, token, achievement: "boss" });const kills = await setStat({ secret: process.env.VELVEN_SECRET, token, stat: "kills", value: 1, mode: "add" });// { ok: true, stat: "kills", value, unlocked: [{ key, title, description, icon, points }] }

Both post to /api/v1/achievements and /api/v1/stats with x-velven-secret, and answer bad_request, invalid_secret, invalid_token, expired_token, wrong_space, unavailable, banned, no_achievement, no_stat, invalid_value, rate_limited or failed, as postScore does.

Tokens from a preview

A token made in a preview or velven dev carries "sbx": true and names the preview's own address as its audience. verifyToken answers sandbox: true for it. Velven writes what that player does to your space's sandbox, never the live space; keep it out of anything of your own that counts for real.