Examples
10. Examples: curl#
API=https://your-server.example
# Server info (no auth)
curl -sS "$API/v1/info"
# {"protocol":1,"min_protocol":1,"modules":["auth","chat","storage"]}
# Register (logs in at once)
curl -sS "$API/v1/auth/register" -H 'content-type: application/json' \
-d '{"email":"player@example.com","password":"correct horse battery","display_name":"Player One"}'
# {"account":{"id":42,...},"tokens":{"token_type":"Bearer","access_token":"nbsa_...","access_expires_at":...,"refresh_token":"nbsr_...","refresh_expires_at":...}}
# Log in
curl -sS "$API/v1/auth/login" -H 'content-type: application/json' \
-d '{"email":"player@example.com","password":"correct horse battery"}'
ACCESS=nbsa_... # tokens.access_token
REFRESH=nbsr_... # tokens.refresh_token
# Who am I
curl -sS "$API/v1/account" -H "authorization: Bearer $ACCESS"
# Refresh (single use: store the new pair)
curl -sS "$API/v1/auth/refresh" -H 'content-type: application/json' \
-d "{\"refresh_token\":\"$REFRESH\"}"
# Save an object (create only if new)
curl -sS -X PUT "$API/v1/storage/saves/slot-1" -H "authorization: Bearer $ACCESS" \
-H 'content-type: application/json' -d '{"value":{"level":3,"gold":250},"if_version":0}'
# {"collection":"saves","key":"slot-1","version":1,"updated_at":...}
# Load it (the ETag header is the version)
curl -sS -i "$API/v1/storage/saves/slot-1" -H "authorization: Bearer $ACCESS"
# Overwrite only version 1
curl -sS -X PUT "$API/v1/storage/saves/slot-1" -H "authorization: Bearer $ACCESS" \
-H 'content-type: application/json' -d '{"value":{"level":4,"gold":300},"if_version":1}'
# a stale version: 409 {"error":{"code":"version_conflict","message":"...","details":{"current_version":2}}}
# List a collection (no values), then delete
curl -sS "$API/v1/storage/saves?limit=20" -H "authorization: Bearer $ACCESS"
curl -sS -X DELETE "$API/v1/storage/saves/slot-1?if_version=2" -H "authorization: Bearer $ACCESS"
# Chat over HTTP: public rooms, a room's history, open a DM
curl -sS "$API/v1/chat/rooms" -H "authorization: Bearer $ACCESS"
curl -sS "$API/v1/chat/rooms/12/messages?limit=50" -H "authorization: Bearer $ACCESS"
curl -sS "$API/v1/chat/dm" -H "authorization: Bearer $ACCESS" -H 'content-type: application/json' -d '{"user":77}'
# Password reset (website flow)
curl -sS "$API/v1/auth/password/forgot" -H 'content-type: application/json' -d '{"email":"player@example.com"}'
curl -sS "$API/v1/auth/password/reset" -H 'content-type: application/json' \
-d '{"token":"<token from the mail link>","new_password":"another long passphrase"}'
# Log out (this session; with an expired access token send {"refresh_token":"..."} instead)
curl -sS "$API/v1/auth/logout" -H "authorization: Bearer $ACCESS" -H 'content-type: application/json' -d '{}'
# The API documents
curl -sS "$API/v1/openapi.json" -o openapi.json
curl -sS "$API/v1/asyncapi.json" -o asyncapi.json
11. Examples: browser JavaScript#
Plain JavaScript with fetch and WebSocket, no library. The page must be served from the API's
origin, or from an origin listed in the server's cors.allowed_origins.
HTTP helper and token handling#
const API = "https://your-server.example";
class ApiError extends Error {
constructor(status, error) {
super(error.message || `HTTP ${status}`);
this.status = status; // HTTP status
this.code = error.code; // stable error code: branch on this
this.details = error.details; // code-specific details, if any
}
}
async function api(method, path, { body, token, query } = {}) {
const url = new URL(API + path);
for (const [name, value] of Object.entries(query || {})) {
if (value !== undefined && value !== null) url.searchParams.set(name, value);
}
const headers = { "x-net-backend-protocol": "1" };
if (token) headers["Authorization"] = `Bearer ${token}`;
if (body !== undefined) headers["Content-Type"] = "application/json";
const response = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
const json = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new ApiError(response.status, (json && json.error) || { code: `http_${response.status}`, message: response.statusText });
}
return json;
}
// Tokens are shared by every tab of the site through localStorage, so two tabs never rotate the
// same refresh token minutes apart (a reuse after 30 s would revoke the session).
const TOKENS_KEY = "nb_tokens";
const loadTokens = () => JSON.parse(localStorage.getItem(TOKENS_KEY) || "null");
const saveTokens = (tokens) => localStorage.setItem(TOKENS_KEY, JSON.stringify(tokens));
const clearTokens = () => localStorage.removeItem(TOKENS_KEY);
let refreshing = null; // single-flight within this tab
// `staleAccessToken`: the access token the server just refused (token_expired, close 4001), if any.
function refreshTokens(staleAccessToken) {
if (!refreshing) {
refreshing = (async () => {
const before = loadTokens(); // re-read: another tab may have refreshed already
if (!before) throw new ApiError(401, { code: "unauthorized", message: "not logged in" });
const rotatedElsewhere = staleAccessToken && before.access_token !== staleAccessToken;
const stillFresh = !staleAccessToken && before.access_expires_at - Date.now() > 60_000;
if (rotatedElsewhere || stillFresh) return before;
try {
const tokens = await api("POST", "/v1/auth/refresh", { body: { refresh_token: before.refresh_token } });
saveTokens(tokens);
return tokens;
} catch (error) {
// 401 (unauthorized, refresh_token_reused) or 403 (banned): the session is over.
// A network error or a 5xx keeps the tokens: try again later.
if (error.status === 401 || error.status === 403) clearTokens();
throw error;
} finally {
refreshing = null;
}
})();
}
return refreshing;
}
async function accessToken() {
let tokens = loadTokens();
if (!tokens) throw new ApiError(401, { code: "unauthorized", message: "not logged in" });
if (tokens.access_expires_at - Date.now() < 60_000) tokens = await refreshTokens();
return tokens.access_token;
}
// An authenticated call: refreshes ahead of expiry, and once more on `token_expired`.
async function authed(method, path, options = {}) {
const token = await accessToken();
try {
return await api(method, path, { ...options, token });
} catch (error) {
if (error.code !== "token_expired") throw error;
const tokens = await refreshTokens(token);
return api(method, path, { ...options, token: tokens.access_token });
}
}
Register, log in, refresh, log out#
async function register(email, password, displayName) {
const session = await api("POST", "/v1/auth/register", {
body: { email, password, display_name: displayName },
});
saveTokens(session.tokens);
return session.account;
}
async function login(email, password) {
try {
const session = await api("POST", "/v1/auth/login", { body: { email, password } });
saveTokens(session.tokens);
return session.account;
} catch (error) {
if (error.code === "invalid_credentials") throw new Error("Wrong email or password.");
if (error.code === "rate_limited") throw new Error(`Too many attempts. Try again in ${Math.ceil(error.details.retry_after_ms / 1000)} s.`);
if (error.code === "banned") throw new Error("This account is banned.");
if (error.code === "validation_failed") throw new Error(Object.values(error.details.fields).flat().join(" "));
throw error;
}
}
async function logout(everywhere = false) {
const tokens = loadTokens();
if (!tokens) return;
try {
// The refresh token works even when the access token has expired.
await api("POST", "/v1/auth/logout", { body: { refresh_token: tokens.refresh_token, everywhere } });
} finally {
clearTokens();
}
}
const me = () => authed("GET", "/v1/account");
Save and load an object#
async function loadSave(slot) {
try {
const object = await authed("GET", `/v1/storage/saves/${slot}`);
return { value: object.value, version: object.version };
} catch (error) {
if (error.code === "not_found") return { value: null, version: 0 };
throw error;
}
}
// Writes only over the version that was loaded (0 = only if it does not exist yet).
async function writeSave(slot, value, version) {
try {
const ack = await authed("PUT", `/v1/storage/saves/${slot}`, { body: { value, if_version: version } });
return ack.version; // keep it for the next write
} catch (error) {
if (error.code === "version_conflict") {
// Another device saved meanwhile: reload, let the player decide, write again.
throw new Error(`The save changed elsewhere (now version ${error.details.current_version ?? "deleted"}).`);
}
throw error;
}
}
// Usage
const save = await loadSave("slot-1");
const newVersion = await writeSave("slot-1", { level: 3, gold: 250 }, save.version);
WebSocket: connect, authenticate, join, chat, reconnect#
class Realtime {
constructor() {
this.ws = null;
this.ready = false;
this.nextId = 1;
this.pending = new Map(); // request id -> { resolve, reject }
this.listeners = new Map(); // push type -> [callback]
this.rooms = new Set(); // room keys / ids to join again after a reconnect
this.attempt = 0;
this.stopped = false;
this.lastAuthError = null;
this.authToken = null; // the access token sent in the last `auth`
this.retriedAfter4001 = false;
}
on(type, callback) {
if (!this.listeners.has(type)) this.listeners.set(type, []);
this.listeners.get(type).push(callback);
}
emit(type, data) {
for (const callback of this.listeners.get(type) || []) callback(data);
}
connect() {
this.stopped = false;
const ws = new WebSocket(API.replace(/^http/, "ws") + "/v1/ws");
this.ws = ws;
this.ready = false;
this.lastAuthError = null;
ws.onopen = async () => {
try {
// Browsers cannot set headers on a WebSocket: authenticate with the first message (within 5 s).
this.authToken = await accessToken();
ws.send(JSON.stringify({ type: "auth", data: { token: this.authToken, protocol: 1 } }));
} catch {
ws.close(); // not logged in, or the refresh failed
}
};
ws.onmessage = (event) => this.onFrame(JSON.parse(event.data));
ws.onclose = (event) => this.onClose(event);
}
onFrame(frame) {
const isAnswer = typeof frame.id === "number" && ("ok" in frame || !("type" in frame));
if (isAnswer) {
const waiting = this.pending.get(frame.id);
if (!waiting) return;
this.pending.delete(frame.id);
if (frame.ok === false) waiting.reject(frame.error);
else waiting.resolve(frame.data ?? null);
return;
}
if (frame.type === "auth.ok") {
this.ready = true;
this.attempt = 0;
this.retriedAfter4001 = false;
this.onReady();
return;
}
if (frame.type === "auth.failed") {
this.lastAuthError = frame.error; // the server closes the socket right after
return;
}
this.emit(frame.type, frame.data); // a push: chat.message, chat.deleted, chat.presence, ...
}
request(type, data) {
return new Promise((resolve, reject) => {
if (!this.ready) return reject({ code: "not_connected", message: "not connected" });
const id = this.nextId++;
this.pending.set(id, { resolve, reject });
this.ws.send(JSON.stringify({ id, type, data }));
});
}
async onReady() {
// Membership ends with the connection: join every room again.
for (const room of this.rooms) {
try {
const info = await this.request("chat.join", { room });
this.emit("joined", info);
} catch (error) {
console.warn("join failed", room, error.code);
}
}
this.emit("ready");
}
async onClose(event) {
this.ready = false;
for (const waiting of this.pending.values()) waiting.reject({ code: "disconnected", message: "connection closed" });
this.pending.clear();
if (this.stopped) return;
if (event.code >= 4000 && event.code <= 4099) {
// Never come back automatically with the same credentials.
if (event.code === 4001 && !this.retriedAfter4001) {
this.retriedAfter4001 = true;
try {
await refreshTokens(this.authToken); // works when the token had only expired
this.connect(); // one more try with the new token
return;
} catch {
// the session is over: fall through
}
}
this.emit("gone", { code: event.code, error: this.lastAuthError }); // show login / ban / "opened elsewhere"
return;
}
// Everything else: exponential backoff with jitter, 1 s .. 30 s.
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = base * (0.75 + Math.random() * 0.5);
setTimeout(() => this.connect(), delay);
}
close() {
this.stopped = true;
if (this.ws) this.ws.close(1000);
}
// Chat helpers
async join(room) {
this.rooms.add(room);
return this.request("chat.join", { room });
}
async leave(roomId, roomRef) {
this.rooms.delete(roomRef ?? roomId);
return this.request("chat.leave", { room: roomId });
}
send(roomId, text) {
const nonce = crypto.randomUUID().replaceAll("-", ""); // random, never secret
return this.request("chat.send", { room: roomId, text, nonce });
}
history(roomId, cursor) {
return this.request("chat.history", { room: roomId, cursor, limit: 50 });
}
}
// Usage
const live = new Realtime();
const shown = new Set(); // message ids already on screen (the sender gets its own echo)
live.on("chat.message", (message) => {
if (shown.has(message.id)) return;
shown.add(message.id);
console.log(`[${message.room}] ${message.sender_name ?? message.sender}: ${message.text}`);
});
live.on("chat.deleted", ({ id, room }) => console.log(`message ${id} in room ${room} was deleted`));
live.on("chat.presence", ({ room, user, event, count }) => console.log(`user ${user} ${event} room ${room} (${count ?? "?"} online)`));
live.on("gone", ({ code, error }) => console.log("disconnected for good:", code, error && error.code));
live.on("joined", async (room) => {
// Every (re)join: load the latest history (newest first) and show what is not on screen yet.
const page = await live.history(room.id);
for (const message of page.items.reverse()) {
if (!shown.has(message.id)) { shown.add(message.id); console.log(message.text); }
}
});
live.rooms.add("world"); // join the public room "world" on every (re)connect
live.connect();
// Later, once connected (12 = the `id` of the joined room):
// const ack = await live.send(12, "hello"); // { message_id, sent_at }; the echo follows
// shown.add(ack.message_id); // if you already drew the message yourself
Errors from live.request(...) are the protocol's error objects ({code, message, details?}):
for example rate_limited on chat.send carries details.retry_after_ms.
Generated from API.md (repository commit e25edb9, file sha256 3db87f7bba70).