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.
Choose a tier
Choose server when your server has something to check, and client otherwise.
| Tier | Who posts | What it proves |
|---|---|---|
client | Your space's page, through the Velven page | The player's word, within the board's range, cooldown and caps. |
server | Your server, with the secret and the player's token | That the score came through your server. |
- 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:
<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.
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.
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.
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};import { createRemoteJWKSet, jwtVerify } from "jose";const VELVEN = "https://velven.ai";const SPACE_ORIGIN = "https://your-space.netlify.app";const SPACE_ID = Number(process.env.VELVEN_SPACE_ID); // your space's id: Velven.space.id in the pageconst jwks = createRemoteJWKSet(new URL(`${VELVEN}/.well-known/jwks.json`));export default async (request) => { const { token, value, request_id } = await request.json(); try { const { payload } = await jwtVerify(token, jwks, { issuer: VELVEN, audience: SPACE_ORIGIN, algorithms: ["ES256"] }); if (payload.space !== SPACE_ID) throw new Error("wrong space"); } catch { return Response.json({ error: "invalid_token" }, { status: 401 }); } const res = await fetch(`${VELVEN}/api/v1/scores`, { method: "POST", headers: { "content-type": "application/json", "x-velven-secret": process.env.VELVEN_BOARD_SECRET }, body: JSON.stringify({ token, board: "main", value, request_id }), }); return Response.json(await res.json(), { status: res.status });};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.
postScoresendsmainwhen 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 forrequestIdin the page and inpostScore. 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 | Body | Meaning |
|---|---|---|
| 200 | { "ok": true, "board", "value", "total", "improved", "rank" } | 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. |
| 400 | { "error": "bad_request" } | The body was not { token, board, value, request_id } with an optional meta. |
| 401 | { "error": "invalid_secret" } | No space has that secret. Issue a new one and put it in the environment. |
| 401 | expired_token or invalid_token | The token is past its hour, or Velven did not sign it. Ask the page for a fresh one. |
| 403 | wrong_space | The token was made for another space. |
| 403 | banned, server_only or client_only | You blocked the player on the space's boards, or Velven banned their account, or the board's trust does not take this path. |
| 404 | unavailable or no_board | The space is not published with a verified owner, or no board has that key. |
| 422 | { "error": "out_of_range", "min", "max" } or invalid_value | Outside the board's range, not a finite number, or meta over 1 KB. |
| 429 | { "error": "cooldown", "retry_after" } or rate_limited | Too soon for this player, or more than 600 posts from this space in a minute. |
| 503 | { "error": "failed" } | Velven could not store it just now. Retry with the same request_id. |
- Status
- 200
- Body
{ "ok": true, "board", "value", "total", "improved", "rank" }- Meaning
- Entered.
rank,totalandimprovedmean what they do forsubmit: the player's standing now, not this run's rank, except on arunboard.
- Status
- 400
- Body
{ "error": "bad_request" }- Meaning
- The body was not
{ token, board, value, request_id }with an optionalmeta.
- 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_tokenorinvalid_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_onlyorclient_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
unavailableorno_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" }orinvalid_value- Meaning
- Outside the board's range, not a finite number, or
metaover 1 KB.
- Status
- 429
- Body
{ "error": "cooldown", "retry_after" }orrate_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.
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.