HTTP and WebSocket API

The HTTP API puts your server’s data on your website, in your Discord bot or in your store: leaderboards, player profiles, live stats, rewards for purchases. Everything is JSON. The WebSocket API adds live updates: a change made on a Minecraft server reaches a browser within milliseconds.

The API runs on port 7421, next to the gRPC port Minecraft servers use. It is on by default, but only for the machine LeonsDB runs on (see Configuration).

Tokens and permissions

Every request needs a token:

Authorization: Bearer ldb_3f2a...

For websites, bots and stores, create an API token that can only reach what it needs. Its grants list collections and, optionally, fields:

# A public leaderboard: names and kills of example.stats, nothing else.
leonsdb token create -name website -role api -grant example.stats:read:name,kills

# A store that gives coins and reads nothing.
leonsdb token create -name store -role api -grant example.stats:write:coins

# A Discord bot that reads everything in two namespaces, 5 requests/second.
leonsdb token create -name bot -role api -grant example.*:read -grant clans.*:read -rate 5

A grant is <namespace>.<collection>:<access>[:<fields>]:

Rules that hold for every API token:

Tokens with the server, read_only and admin roles also work here, with their usual access. Never put those in a web page.

Rate limits

Each API token allows 20 requests per second per client address by default (-rate changes it), with bursts of twice that. Over the limit, requests get 429 RATE_LIMITED with a Retry-After header. Other tokens and other clients are not affected.

Ten failed logins from one address block that address for a minute.

Using a token in a web page

Anything in a web page is public, so a token used from browser JavaScript is effectively public too. That is fine for a read-only token limited to public fields, like the website example above: it can read nothing else, and the rate limit applies per visitor. Allow your site’s origin with http.cors_origins.

Tokens that can write, or read private fields, belong on your server (your website’s backend, your bot), never in a page.

Endpoints

All paths start with /v1. Player records are addressed by UUID (with or without dashes); global records by their id.

Method Path
GET /v1/health {"status":"ok"}. No token needed.
GET /v1/info Server version. No token needed.
GET /v1/collections Collections the token can access. ?namespace= filters.
GET /v1/collections/{ns}/{coll} One collection and its fields.
GET /v1/collections/{ns}/{coll}/records All records, a page at a time.
GET /v1/collections/{ns}/{coll}/records/{key} One record.
PATCH /v1/collections/{ns}/{coll}/records/{key} Change fields.
DELETE /v1/collections/{ns}/{coll}/records/{key} Delete a record.
GET /v1/collections/{ns}/{coll}/leaderboards/{field} A leaderboard page.
GET /v1/collections/{ns}/{coll}/leaderboards/{field}/{key} One record’s rank.
GET /v1/ws WebSocket, see below.

Read a record

curl -H "Authorization: Bearer $TOKEN" \
  https://db.example.com:7421/v1/collections/example/stats/records/069a79f4-44e9-4726-a5be-fca90e38aaf5
{
  "namespace": "example",
  "collection": "stats",
  "key": "069a79f4-44e9-4726-a5be-fca90e38aaf5",
  "version": 42,
  "updated_at": "2026-10-24T18:03:11.250Z",
  "fields": {"name": "Notch", "kills": 25}
}

A missing record is 404 RECORD_NOT_FOUND.

List records

GET .../records?limit=100 returns {"records": [...], "next_cursor": "..."}. Pass ?cursor=<next_cursor> for the next page; there is no next_cursor on the last one. limit is 1 to 1000 (default 100).

Leaderboards

The field must be @Indexed in the plugin.

GET /v1/collections/example/stats/leaderboards/kills?limit=10
{
  "field": "kills",
  "order": "highest",
  "total": 1520,
  "entries": [
    {"rank": 1, "key": "069a79f4-...", "value": 25, "record": {"fields": {"name": "Notch", "kills": 25}, "...": "..."}},
    {"rank": 2, "key": "853c80ef-...", "value": 20, "record": {"...": "..."}},
    {"rank": 2, "key": "61699b2e-...", "value": 20, "record": {"...": "..."}}
  ]
}
Parameter
limit 1 to 1000, default 10.
offset Entries to skip, for paging.
order highest (default) or lowest first, for example for fastest times.
records false leaves out each entry’s record.

Equal values share a rank. GET .../leaderboards/kills/{key} returns {"ranked": true, "rank": 3, "value": 25, "total": 1520}, or {"ranked": false, ...} when the record has no value.

Change a record

curl -X PATCH -H "Authorization: Bearer $TOKEN" \
  -d '{"increment": {"coins": 500}}' \
  https://db.example.com:7421/v1/collections/example/stats/records/069a79f4-44e9-4726-a5be-fca90e38aaf5
{"version": 43, "fields": {"coins": 1700}}

The body can contain:

Key
set Fields to replace: {"title": "Champion"}.
increment Numbers to add to: {"coins": 500, "deaths": -1}. A missing field starts at 0; overflow is an error, never a wrap-around.
unset Fields to remove: ["title"].
expected_version Only apply if the record’s version is still this (0 means “must not exist yet”); otherwise 409 VERSION_MISMATCH. Use it for read-modify-write.

All changes are applied atomically, and the answer only comes once they are on disk. A field can appear only once per request. The response has the new values of the changed fields (only those the token may read).

