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>]:
- access is
read,writeorread_write. *matches every collection of a namespace (example.*), or everything (*).- With fields, the token only sees and changes those fields. Every other field is removed from every record it reads: in records, leaderboards, scans and live updates alike.
- Deleting a record needs write access to all of its fields.
Rules that hold for every API token:
- It works the same over HTTP, WebSocket and gRPC.
- It cannot create collections or change schemas. Collections come from your plugins.
- It cannot change the data of a player who is online. While a player
is on a Minecraft server, that server owns their data, and any other
write would be lost or would overwrite the game. The API answers
409 PLAYER_ONLINE; retry after the player leaves, or deliver the reward in game. (Live delivery to the owning server comes with change streams, Phase 7.)
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:
- A record subscription sends
{"exists": true, "record": {...}}after every change, or{"exists": false}once it is deleted. - A leaderboard subscription sends the whole page whenever its ranked field changes in a way that changes the page, at most four times a second. A burst of changes gives one update with the final order, not one per 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