For plugin and mod developers
One POST and your server is wrapped
Send us the numbers your server already counts and every player gets a year in review page with a share card. It is one HTTP request with a bearer key and a list of players - no SDK, no handshake, no webhook to receive. Have your plugin run it once a week and the page keeps itself up to date.
Quick start
Three steps, and the first two are done once. If you just want to see your own server wrapped without writing any code, you do not need this page at all - upload the stats folder instead and skip the API.
Add your server on the panel and press New key.
It starts wgg_live_ and is shown once.
A list of UUIDs, names and stat counts as JSON, to
/v1/stats.
The response hands you the page URL. Publish it on the panel and post it.
The whole thing, in one command:
curl -X POST https://wrapped.gg/v1/stats \
-H "Authorization: Bearer wgg_live_…" \
-H "Content-Type: application/json" \
-d '{
"players": [
{
"uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",
"name": "Notch",
"stats": { "custom/play_time": 7200000, "mined/stone": 5000 }
}
]
}'
201 Created
{
"snapshot_id": "m9x2k4…",
"state": "queued",
"players": 1,
"stats": 2,
"url": "https://wrapped.gg/your-server-k7m2p",
"poll": "/v1/snapshots/m9x2k4…"
}
The build runs in the background and takes a few seconds. Nothing is public until you press Publish on the panel, so you can send test data and look at it first.
Getting a key
Keys belong to one server and do one thing: upload stats to it. They cannot read player data, rename anything, publish, or delete. That means it is safe to put one in a plugin config on a machine you do not fully control - the worst a stolen key does is overwrite the numbers, and you can revoke it on the panel and issue another in a few seconds.
Ask for it in your plugin's config rather than shipping one, and let people check it works at startup:
GET /v1/stats
Authorization: Bearer wgg_live_…
200 OK
{
"ok": true,
"server": {
"slug": "your-server-k7m2p",
"name": "Bob's SMP",
"url": "https://wrapped.gg/your-server-k7m2p",
"published": true,
"players": 42,
"last_upload_at": 1756512000
},
"limits": { "uploads_per_day": 6, "max_players": 100000, "max_bytes": 67108864 }
}
A wrong or revoked key answers 401 here exactly as it does on the
upload, so one call at startup tells your users their config is right while
they are still looking at the console.
What to send
A players list. Each entry needs a uuid and some
stats; the name is optional but worth sending, because without it
we have to go and look the name up.
{
"source": "my-plugin", optional, tags the upload as yours
"players": [
{
"uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",
"name": "Notch",
"stats": { "custom/play_time": 7200000, "custom/deaths": 3 }
}
]
}
Dashes in the UUID are optional and case does not matter. Counts must be whole numbers; zeros are dropped for you, so there is no need to filter them out first. If you send the same UUID twice, the first one wins.
Shapes we also accept
All four of these mean the same thing. Send whichever falls out of your code most naturally rather than reshaping it to match ours.
A vanilla stats file, pasted in whole -
the exact contents of world/stats/<uuid>.json, prefixes and all:
{ "uuid": "069a79f4-…", "name": "Notch", "stats": {
"DataVersion": 3953,
"stats": {
"minecraft:custom": { "minecraft:play_time": 7200000 },
"minecraft:mined": { "minecraft:stone": 5000 }
}
} }
Categories as nested objects, without the prefix:
{ "uuid": "069a79f4-…", "stats": {
"custom": { "play_time": 7200000 },
"mined": { "stone": 5000 }
} }
A map keyed by UUID, if that is how you hold players:
{ "players": {
"069a79f4-44e9-4726-a5be-fca90e38aaf5": { "name": "Notch", "stats": { "custom/deaths": 3 } }
} }
NDJSON, one player per line, for servers with
thousands of players. Set Content-Type: application/x-ndjson:
{"uuid":"069a79f4-…","name":"Notch","stats":{"custom/deaths":3}}
{"uuid":"853c80ef-…","name":"Steve","stats":{"custom/deaths":9}}
Either format may be gzipped - send the gzip as the raw body and leave
Content-Encoding off, or the edge will decompress it and the
upload will hang against a Content-Length that no longer fits.
Stat names
A stat name is category/key, using the names Minecraft itself
uses. The minecraft: prefix is stripped wherever it appears, so
minecraft:mined/minecraft:stone and mined/stone are
the same stat. A bare name with no slash is read as a
custom/ stat, since that is where most of the interesting ones
live.
| Category | Holds | Example |
|---|---|---|
custom | Playtime, deaths, jumps, distances | custom/play_time |
mined | Blocks broken, by block | mined/diamond_ore |
killed | Mobs killed, by mob | killed/creeper |
killed_by | What killed the player | killed_by/creeper |
crafted | Items crafted | crafted/torch |
used | Items used or placed | used/water_bucket |
picked_up | Items picked up | picked_up/diamond |
dropped | Items dropped | dropped/cobblestone |
broken | Tools worn out | broken/iron_pickaxe |
Playtime is in ticks, like the file it comes from: 20 ticks a second, so an hour is 72000. Distances are in centimetres. Send more than you think matters - awards are worked out from whatever arrives, and a stat nobody sends is a board nobody appears on.
Code examples
The shortest path for a plugin is to read the stats files the server has
already written rather than tracking anything yourself. They are in
<world>/stats/, one <uuid>.json per
player, and you can forward them untouched.
import com.google.gson.*;
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
// Reads world/stats/*.json and posts the lot. Runs off the main thread.
public void upload(Path world, String key) throws Exception {
JsonArray players = new JsonArray();
try (var files = Files.list(world.resolve("stats"))) {
for (Path f : files.filter(p -> p.toString().endsWith(".json")).toList()) {
String uuid = f.getFileName().toString().replace(".json", "");
JsonObject p = new JsonObject();
p.addProperty("uuid", uuid);
p.addProperty("name", Bukkit.getOfflinePlayer(java.util.UUID.fromString(uuid)).getName());
p.add("stats", JsonParser.parseString(Files.readString(f)));
players.add(p);
}
}
JsonObject body = new JsonObject();
body.addProperty("source", "my-plugin");
body.add("players", players);
HttpRequest req = HttpRequest.newBuilder(URI.create("https://wrapped.gg/v1/stats"))
.header("Authorization", "Bearer " + key)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 300) getLogger().warning("wrapped.gg: " + res.body());
else getLogger().info("wrapped.gg: uploaded");
}
import json, pathlib, requests
def upload(world: str, key: str):
players = []
for f in pathlib.Path(world, "stats").glob("*.json"):
players.append({"uuid": f.stem, "stats": json.loads(f.read_text())})
r = requests.post(
"https://wrapped.gg/v1/stats",
headers={"Authorization": f"Bearer {key}"},
json={"source": "my-script", "players": players},
timeout=120,
)
r.raise_for_status()
print("wrapped:", r.json()["url"])
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
export async function upload(world, key) {
const dir = join(world, 'stats');
const players = [];
for (const f of await readdir(dir)) {
if (!f.endsWith('.json')) continue;
players.push({
uuid: f.slice(0, -5),
stats: JSON.parse(await readFile(join(dir, f), 'utf8')),
});
}
const res = await fetch('https://wrapped.gg/v1/stats', {
method: 'POST',
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: JSON.stringify({ source: 'my-script', players }),
});
const out = await res.json();
if (!res.ok) throw new Error(`${out.error}: ${out.message}`);
console.log('wrapped:', out.url);
}
Do the upload off the main thread. On Paper that is
getServer().getAsyncScheduler(); on Spigot,
runTaskAsynchronously. Reading a few thousand small files and
waiting on a network round trip is not something to do on a tick.
Updating it every week
Stats are running totals, not events. Every upload replaces the last one rather than adding to it, so there is no state to keep, no cursor to store, and nothing to reconcile if a week goes missing. That makes a repeating task the whole feature:
long week = TimeUnit.DAYS.toSeconds(7);
getServer().getAsyncScheduler().runAtFixedRate(this,
task -> upload(world, key), /* first run after a minute */ 60, week, TimeUnit.SECONDS);
Send an Idempotency-Key if a retry might duplicate the call - the
same key returns the first answer instead of building again. Something stable
per attempt works well, like the ISO week:
Idempotency-Key: 2026-W35
Weekly is the cadence we would pick. Daily is fine and well inside the limit;
hourly is not, and will start collecting 429s. Stagger the hour a
little if your plugin runs on many servers, so they do not all arrive at
midnight.
Reference
| Header | Value |
|---|---|
Authorization required |
Bearer wgg_live_… |
Content-Type |
application/json, or application/x-ndjson for line-per-player |
Idempotency-Key |
Repeat of the same value returns the first answer |
X-Wrapped-Source |
Names the uploader, e.g. your plugin. Wins over source in the body |
queued, building, ready, failed.
You do not have to poll. The build finishes on its own and the page updates itself; polling is only worth it if you want to log the outcome or show it in a panel. If you do, wait a few seconds between tries - a build is seconds, not milliseconds.
Errors
Every failure is JSON with an error code and a
message written for a person. Log the message rather than the
code: it says what to change.
{ "error": "bad_uuid",
"message": "player 2 has no usable \"uuid\". It needs a 32-character
Minecraft UUID, with or without dashes." }
| Code | Status | What went wrong |
|---|---|---|
unauthorized | 401 | Key is wrong, revoked, or the header is not Bearer … |
rate_limited | 429 | More than six uploads today. Try tomorrow |
bad_json | 400 | Body is not valid JSON, or was cut off in transit |
no_players | 400 | No players list, or it was empty |
bad_uuid | 400 | A player has no usable UUID. The message names which |
bad_player | 400 | An entry in players is not an object |
payload_too_large | 413 | Over 64 MB. Send NDJSON, or gzip it |
too_many_players | 400 | Over 100,000 players in one upload |
too_many_counters | 400 | One player carries over 8,000 stats |
player_too_large | 400 | One player is over 1 MB of JSON |
storage_failed | 502 | Our end. Retry in a minute |
Limits
| Limit | Value | Why |
|---|---|---|
| Uploads | 6 a day, per server | It is a year in review. Weekly is plenty |
| Body | 64 MB | Gzip or NDJSON if you are near it |
| Players | 100,000 per upload | Larger than any server we have seen |
| Stats per player | 8,000 | Vanilla writes a few hundred |
Keys are per server. If you are a hosting provider creating servers on behalf of customers, you want the partner API instead - it mints these keys for you.
FAQ
Do I need a plugin to use wrapped.gg?
No. Uploading the stats folder from the panel is the normal way in, and it needs nothing running on the server. This API exists for people who would rather their plugin or mod send the numbers on a schedule than do it by hand.
What stat names does the API accept?
The same ones Minecraft writes. Send category/key pairs like custom/play_time and mined/stone, or paste a whole vanilla stats file in unchanged. The minecraft: prefix is stripped for you either way, so both minecraft:custom and custom work.
How often should my plugin upload?
Once a week is the sweet spot, and the cap is six uploads a day. Stats are cumulative totals rather than events, so an upload replaces the last one instead of adding to it - missing a week costs you nothing.
Does it work with Bedrock and Geyser players?
Yes. Send the Floodgate UUID as it is and the gamertag as the name. Floodgate UUIDs have sixteen leading zeros, and we look up gamertags from the Geyser Global API when you do not send a name.
What happens if my plugin sends the same data twice?
Set an Idempotency-Key header and a repeat of the same upload returns the first answer instead of building again. Without one, a second upload is treated as a fresh snapshot and simply replaces the page.
Is the API free?
Yes, free and with no revenue share. There is no paid tier and no key quota beyond the six uploads a day, which is far more than a yearly product needs.
Built something?
Tell us in Discord and we will link it here, so people looking for a plugin find yours instead of writing their own.
Come say hello