Everything public on this site is readable as JSON. There is no key, no account and no application to fill in: if you can open a page here, you can fetch the same thing from a program.
A key exists for exactly one thing, which is writing. Reporting the players your server sees and collecting votes to reward both need one. Reading never does, and nothing below asks for one.
The Base URL
Every endpoint lives under https://api.lecternmc.com and every path begins with /v1. The version prefix is not optional, and leaving it off is the single commonest mistake:
# right, and answers 200 with an empty body
curl -s -o /dev/null -w '%{http_code}\n' https://api.lecternmc.com/v1/health
# 200
# wrong, and the error says so
curl -s https://api.lecternmc.com/health
# {"error":"not_found","message":"no route matches this path", ... }Health is a real endpoint and a good first call. It runs an actual query, so 200 means the database answered and 503 means it did not. The status code is the whole reply: there is no body, and nothing to parse.
A Player Profile
One call gives you everything a profile page draws. The worn skin and cape are embedded rather than linked, so a header does not cost three round trips.
curl -s https://api.lecternmc.com/v1/players/2535455104824194{
"xuid": "2535455104824194",
"gamertag": "Electroblace",
"firstSeenAt": "2026-07-24T18:40:11.065Z",
"lastSeenAt": "2026-07-24T14:25:16.000Z",
"nameHistoryCount": 13,
"skin": {
"id": "3c414acdaf2c9ebed74bade4839c815dd530269dc91167060cfb0d908d1fc2b1",
"kind": "classic",
"model": "wide",
"width": 64,
"height": 64,
"textureUrl": "https://api.lecternmc.com/v1/skins/3c414acd.../texture.png",
"firstSeenAt": "2026-07-27T04:15:20.356Z"
},
"cape": null,
"counts": { "skins": 1, "capes": 0 }
}Both skin and cape are null when we have not seen one. A skin is also null-textured when it is a persona, which has no image to serve, and while a hash has arrived but the bytes have not. Never assume textureUrl is a string.
This is a record of what servers reported, not a source of truth. Mojang can tell you what a Java account is called right now. We can tell you what something last saw, which is a different promise and a weaker one.
Finding a Player by Name
If all you have is a gamertag, resolve it. The response is byte for byte the profile above, so you do not need a second code path:
curl -s https://api.lecternmc.com/v1/players/by-gamertag/ElectroblaceIt is case insensitive, and it only matches the name somebody holds now. A gamertag they have since dropped resolves to nothing here, which is the point: it is a lookup, not a history search.
Up to a Hundred at Once
A leaderboard or a full player list should not make a hundred requests to draw one page. Identifiers are comma separated, and the two kinds can be mixed:
curl -s "https://api.lecternmc.com/v1/players\
?xuid=2535455104824194,2533275001873580\
&gamertag=RcmJigObject"{
"items": [ ... profiles ... ],
"notFound": { "xuid": ["2535000000000000"], "gamertag": [] }
}A hundred identifiers per request, counting both kinds together. A miss is not an error: unknown ids come back in notFound and everything else still resolves, so one bad id never costs you the page.
History
Three sub-resources hang off a profile, all paginated:
- /v1/players/{xuid}/names - gamertags, newest first
- /v1/players/{xuid}/skins - every skin and persona seen on them, filterable with kind=classic or kind=persona
- /v1/players/{xuid}/capes - capes they own, most recently worn first
{
"items": [
{
"gamertag": "Electroblace",
"changedAt": null,
"observedAt": "2026-07-24T18:40:11.065Z",
"confidence": "single_source",
"isCurrent": true
}
],
"nextCursor": "eyJhdCI6IjIwMjItMTEtMjVUMDM6Mzg6MDcuMDAwWiIsIm..."
}confidence on a name is confirmed, corroborated or single_source, in that order of trust. Everything imported from the old dataset is single_source, because it never recorded how many sources agreed. changedAt is null wherever we cannot honestly say when a rename happened, which is most of the time.
Capes are a set rather than a history. A Bedrock cape is owned, so the question is which ones somebody has, not the order they wore them in.
Pagination Works the Same Everywhere
Every list takes limit and cursor and answers with items and nextCursor. Default limit is 25, maximum is 100.
curl -s "https://api.lecternmc.com/v1/players/2535455104824194/names?limit=2"Cursors are opaque. Pass back exactly what you were given and never build one yourself, because the encoding is free to change. A null nextCursor means you have reached the end; keep going until you see one.
An out of range limit is refused rather than quietly clamped. Asking for 500 gets you a 400, not the first 100 dressed up as the whole set.
Skins
A skin is identified by the sha256 of its own bytes, so an id is the same skin forever no matter who wears it:
- /v1/skins/{hash} - one skin, with wearerCount and full persona piece data where it is a persona
- /v1/skins/{hash}/owners - who has been seen wearing it, most recent first
- /v1/skins/{hash}/texture.png - the image
{
"id": "3c414acdaf2c9ebed74bade4839c815dd530269dc91167060cfb0d908d1fc2b1",
"kind": "classic",
"model": "wide",
"width": 64,
"height": 64,
"textureUrl": "https://api.lecternmc.com/v1/skins/3c414acd.../texture.png",
"wearerCount": 1,
"firstSeenAt": "2026-07-27T04:15:20.356Z",
"persona": null
}Capes
A cape is addressed by texture hash or by slug, and the two are interchangeable in the path. The hash is the identity; the slug is the human handle added when somebody names it.
- /v1/capes - the catalog, sortable by owners or newest
- /v1/capes/{hashOrSlug} - one cape
- /v1/capes/{hashOrSlug}/owners - identical shape to the skin owners list, so write that list once
- /v1/capes/{hashOrSlug}/texture.png - the image
Only named capes appear in the catalog. One found by our collectors is recorded, stored and reachable by hash straight away, but stays out of the listing until somebody gives it a name.
Textures Are the Only Images
Both texture routes serve the source png with no crop and no scale. The API renders nothing: there is no head crop, no body render and no avatar endpoint, and none is planned. A client that wants a face draws it from these bytes.
curl -s -o skin.png \
https://api.lecternmc.com/v1/skins/3c414acd.../texture.pngThese are content addressed, so they are served immutable for a year and carry an ETag. Send If-None-Match and you will get a 304 costing you nothing. Asking for a persona's texture answers 409 rather than 404, because a persona is a different kind of thing rather than a missing file.
Servers
- /v1/servers - the directory. sort=votes|players|newest|name, period=month|week|all, plus tag, region and q
- /v1/servers/{idOrSlug} - one listing, with live player counts and a 24 hour summary
- /v1/servers/{idOrSlug}/stats - a time series
- /v1/servers/{idOrSlug}/votes - recent votes, gamertag and time only
Stats take a range from 1h up to 1y and pick the interval themselves so every range lands between 60 and 180 points. source tells you which stored resolution answered, which is why detail drops between 1d and 3d: raw pings are only kept for 48 hours. Narrow the payload with metrics=players,uptime,latency,votes.
{
"range": "1h", "interval": "1m", "source": "raw",
"summary": {
"players": { "current": 17, "avg": 15.6, "min": 11, "peak": 19 },
"uptime": { "percent": 100, "onlineSamples": 41, "expectedSamples": 41 },
"latency": { "avgMs": 97.2, "minMs": 86, "maxMs": 126 },
"votes": { "total": 0 }
},
"points": [ { "at": "...", "players": 17, "uptime": 100, "latencyMs": 126, "votes": 0 } ]
}A point can be null on every metric where nothing was sampled in that bucket. Draw a gap, not a zero.
The vote list is gamertags and timestamps. Never a voter hash, never an address, never a user id. We do not hold the address in the first place.
Search
One box, results grouped by kind. Players match on their current gamertag first and then on names they used to hold, with matchedOn saying which:
curl -s "https://api.lecternmc.com/v1/search?q=Electro&limit=5"{
"query": "Electro",
"players": { "items": [ { "xuid": "...", "gamertag": "ElectroGalaxy14",
"matchedOn": "gamertag", "matchedGamertag": null,
"lastSeenAt": "...", "textureUrl": "..." } ], "nextCursor": null },
"servers": { "items": [], "nextCursor": null },
"gallery": { "items": [], "nextCursor": null }
}Every group is the same items and nextCursor shape used everywhere else, so one list renderer covers all three. Narrow with type=players,servers,gallery; all three are searched by default.
The Skin Gallery
Skins people uploaded themselves, as opposed to ones our collectors found. /v1/gallery lists them and /v1/gallery/{id} is one entry. Filter with tag, model=wide|slim, sort=popular|newest|downloads and period=day|week|month|all.
The default sort is popular, which ranks by likes gained inside the period rather than lifetime likes. On a quiet period that can legitimately come back empty. Use sort=newest if you want everything.
Discovery Feeds
- /v1/players/recent - players we saw for the first time recently
- /v1/players/recent-names - gamertag changes, newest first
- /v1/skins/trending - skins gaining wearers fastest, with window=24h|7d|30d and kind
Only confirmed name changes reach the recent-names feed, never corroborated or single_source. It is the most visible surface we have, and an unverified report should not be able to publish an arbitrary name against an arbitrary player on our front page.
Trending ranks by wearers gained inside the window rather than by total wearers, because sorting by total shows the same handful of default skins every day forever.
Players We Have Never Seen
There is no endpoint that goes and finds one for you. A profile exists because a server reported the player or one of our collectors saw them, and if neither has happened yet there is nothing to return.
So a lookup that comes back empty is an honest answer rather than a gap to route around. Do not poll it waiting for the player to appear. If you run the server they play on, reporting is the way to put them here, and it is covered separately.
When Something Goes Wrong
Every failure is the same envelope, whatever produced it:
{
"error": "player_not_found",
"message": "no player with xuid 2535000000000000",
"requestId": "req_b7c557a6e66e4a21",
"fields": { "limit": "must be between 1 and 100" }
}fields only appears on a validation failure and names what was wrong with each one. requestId is also on every response as the X-Request-Id header, including successful ones. Quote it if you report a problem and we can find the exact request.
The codes worth handling by name:
- 400 invalid_request - a bad limit, a malformed cursor, or no identifiers at all
- 400 too_many_ids - more than 100 in one batch
- 404 player_not_found, skin_not_found, server_not_found - no such thing
- 410 player_removed - erased at the player's request, and permanent
- 409 persona_has_no_texture - a persona has no image to serve
- 429 rate_limited - the message says how long to wait
- 503 upstream_unavailable - something we depend on is not answering. Retry shortly
410 Is Not 404
A player who asked to be removed answers 410 Gone, permanently, whatever a server later reports about them. That is deliberately not a 404: a 404 invites a retry and a 410 tells you to stop asking.
If you cache profiles, treat a 410 as an instruction to drop what you held rather than keep serving the last copy you saw. The suppression is stored as a one-way fingerprint of the xuid, so it can answer whether a given player asked to be left alone without being readable as a list of who did.
Caching
Every read sends a Cache-Control that says what it means. Honour it rather than working around it, because that caching is the reason these endpoints are free:
- Profiles - 60 seconds
- History and lists - an hour
- Textures - a year, immutable, with an ETag
- A 404 - 60 seconds, because a skin hash often arrives before its bytes
- A 410 - a day, because it is never coming back
Cross-Origin Requests Are Fine
The public reads send a wildcard CORS header, so a page on your own domain can fetch them straight from the browser with no proxy in between.
Be Reasonable
There is no per-caller quota on the public reads today, and keeping it that way depends on the traffic staying sensible.
Cache what you fetch, do not poll a profile that changes a few times a year, and do not try to walk the whole player table. A tool that behaves keeps this open for everybody else.
Key on the XUID
Every player-shaped thing here is addressed by Xbox ID, and anything you store should be too. A gamertag changes when somebody renames, and a tool keyed on the name stops matching quietly: nothing errors, nothing 404s, and you find out weeks later when somebody complains.
Store it as a string. It is an identifier rather than a quantity, you will never do arithmetic on it, and plenty of languages will hand you a 32 bit integer if you let them.
The Whole Thing, Machine Readable
This page covers the reads. The complete surface, including the write endpoints, is generated from the routes themselves and cannot drift from what the server actually does:
curl -s https://api.lecternmc.com/v1/openapi.jsonIf this article and that document ever disagree, the document is right and we would like to know.
Did this answer your question?