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
| Tier | Who posts | What it proves |
|---|---|---|
client | The 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. |
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. Choose server when your server has something to check, and client otherwise.
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. Since the page calls it from another origin with a JSON body, 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, and send the same Access-Control-Allow-Origin on the POST's answer. With no server at all, declare the board client.
Issue the secret
Issue the secret on the space's edit page, or through the REST API. 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 offered the 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.
Verify the token and post
On your server, verify 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. Then POST to https://velven.ai/api/v1/scores with the secret in x-velven-secret.
@velven/sdk/server does both with no dependencies, on Node 20 and later, Deno, Bun and Cloudflare Workers. It accepts only Velven's own tokens and has no local mode. 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 listing'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 here: 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 listing'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 });};Velven checks the token again itself, so a leaked secret alone cannot post as a player.
Request body
POST /api/v1/scores with the header x-velven-secret and a JSON body:
tokenString- The player's token, from
signIn()in the page. boardBoard key- The board to post to.
valueNumber- The score.
request_idUp to 64 characters- Makes the post idempotent: a retry with the same id answers as the first did and is entered once.
metaObject, up to 1 KB as JSON- Optional. Returned with every read.
Responses
| Status | Body | Meaning |
|---|---|---|
| 200 | { "ok": true, "board", "value", "total", "improved", "rank" } | Entered. total is what the board ranks the player by now. |
| 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 | The player is banned, 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. |