wrapped.gg
Get a key

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.

1. Make a key

Add your server on the panel and press New key. It starts wgg_live_ and is shown once.

2. Post your players

A list of UUIDs, names and stat counts as JSON, to /v1/stats.

3. Share the link

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.

CategoryHoldsExample
customPlaytime, deaths, jumps, distancescustom/play_time
minedBlocks broken, by blockmined/diamond_ore
killedMobs killed, by mobkilled/creeper
killed_byWhat killed the playerkilled_by/creeper
craftedItems craftedcrafted/torch
usedItems used or placedused/water_bucket
picked_upItems picked uppicked_up/diamond
droppedItems droppeddropped/cobblestone
brokenTools worn outbroken/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");
}

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

POST/v1/stats Send stats. JSON or NDJSON, gzipped or not.
HeaderValue
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
GET/v1/stats Check a key and see which server it belongs to. Call it at startup.
GET/v1/snapshots/<snapshot_id> Whether the build finished. States are 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." }
CodeStatusWhat went wrong
unauthorized401Key is wrong, revoked, or the header is not Bearer …
rate_limited429More than six uploads today. Try tomorrow
bad_json400Body is not valid JSON, or was cut off in transit
no_players400No players list, or it was empty
bad_uuid400A player has no usable UUID. The message names which
bad_player400An entry in players is not an object
payload_too_large413Over 64 MB. Send NDJSON, or gzip it
too_many_players400Over 100,000 players in one upload
too_many_counters400One player carries over 8,000 stats
player_too_large400One player is over 1 MB of JSON
storage_failed502Our end. Retry in a minute

Limits

LimitValueWhy
Uploads6 a day, per serverIt is a year in review. Weekly is plenty
Body64 MBGzip or NDJSON if you are near it
Players100,000 per uploadLarger than any server we have seen
Stats per player8,000Vanilla 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