Values take the field’s declared type: {"coins": 5} stores a long if the plugin declared coins as a long. The type is never changed by the API; a value that does not fit is 400 INVALID_VALUE.

DELETE .../records/{key} answers {"existed": true}; it accepts ?expected_version= too.

Value types

Type JSON Example
int, long number 25
double number 1.5
bool boolean true
string string "Notch"
UUID string "069a79f4-44e9-4726-a5be-fca90e38aaf5"
timestamp ISO 8601 string (milliseconds also accepted when writing) "2026-10-24T18:03:11.250Z"
list array ["vip", "builder"]
map object {"wins": 3}
location object {"world": "world", "x": 10.5, "y": 64, "z": -3, "yaw": 90, "pitch": 0}
item object see below
inventory object {"size": 41, "slots": {"0": <item>, "40": <item>}}
bytes base64 string "AAH/"

Longs are written as exact JSON numbers. JavaScript reads numbers above 2^53 (9 quadrillion) inexactly; other languages do not.

Items show what they are, so a web page can render an inventory:

{
  "id": "minecraft:diamond_sword",
  "count": 1,
  "components": {"minecraft:enchantments": {"levels": {"minecraft:sharpness": 5}}},
  "data_version": 4440,
  "data": "H4sIAAAAAAAA..."
}

data and data_version are the item exactly as Minecraft stores it. To write an item, send those two (as read from another record); id, count and components are for display and are ignored on writes.

Errors

Every error has the same shape:

{"error": {"status": 409, "reason": "PLAYER_ONLINE", "message": "this player is online on lobby-1, ..."}}

reason is stable; match on it, not on message.

Status Reasons
400 INVALID_JSON, INVALID_VALUE, INVALID_KEY, INVALID_NAME, INVALID_OPERATION, TYPE_MISMATCH, OVERFLOW, LIMIT_EXCEEDED, RECORD_TOO_LARGE
401 UNAUTHENTICATED: no token, or a wrong or revoked one
403 PERMISSION_DENIED: the token’s grants do not allow it
404 RECORD_NOT_FOUND, COLLECTION_NOT_FOUND, NOT_FOUND (no such endpoint)
409 PLAYER_ONLINE, VERSION_MISMATCH, NOT_INDEXED, SCHEMA_CONFLICT
429 RATE_LIMITED: wait for Retry-After seconds
503 INDEX_BUILDING: a new leaderboard is still being built; retry shortly

WebSocket

Connect to wss://<host>:7421/v1/ws. Browsers cannot send headers on a WebSocket, so the first message authenticates:

const ws = new WebSocket("wss://db.example.com:7421/v1/ws");
ws.onopen = () => {
  ws.send(JSON.stringify({type: "auth", token: TOKEN}));
  ws.send(JSON.stringify({
    id: 1, type: "subscribe", namespace: "example", collection: "stats",
    leaderboard: {field: "kills", limit: 10}
  }));
};
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "update" && msg.subscription === 1) render(msg.data.entries);
};

(Programs that can set headers may send Authorization: Bearer ... with the connection instead.) The server answers {"type": "ready"}.

Every message from the client is JSON with a type, and an id that the reply repeats:

Message Reply
{"id": 1, "type": "get", "namespace", "collection", "key"} {"id": 1, "type": "result", "data": {"exists": true, "record": {...}}}
{"id": 2, "type": "top", "namespace", "collection", "field", "limit", "offset", "order", "records"} result with a leaderboard page
{"id": 3, "type": "rank", "namespace", "collection", "key", "field", "order"} result with the rank
{"id": 4, "type": "subscribe", "namespace", "collection", "key"} subscribed, then update messages
{"id": 5, "type": "subscribe", "namespace", "collection", "leaderboard": {"field", "limit", "offset", "order", "records"}} subscribed, then update messages
{"id": 6, "type": "unsubscribe", "subscription": 4} unsubscribed
{"id": 7, "type": "ping"} pong

Errors come back as {"id": ..., "type": "error", "error": {...}} in the same shape as HTTP errors, and the connection stays open.

Subscriptions. The subscription’s id is the id of the subscribe request. The first update is the current state; each later one is the new state after a change:

Updates always carry the latest state. A client that cannot keep up skips intermediate states rather than falling behind.

Limits. Every message counts toward the token’s rate limit. A connection can have 100 subscriptions; an address 100 connections. The connection closes with code 1008 when the token is revoked (checked every 5 seconds), and with 1001 when the server shuts down. Reconnect and subscribe again. Nothing is missed: the first update is always the current state.

Configuration

In leonsdb.yml:

http:
  # Port of the HTTP and WebSocket API. Empty disables it.
  # Like the gRPC port, a network address requires TLS (it uses the same
  # certificate).
  listen: 0.0.0.0:7421

  # Websites whose pages may call the API from the browser. "*" allows any.
  cors_origins: ["https://example.com"]

  # Reverse proxies in front of LeonsDB (nginx, Cloudflare tunnels...).
  # Only they may set the client address with X-Forwarded-For, which rate
  # limits are based on.
  trusted_proxies: ["127.0.0.1"]

  # Maximum open WebSocket connections.
  max_websockets: 10000