API reference

OpenThe API answers at https://api.donutapi.app, and you do not need a key to try it. Free keys are in the dashboard. Paid plans cannot be bought yet.
ContextDonutSMP disabled its official API in 2026. Donut API is an independent, unofficial replacement. What happened to the official API?

Building with an AI agent?

Copy a prompt that describes every route, its parameters and its response format, ready to paste into Claude Code, Cursor or ChatGPT.

Open in Claude Open in ChatGPT llms.txt

Overview

The base URL is https://api.donutapi.app. The data routes are read only GET requests that return JSON, and they can be called from a browser (CORS is open). Account linking and payments are the exceptions: they use POST and need a key.

Data comes from bots in the game that read the auction house and the buy orders every few minutes. Items are identified by their Minecraft ids, such as diamond or netherite_ingot. Fields named at are Unix time in milliseconds.

Auction house prices come from two sources. The bots search the most traded items one by one in the game, which gives their real lowest price. They also sweep the auction house page by page, cheapest first, up to a page cap: that sweep is partial, so an item that only has expensive listings can be missing from it. Orders snapshots are partial in the same way. Each response carries a snapshot object that says when the data was read, and snapshot.complete is false when a page cap was reached.

curl first call
curl "https://api.donutapi.app/v1/ah/items"

What happened to the official DonutSMP API?

DonutSMP used to give API keys to its players through the in-game /api command. In 2026 that API was restricted, then disabled.

WhenWhat
July 3, 2026DonutSMP staff announced in their updates channel that keys were now limited to ranked players: "For abuse & safety reasons from now on API keys are only available to users with a rank, if you are a normal player and you wish to get an API key you will need to apply for it in the support website in "Other", you will need to finish your project fully and showcase it before requesting access."
September 29, 2026DonutSMP staff member archivePedro wrote on the official Discord: "The API has been fully disabled." On Reddit he added that it was removed entirely for abuse and safety reasons.
November 2026In the game, /api answers that the API has been disabled.

Source: archivePedro's comment on r/DonutSMP, in the thread "/api command doesn't work", and the same statements on the official DonutSMP Discord.

Some community guides, wikis and mods still tell players to run /api to get a key. That no longer works.

Donut API is an independent, unofficial replacement. It provides the same kind of data by reading it through bots in the game, and it is not affiliated with or endorsed by DonutSMP.

Keys and limits

Without a key, the API answers in keyless mode, limited per IP address: 10 calls per minute, 3,000 calls per month and 24 hours of history.

With a key, send it in one of these two headers. Keys start with dk_. You get a free one in the dashboard, by signing in with your Minecraft account. If you gave an email address when you signed in, you can come back later with a 6 digit code sent to that address. Email does not create an account. The full key is shown once, when it is created. Paid plans cannot be bought yet: they are on the pricing page.

curl with a key
curl "https://api.donutapi.app/v1/ah/items" \
  -H "Authorization: Bearer dk_your_key"

curl "https://api.donutapi.app/v1/ah/items" \
  -H "x-api-key: dk_your_key"

Every counted response tells you where you stand. /v1/status is never counted, and neither are 404 and 202 answers.

HeaderMeaning
x-ratelimit-plankeyless, free, developer or pro.
x-ratelimit-limit-minuteCalls allowed per minute.
x-ratelimit-remaining-minuteCalls left in the current minute.
x-ratelimit-limit-monthCalls allowed per month.
x-ratelimit-remaining-monthCalls left this month.

Errors

Errors are JSON objects with an error field.

StatusBody and meaning
401{"error":"invalid or revoked API key"}
404{"error":"not found"}
429{"error":"rate limit: too many calls this minute"}, with a retry-after header that says how many seconds to wait.
429{"error":"monthly quota reached"}
502{"error":"origin unreachable"}. The data server behind the API did not answer.

Status

GET/v1/status

Tells you when the latest auction house and orders snapshots were taken, how large they were, the state of each bot, and whether the DonutSMP servers answer. Use it to check that the data is fresh. This route is not counted against your limits.

FieldDescription
ah.ts, orders.tsWhen the snapshot was taken, as Unix time in milliseconds.
pagesNumber of pages read.
lignesNumber of rows read.
complet1 when every page was read, 0 when the page cap was reached.
bots[].nameName of the bot.
bots[].onlineWhether the bot is in the game right now. A bot that is reconnecting is not online.
bots[].stateonline, or why the bot is not in the game: throttled (the game ignores its commands, it rests), reconnecting, queued (its game server is restarting and DonutSMP put it in the restart queue), maintenance, already_online, rate_limited, needs_login, offline, and the serious ones: banned, banned_temporary, blocked and auth_failed.
bots[].sinceWhen the current block started, as Unix time in milliseconds. Left out when there is none.
bots[].last_seenWhen the bot was last seen, as Unix time in milliseconds.
bots[].taskWhat the bot is doing: ah, orders or leaderboard during a snapshot, otherwise chat or player. null when the bot is offline.
bots[].uptime_24hShare of the last 24 hours the bot spent in the game, from 0 to 1. null until a few minutes of history exist. The log of bot states is recent, so it covers less than 24 hours at first.
bots_summaryThe bots as one figure: state is operational (all in the game), degraded (at least one missing) or major_outage (none in the game), online and total count the bots, percent_online is the share in the game right now, blocked counts the bots that are banned, blocked, refused at sign-in or waiting for a new sign-in, and uptime_24h works like the one of each bot.
donutsmp[]The DonutSMP server addresses, pinged once a minute from a server in Germany: id, name, host, online, players_online, players_max, latency_ms and checked_at. When a server is offline, online is false and the entry carries error and down_since. online is null until the first ping after a restart. The addresses lead to the same network and report the same player count, so do not add them up.
trafficHow busy the API is: level is normal or high, requests_per_minute is the average over the last 5 minutes, usual_per_minute is the usual median (null until 30 minutes of history exist) and player_queue is the number of player lookups waiting. When level is high, player lookups may take longer.
nowServer time, as Unix time in milliseconds.
curl request
curl "https://api.donutapi.app/v1/status"
Response (real data, trimmed to two bots)
200 OK application/json
{
  "ah": { "id": 86, "type": "ah", "ts": 1794398309676,
    "pages": 80, "lignes": 3600, "complet": 0, "bot": "bot2" },
  "orders": { "id": 87, "type": "ordres", "ts": 1794398622280,
    "pages": 40, "lignes": 1800, "complet": 0, "bot": "bot1" },
  "bots": [
    { "name": "bot1", "online": true, "state": "online",
      "last_seen": 1794398705049, "task": "orders", "uptime_24h": null },
    { "name": "bot2", "online": true, "state": "online",
      "last_seen": 1794398705189, "task": "ah", "uptime_24h": null }
  ],
  "bots_summary": { "state": "operational", "online": 5, "total": 5,
    "percent_online": 100, "blocked": 0, "uptime_24h": null },
  "donutsmp": [
    { "id": "main", "name": "DonutSMP", "host": "java.donutsmp.net",
      "online": true, "players_online": 12777, "players_max": 50000,
      "latency_ms": 5, "checked_at": 1794398704774 },
    { "id": "eu", "name": "DonutSMP Europe", "host": "eujava.donutsmp.net",
      "online": true, "players_online": 12777, "players_max": 50000,
      "latency_ms": 4, "checked_at": 1794398704780 }
  ],
  "traffic": { "level": "normal", "requests_per_minute": 0,
    "usual_per_minute": null, "player_queue": 0 },
  "now": 1794398726750
}

The field names of this route are internal and may be renamed. A live view of it is on the status page.

Server history

GET/v1/status/history?hours=<n>

How the DonutSMP addresses we ping behaved over a period: players online, latency, share of pings answered, and the outages we saw. The period is cut into equal slices, 300 at most, and each slice gives one point. The answer is cached for 30 seconds and is never counted in your quota.

We only know what our pings saw. A slice with no ping at all has no point, so a hole in points means our API was restarting or the history had not started yet, not that the server was offline. The history starts at logging_since and pings are kept for 31 days. Latency is measured from our server in Germany.

ParameterDescription
hoursHow many hours back to look, from 1 to 720.
FieldDescription
from, toStart and end of the period, as Unix time in milliseconds.
step_minutesLength of a slice in minutes: 5 for 24 hours, 34 for 7 days, 144 for 30 days.
logging_sinceWhen the history started, as Unix time in milliseconds.
servers[].id, name, hostThe address pinged, the same entries as donutsmp in /v1/status.
servers[].uptimeShare of pings answered in the period, from 0 to 1. null when there was no ping in the period.
servers[].points[]One point per slice, oldest first. at is the start of the slice. online is true when at least half of the pings in the slice were answered. players and latency_ms are null when none was.
servers[].outages[]Outages in the period, most recent first. to is null while the outage is still going on. error is the reason the ping failed.
curl request
curl "https://api.donutapi.app/v1/status/history?hours=24"
Response (real data, trimmed to one server, taken minutes after the history started)
200 OK application/json
{
  "from": 1794313227984,
  "to": 1794399627984,
  "step_minutes": 5,
  "logging_since": 1794399485265,
  "servers": [
    { "id": "main", "name": "DonutSMP", "host": "java.donutsmp.net",
      "uptime": 1,
      "points": [
        { "at": 1794399300000, "players": 13228, "latency_ms": 4, "online": true },
        { "at": 1794399600000, "players": 13292, "latency_ms": 5, "online": true }
      ],
      "outages": [] }
  ]
}

Auction house offers

GET/v1/ah?item=<id>&limit=<n>

Offers for one item, cheapest first. With item, the offers come from a search of that item in the game, so the first one is the real lowest price on the market. A search returns 45 offers at most.

The most traded items (ores, netherite, tridents, maces, totems, common blocks and more) are searched all the time: they answer at once from a cache, with data that is at most 20 minutes old. Any other item is searched in the game when you ask for it, which takes 4 to 9 seconds, and the answer is then cached for 5 minutes.

If the id is unknown or the search finds nothing, the route falls back to the page by page sweep: snapshot then has no source field, and listings can be empty.

Known limitThe search of the game also returns shulker boxes and books that contain the word. For elytra they drown the real offers, so there is no elytra price for now.
ParameterDescription
itemMinecraft item id, for example netherite_ingot.
limitMaximum number of offers to return.
FieldDescription
itemItem id.
nameDisplay name of the item.
countNumber of items in the offer.
priceTotal price of the offer.
unit_pricePrice per single item.
snapshot.atWhen the data was read, as Unix time in milliseconds.
snapshot.sourcesearch when the offers come from a search in the game. Left out for the sweep.
snapshot.completeWhether everything was read. Always true for a search.
snapshot.pagesNumber of pages read. Only for the sweep.
curl request
curl "https://api.donutapi.app/v1/ah?item=netherite_ingot&limit=2"
Response (real data)
200 OK application/json
{
  "snapshot": { "at": 1794395033684, "source": "search", "complete": true },
  "listings": [
    { "item": "netherite_ingot", "name": "Netherite Ingot", "count": 1,
      "price": 23200000, "unit_price": 23200000 },
    { "item": "netherite_ingot", "name": "Netherite Ingot", "count": 1,
      "price": 23300000, "unit_price": 23300000 }
  ]
}

Auction house items

GET/v1/ah/items

One summary line per item: how many listings it has, the lowest unit price, and the total number of items on sale. Items are sorted by name.

An item with a priced_at field was priced by a search in the game: its lowest_unit_price is the real lowest price on the market, and its counts stop at 45, the size of a search. The other items come from the page by page sweep, which is partial, so their lowest price is less certain.

FieldDescription
itemItem id.
listingsNumber of offers.
lowest_unit_priceCheapest price per single item.
total_countNumber of items on sale.
priced_atWhen the item was last searched in the game, as Unix time in milliseconds. Left out for items that only come from the sweep.
curl request
curl "https://api.donutapi.app/v1/ah/items"
Response (real data, trimmed to two items)
200 OK application/json
{
  "snapshot": { "at": 1794394483102, "pages": 80, "complete": false },
  "items": [
    { "item": "acacia_button", "listings": 2,
      "lowest_unit_price": 5900, "total_count": 2 },
    { "item": "ancient_debris", "listings": 45,
      "lowest_unit_price": 9100000, "total_count": 45,
      "priced_at": 1794394584831 }
  ]
}

Buy orders

GET/v1/orders?item=<id>&limit=<n>

Open buy orders from the latest orders snapshot.

FieldDescription
itemItem id.
nameDisplay name of the item.
price_eachPrice offered per item.
deliveredHow many items were already delivered.
wantedHow many items the order asks for.
curl request
curl "https://api.donutapi.app/v1/orders?limit=2"
Response (real data)
200 OK application/json
{
  "snapshot": { "at": 1794387262306, "pages": 40, "complete": false },
  "orders": [
    { "item": "gold_block", "name": "Blocks of Gold",
      "price_each": 37000, "delivered": 744000, "wanted": 1700000 },
    { "item": "pumpkin", "name": "Pumpkins",
      "price_each": 72.52, "delivered": 328000000, "wanted": 400000000 }
  ]
}

History

GET/v1/history?item=<id>&type=ah|orders&hours=<n>

Points from past snapshots for one item, ready to chart. Each point has the time of the snapshot, the price and the number of listings. A snapshot in which the item did not appear gives no point. An item that is searched in the game gets a point at every search.

ParameterDescription
itemMinecraft item id.
typeah for the auction house or orders for buy orders.
hoursHow many hours back to look. Your plan sets the maximum: 24 hours without a key.
curl request
curl "https://api.donutapi.app/v1/history?item=ender_chest&type=ah&hours=24"
Response (real data)
200 OK application/json
{
  "item": "ender_chest",
  "type": "ah",
  "points": [
    { "at": 1794383619041, "price": 18500, "listings": 201 },
    { "at": 1794387666192, "price": 13800, "listings": 1 }
  ]
}

Public chat

GET/v1/chat?limit=<n>&after=<id>&q=<words>

The public chat of the server, as the bots see it. Player names are never exposed: author is an anonymous id that stays the same for a given player. Messages are kept for 30 days.

Without after, you get the latest messages, oldest first. To follow the chat, call again with after set to the latest_id you received: you only get newer messages. One call every 10 to 15 seconds is enough.

To search, pass q: you get the latest messages that contain every word, oldest first, from the whole history that is kept. The search ignores case and matches the start of words, so raid finds "raid" and "raids" but not "unraid". Use up to 6 words of 2 to 32 characters. The answer repeats the words that were searched in a q field.

The text is written by players. Show it as plain text and never as HTML.

ParameterDescription
limitNumber of messages to return. 50 by default, 200 at most.
afterOnly return messages with an id above this one.
qWords to search for, separated by spaces. Example: /v1/chat?q=base+raid.
FieldDescription
idMessage id, increasing.
atWhen the message was seen, as Unix time in milliseconds.
authorAnonymous id of the player. Never the player name.
textThe message, as written by the player.
latest_idId of the newest message. Pass it as after on your next call.
curl request
curl "https://api.donutapi.app/v1/chat?limit=2"
Response (real data)
200 OK application/json
{
  "messages": [
    { "id": 692, "at": 1794388047324, "author": "u_83666249ba",
      "text": "i have a strategy to get many free glided blackstone come if you want" },
    { "id": 693, "at": 1794388047627, "author": "u_2044581161",
      "text": "TPA" }
  ],
  "latest_id": 693
}

Daily chat stats

GET/v1/chat/daily?day=<YYYY-MM-DD>

Stats on one day of public chat: how many messages, the most said words, the most active chatter and the busiest hour. Without day you get the latest complete day. While no day is complete yet, you get the current day with partial set to true, recomputed every hour. A day with no data answers 404.

A word is counted once per message, and common words and insults are left out. The most active chatter is an anonymous id, the same as in /v1/chat, never a player name. Every field except day can be missing, so read them as optional.

FieldDescription
dayThe day, as YYYY-MM-DD in UTC.
partialtrue when the day is not over yet. Left out for a complete day.
generated_atWhen the stats were computed, as Unix time in milliseconds.
messagesNumber of messages that day.
authorsNumber of distinct authors that day.
top_wordsUp to 10 words with their count, most said first.
top_chatterThe author who sent the most messages, and how many. head is the address of a 64 by 64 PNG of that player's head, served without the player name. It is left out when the head could not be found.
busiest_hourThe hour with the most messages, from 0 to 23 in UTC, and how many.
topicsUp to 5 topics with a label and a share between 0 and 1. Both are estimates made by an AI model, not a count.
summary, summary_modelA short text summary of the day written by an AI model, and the id of that model. It can be wrong.
curl request
curl "https://api.donutapi.app/v1/chat/daily"
Response (real data, top words and topics trimmed)
200 OK application/json
{
  "day": "2026-11-11",
  "messages": 31727,
  "authors": 2575,
  "top_words": [
    { "word": "tpa", "count": 9340 },
    { "word": "money", "count": 4460 },
    { "word": "loot", "count": 4431 }
  ],
  "top_chatter": { "author": "u_5192bf6769", "messages": 2014,
    "head": "https://api.donutapi.app/v1/chat/daily/head?day=2026-11-11" },
  "busiest_hour": { "hour": 10, "messages": 14928 },
  "topics": [
    { "label": "Spam and Scam Offers", "share": 0.3 },
    { "label": "Pvp Duels Requests", "share": 0.25 }
  ],
  "summary": "Chat was dominated by teleport requests (tpa/tp) for PvP duels, loot drops, and base visits, along with a large volume of spam and scam attempts such as money-doubling offers, giveaways, and mystery box sales that should be treated with caution. Players also traded and asked about netherite gear, elytra, and spawners, while many messages were repetitive or meaningless. Overall the server saw very high activity with a peak around 10:00 UTC.",
  "summary_model": "claude-haiku-5-5",
  "generated_at": 1794396599940,
  "partial": true
}

Money leaderboard Beta

GET/v1/leaderboard/money?limit=<n>

The richest players on the server, ranked by balance, from the in-game leaderboard. Amounts are rounded by the game to two or three significant digits.

ParameterDescription
limitNumber of players to return, 200 at most.
FieldDescription
rankPosition in the leaderboard, starting at 1.
playerPlayer name.
moneyBalance, rounded as in the game.
snapshot.atWhen the snapshot was taken, as Unix time in milliseconds.
curl request
curl "https://api.donutapi.app/v1/leaderboard/money?limit=2"
Response (real data)
200 OK application/json
{
  "snapshot": { "at": 1794387284205 },
  "players": [
    { "rank": 1, "player": "Abuodee", "money": 5900000000000 },
    { "rank": 2, "player": "Wampiptiii01", "money": 3700000000000 }
  ]
}

Player stats Beta

GET/v1/player/<name>

Stats of one player, read from the player's stats page in the game and rounded the same way the game shows them. The name is a Java name, or a Bedrock name with its leading dot, 2 to 16 characters.

Answers come from a 10 minute cache. When a player is not in the cache, a bot asks the game, which takes two to six seconds. If the game does not answer in time, the route answers 202 and you retry after the delay it gives. A 202 answer is not counted against your limits. Lookups are served one at a time by each bot, so do not start more than two or three uncached lookups at once.

FieldDescription
playerPlayer name.
atWhen the stats were read, as Unix time in milliseconds.
cachedWhether the answer came from the cache.
stats.moneyBalance, rounded as in the game.
stats.killsNumber of kills.
stats.deathsNumber of deaths.
stats.playtimeTime played, as a string such as 180d 12h.
stats.shardsNumber of shards. Can be missing.
stats.blocks_placedBlocks placed. Can be missing.
stats.blocks_brokenBlocks broken. Can be missing.
stats.mobs_killedMobs killed. Can be missing.

Numbers are rounded by the game to two or three significant digits. The last four fields come from the full profile: when the game only returns the short one, they are left out, so treat them as optional.

curl request
curl "https://api.donutapi.app/v1/player/Abuodee"
Response (real data)
200 OK application/json
{
  "player": "Abuodee",
  "at": 1794389609645,
  "cached": false,
  "stats": {
    "money": 5900000000000,
    "kills": 8000,
    "deaths": 1500,
    "playtime": "180d 12h",
    "shards": 129000,
    "blocks_placed": 509000,
    "blocks_broken": 221000,
    "mobs_killed": 52500
  }
}
StatusMeaning
200Stats found.
202The game did not answer within 15 seconds. Body: {"status":"pending","retry_in_seconds":5}. Retry after that delay. Not counted against your limits.
400Invalid player name.
404The game says this player does not exist. Answers in about a second.
503Too many lookups waiting. Try again later.

Staff Beta

GET/v1/staff
GET/v1/staff/online

The DonutSMP staff members our bots have seen in the player list of the game, where staff carry a marker in front of their name. The bots only read that list: they send no command and no message. It is refreshed every 30 seconds.

/v1/staff lists every staff member the bots have met so far, most recently seen first, and says who is online now. The list fills up as staff members connect. /v1/staff/online only returns the ones seen online right now.

Read it as "staff seen online", not as "all staff online". A staff member who is hidden does not appear, so a name that is missing or marked offline does not prove that person is offline. One call per minute is enough.

FieldDescription
staffAlways an array, empty when there is nobody to list.
staff[].playerPlayer name of the staff member.
staff[].uuidMinecraft account id of the member, which stays the same when the name changes. Only in /v1/staff.
staff[].previous_nameThe former name, after the member changed name. Left out otherwise. Only in /v1/staff.
staff[].roleModerator or Developer, read from the colour of the staff marker. Left out when the role could not be determined.
staff[].spectatortrue when the member is online in spectator mode, read from the game mode in the player list. Left out otherwise, never false.
staff[].onlineWhether the bots see this member right now. Only in /v1/staff.
staff[].last_seenWhen the member was last seen, as Unix time in milliseconds. Only in /v1/staff.
staff[].first_seenWhen the bots first saw the member, as Unix time in milliseconds. Only in /v1/staff.
snapshot.atWhen the list was read, as Unix time in milliseconds. Only in /v1/staff/online, and snapshot is null when no bot is in the game.
curl request
curl "https://api.donutapi.app/v1/staff"
Response (real data)
200 OK application/json
{
  "staff": [
    { "player": "Napooo_", "uuid": "34ebe03c-c52a-4178-be69-b2d954ea6faf",
      "role": "Moderator", "last_seen": 1794396103768,
      "first_seen": 1794390567159, "online": true },
    { "player": "BobIsFound", "uuid": "b5e695a7-f81a-41e4-a31b-9e778be1855a",
      "role": "Moderator", "last_seen": 1794396103765,
      "first_seen": 1794390567155, "online": true }
  ]
}
curl request
curl "https://api.donutapi.app/v1/staff/online"
Response (real data)
200 OK application/json
{
  "snapshot": { "at": 1794391171965 },
  "staff": [
    { "player": "BobIsFound", "role": "Moderator" },
    { "player": "Dough4", "role": "Moderator" },
    { "player": "Napooo_", "role": "Moderator" },
    { "player": "u_vv", "role": "Moderator" }
  ]
}

Staff activity Beta

GET/v1/staff/activity?days=<n>

When the staff members our bots see are online: number of sessions, time online, and how that time spreads over the hours of the day and the days of the week. Members are sorted by total time. Hours and days are in the time zone given by timezone. The answer is cached for 10 minutes.

Read the times as minimums. A member is only counted while one of the bots sees them in the player list, so time spent hidden or out of the bots' view is missing. The log starts at logging_since: until several days have passed, the hours and weekdays say little about anyone's habits.

ParameterDescription
daysHow many days back to look, from 1 to 90. 30 by default.
FieldDescription
logging_sinceWhen the log started, as Unix time in milliseconds.
window_daysNumber of days covered.
timezoneTime zone of the hours and weekdays.
staff[].playerPlayer name of the staff member.
staff[].roleModerator or Developer. Left out when the role could not be determined.
staff[].sessionsNumber of sessions seen.
staff[].total_minutesMinutes seen online.
staff[].median_session_minutesMedian length of a session, in minutes.
staff[].spectator_minutesMinutes seen in spectator mode.
staff[].first_seen, last_seenFirst and last time the member was seen in the window, as Unix time in milliseconds.
staff[].usual_hoursThe hours of the day, from 0 to 23, when the member is most often online.
staff[].minutes_by_hour24 values: minutes online for each hour of the day, starting at midnight.
staff[].minutes_by_weekdayMinutes online for each day of the week, from sun to sat.
curl request
curl "https://api.donutapi.app/v1/staff/activity?days=30"
Response (real data, trimmed to one member, taken minutes after the log started)
200 OK application/json
{
  "logging_since": 1794396733747,
  "window_days": 30,
  "timezone": "Europe/Paris",
  "staff": [
    { "player": "CaptainMoose35", "role": "Moderator", "sessions": 1,
      "total_minutes": 3, "median_session_minutes": 3,
      "spectator_minutes": 0,
      "first_seen": 1794396733747, "last_seen": 1794396883982,
      "usual_hours": [12],
      "minutes_by_hour": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
      "minutes_by_weekday": { "sun": 0, "mon": 0, "tue": 0, "wed": 3,
        "thu": 0, "fri": 0, "sat": 0 } }
  ]
}

Payments

A payment tells you that a player really paid another player in the game. You create it with the recipient, show the payer the command to type, and the API tells you when the money has arrived. Use it when your project has to know that a /pay really happened, for example to sell something for in-game money.

Key requiredEvery payment route needs an API key and answers 401 without one. Get one in the dashboard. Call these routes from your server: a key placed in browser code is public.

No code yet? The dashboard has a form that creates a payment by hand, shows the command to give to the payer and tells you when it is paid. With a Discord webhook added under Integrations, your channel gets a message at that moment.

There are two modes, chosen with the mode field when you create the payment.

ModeHow the money moves
directThe default. The payer pays the recipient in the game. Our payment account only sees that the payment happened and never holds the money. The recipient has to follow that account once.
relayThe payer pays our payment account, which passes the money on to the recipient. For a recipient who does not want to follow the payment account.

Direct mode

  1. Once: the recipient follows the payment account. In the game, the recipient types the follow_command given by the recipients route and keeps "See Payment Activity" turned on for that account in /friends. The account follows back about a minute or two later. A recipient who unfollows is no longer eligible.
  2. Create the payment. The answer carries command, the /pay command that sends the money straight to the recipient.
  3. The payer types the command in the game. The game tells our payment account about it, and the payment becomes paid.
  4. You learn about it. Read the payment with GET, wait on it with ?wait=25, or receive a webhook.

In a real test on November 11, 2026, a direct payment was seen less than a second after it was made. That is one measurement, not a promise.

Direct mode rests entirely on the notice the game sends to the payment account. If that account is out of the game at the moment the payer pays, the payment is not seen. Creating a payment already answers 503 while the account is out of the game.

A payment that matches nothing, from the wrong payer or for the wrong amount, is simply ignored: the money stays with the recipient.

Relay mode

  1. Create the payment with "mode":"relay". command then pays pay_to, our payment account.
  2. The payer types the command in the game. The payment account recognises the payment by its amount, and by the sender when you named a payer.
  3. The account forwards the money to the recipient. The payment becomes paid. If the game refuses that transfer, the payer is refunded and the payment ends as refunded.

The recipient must have payments turned on in the /settings of the game. Otherwise the game refuses the transfer, the payer is refunded, and the payment ends as refunded with the reason in detail. Money that reaches the payment account and matches no pending payment (wrong amount, wrong payer, or sent after the expiry) goes back to its sender, minus the game tax.

Who pays what

DonutSMP takes a 2% tax on every /pay, charged to the sender on top of the amount sent. In direct mode the money moves once. In relay mode it moves twice, so the tax is taken twice.

For an amount of 1000DirectRelay
The payer sends1000 to the recipient, plus the 2% game tax on top.1000 to the payment account, plus the 2% game tax on top.
fee0, on every plan.The Donut API fee, taken from the amount: 0 on the paid plans and 2% on the free plan for now.
game_tax020, the game tax on the transfer to the recipient.
net_amount1000, the recipient receives all of it.980 when the fee is 0.

The relay fee is provisional and can change, so read fee, game_tax and net_amount in the answer instead of computing them.

Check a recipient

GET/v1/payments/recipients/<name>

Tells you whether a player can receive direct payments, and what to ask them to type if not. Read the name of the payment account from the answer instead of writing it in your code.

FieldDescription
usernameThe player you asked about.
direct_readytrue when the player follows the payment account and lets it see payment activity, so direct payments to this player can be created.
sinceSince when the player is ready, as Unix time in milliseconds. null when not ready.
payment_accountThe account the recipient has to follow.
follow_commandThe command the recipient types in the game, ready to show.
curl request
curl "https://api.donutapi.app/v1/payments/recipients/ZKR5" \
  -H "Authorization: Bearer dk_your_key"
Response (shape, for a player who is not ready)
200 OK application/json
{
  "username": "ZKR5",
  "direct_ready": false,
  "since": null,
  "payment_account": "<account>",
  "follow_command": "/follow <account>"
}

Create a payment

POST/v1/payments

Send a JSON body of 4 KB at most. The answer is 201 with the payment, in status pending. Payment calls are counted against your limits like the other routes.

Body fieldDescription
recipientRequired. Name of the player who receives the money.
modeOptional. direct by default, or relay.
amountA whole number from 1 to 1,000,000,000. Above 999 it can have three significant digits at most, because DonutSMP rounds the amounts it shows: 1000, 1250 and 12500 are valid, 1234 is refused and the error carries nearest_valid. Required, except in direct mode when you give a payer: without an amount, the API accepts the first payment from that payer to that recipient, whatever the sum.
payerOptional. Name of the player who has to pay. When you give it, only that player can settle the payment.
referenceOptional. Free text of 64 characters at most, returned as is. Use it for your own order id.
webhook_urlOptional. A plain https address of 300 characters at most that receives the webhooks of this payment.
expires_inOptional. How long the payer has, in seconds, from 60 to 3600. 900 by default.
curl direct payment
curl -X POST "https://api.donutapi.app/v1/payments" \
  -H "Authorization: Bearer dk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"recipient":"ZKR5","payer":"NotRyzn","reference":"verif-notryzn","expires_in":3600}'
A direct payment once paid (real data, from our own test, with the two tracking fields that were added since)
200 OK application/json
{
  "id": "pay_c326970557141a1588d19847",
  "mode": "direct",
  "status": "paid",
  "amount": 15,
  "fee": 0,
  "game_tax": 0,
  "net_amount": 15,
  "recipient": "ZKR5",
  "payer": "NotRyzn",
  "reference": "verif-notryzn",
  "pay_to": "ZKR5",
  "command": "/pay ZKR5 15",
  "created_at": 1794414535733,
  "expires_at": 1794418135733,
  "received_at": 1794414563347,
  "paid_by": "NotRyzn",
  "paid_at": 1794414563347,
  "tx_id": "tx_9bbe5d52263ead748319",
  "track_url": "https://donutapi.app/tx/tx_9bbe5d52263ead748319"
}
A relay payment just created (real data, from a test made before the mode field existed)
201 Created application/json
{
  "id": "pay_8bba7b665c7ed61edd3eb39f",
  "status": "pending",
  "amount": 1000,
  "fee": 0,
  "game_tax": 20,
  "net_amount": 980,
  "recipient": "ZKR5",
  "payer": "NotRyzn",
  "reference": "test-2",
  "pay_to": "Ryuuwn",
  "command": "/pay Ryuuwn 1000",
  "created_at": 1794407605057,
  "expires_at": 1794411205057,
  "received_at": null,
  "paid_by": null,
  "paid_at": null
}
FieldDescription
idId of the payment, starting with pay_.
modedirect or relay.
statusWhere the payment stands. See the list of statuses.
amountWhat the payer has to send. For a direct payment created without an amount, it is null until the payer pays, then it holds the sum really paid.
fee, game_tax, net_amountThe Donut API fee, the game tax on the transfer to the recipient, and what the recipient receives. In direct mode fee and game_tax are 0 and net_amount equals amount.
recipient, payer, referenceWhat you sent. payer is null when anyone can pay.
pay_toThe account the payer has to pay: the recipient in direct mode, the payment account in relay mode.
commandThe command to show the payer, ready to type in the game. For a direct payment without an amount it reads /pay <recipient> <amount>: the payer chooses the sum.
created_at, expires_atUnix time in milliseconds. After expires_at, a payment nobody paid becomes expired.
received_at, paid_byWhen the money was seen, in milliseconds, and the player who sent it. null until then.
paid_atWhen the recipient was paid, in milliseconds. null until then.
tx_id, track_urlThe public id of the payment and the address of its tracking page. Give the link to your customer. Both are null only while the payment has no step in the chain yet.
detailAn explanation, present only when there is one, for example why a transfer was refused.
webhook_url, webhook_secretPresent only when you gave a webhook_url. webhook_secret starts with whsec_ and is shown once, in this answer: store it.
StatusMeaning
400The body is refused: invalid or missing amount, invalid recipient or payer, payer and recipient are the same player, the recipient is the payment account, invalid webhook_url, or in relay mode an amount too small once the fee and the tax are taken.
401{"error":"payments need an API key"}
409In direct mode, the recipient has not followed the payment account: {"error":"the recipient has not followed the payment account yet"}, with fix and follow_command to show the recipient.
409A pending payment would be mistaken for this one. In direct mode, two pending payments to the same recipient cannot share an amount unless they name different payers. In relay mode, two pending payments cannot share an amount unless they name different payers. Name a payer or change the amount.
429One key can hold 20 pending payments at most. The usual rate limit answers apply too.
503The payment account is not in the game right now. Retry later.

Follow or cancel a payment

GET/v1/payments/<id>

Returns the payment, with the same fields as at creation except webhook_secret. Add ?wait=25 and the answer is held until the status changes, for 25 seconds at most: loop on it to know at once without a webhook.

curl wait for a change
curl "https://api.donutapi.app/v1/payments/pay_c326970557141a1588d19847?wait=25" \
  -H "Authorization: Bearer dk_your_key"

A direct payment goes from pending to paid, expired or cancelled. The other statuses only exist in relay mode, where the money passes through the payment account.

StatusMeaning
pendingWaiting for the payer to type the command.
paidFinal. The recipient was paid.
expiredFinal. Nobody paid before expires_at.
cancelledFinal. You cancelled it while it was pending.
receivedRelay only. The money reached the payment account.
forwardingRelay only. The account is sending it to the recipient.
refundingRelay only. The transfer was refused and the account is sending the money back to the payer.
refundedRelay only, final. The payer got the money back. detail says why.
forward_unconfirmedRelay only, final. The game did not confirm the transfer to the recipient.
refund_unconfirmedRelay only, final. The game did not confirm the refund.

The two unconfirmed statuses mean the payment account sent a command and the game never said whether it went through. It never sends the command again blind, which could pay twice: a person on our side checks what happened. Treat these payments as unsettled until then.

POST/v1/payments/<id>/cancel

Cancels a payment that is still pending.

StatusMeaning
404{"error":"payment not found"}
405Wrong method for this route.
409The payment is no longer pending, so it cannot be cancelled.

Payment webhooks

When a payment has a webhook_url, the API sends it a POST with a JSON body each time the payment reaches one of these statuses: paid, expired and cancelled, plus in relay mode received, refunded, forward_unconfirmed and refund_unconfirmed.

Body (shape)
POST application/json
{
  "event": "payment.paid",
  "payment": { "id": "pay_c326970557141a1588d19847", "status": "paid" }
}

event is payment. followed by the status, and payment is the whole payment, with the fields listed under Create a payment.

RuleDetail
AnswerReply with a 2xx status within 5 seconds.
RetriesThe API tries 4 times, then gives up. A webhook can be lost or arrive twice, so read the payment with GET when in doubt and ignore an event you already handled.
AddressRedirects are not followed and private addresses are refused.
User agentDonutAPI-Webhook/1
SignatureHeader x-donut-signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of t, a dot and the raw body, keyed with your webhook_secret.

Check the signature on the raw body, before parsing it, and refuse a timestamp that is too old.

node check a signature
const crypto = require("crypto");

// rawBody: the request body as received, not re-serialised
function isFromDonutApi(rawBody, signatureHeader, webhookSecret) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("="))
  );
  const expected = crypto
    .createHmac("sha256", webhookSecret)
    .update(parts.t + "." + rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}

Track a payment

Every payment has a public page, https://donutapi.app/tx/<tx_id>, that anyone can open without a key. A payment carries its id and the full link in tx_id and track_url. Give the link to your customer: the page shows where the payment stands, each step with its time, and it checks the hashes of the steps in the browser. It refreshes by itself while the payment is pending.

No key neededThis route and /v1/chain are public and are not counted against any limit. Anyone who has a tx_id sees the payer, the recipient and the amount, so share the link only with people who may see them.
GET/v1/tx/<tx_id>

A tx_id is tx_ followed by 20 characters, digits and letters from a to f.

curl request
curl "https://api.donutapi.app/v1/tx/tx_9bbe5d52263ead748319"
Response (real data, from our own test)
200 OK application/json
{
  "id": "tx_9bbe5d52263ead748319",
  "status": "paid",
  "mode": "direct",
  "amount": 15,
  "recipient": "ZKR5",
  "payer": "NotRyzn",
  "created_at": 1794414535733,
  "expires_at": 1794418135733,
  "paid_at": 1794414563347,
  "events": [
    {
      "block": 13,
      "at": 1794414535736,
      "type": "created",
      "hash": "f2e71e643db349d80f6977974c5efaca13e44dd0d0d63ebc42664970e263754a",
      "prev": "74a96ba6a6400570703d3b07f6683b5680e9355933327cc8a4d801dfdf7043af",
      "payload": "{\"tx\":\"tx_9bbe5d52263ead748319\",\"type\":\"created\",\"at\":1794414535736,\"data\":{\"mode\":\"direct\",\"amount\":0,\"recipient\":\"ZKR5\",\"payer\":\"NotRyzn\",\"net_amount\":0},\"salt\":\"aea6acc5c37e9376\"}"
    },
    {
      "block": 14,
      "at": 1794414563350,
      "type": "paid",
      "hash": "b7f41e601a2312175c7a7d4e957adc740b44c0c1c15ab395d18858fd77833ae4",
      "prev": "f2e71e643db349d80f6977974c5efaca13e44dd0d0d63ebc42664970e263754a",
      "payload": "{\"tx\":\"tx_9bbe5d52263ead748319\",\"type\":\"paid\",\"at\":1794414563350,\"data\":{\"mode\":\"direct\",\"amount\":15,\"recipient\":\"ZKR5\",\"payer\":\"NotRyzn\",\"from\":\"NotRyzn\",\"to\":\"ZKR5\"},\"salt\":\"845c2fa605bd5ac3\"}"
    }
  ],
  "verify": "hash = sha256(block + \"\\n\" + prev + \"\\n\" + payload), and prev is the hash of the block before (all zeros for block 1)"
}
FieldDescription
idThe id of the transaction, the tx_id of the payment.
status, modeThe same values as in the payment.
amount, recipient, payerThe sum in game money, the player who is paid and the player who pays.
created_at, expires_at, paid_atUnix time in milliseconds.
eventsThe steps of the payment, oldest first. Each one is a block of the public chain.
events[].blockThe number of the block in the chain.
events[].atWhen the step was written, in milliseconds.
events[].typecreated, paid, received, forwarded, refunded, refund_failed, recipient_refused, unconfirmed, cancelled or expired.
events[].hash, events[].prevThe hash of this block and the hash of the block before it, 64 characters each.
events[].payloadThe exact text that went into the hash: a JSON document with the transaction id, the type, the time, the data of the step and a random salt.
verifyA reminder of the formula below.

In the example above the payment was created without an amount, so the created step holds an amount of 0 and the paid step holds the sum that was paid. An unknown id answers 404 with {"error":"unknown transaction"}.

Check a hash yourself

The hash of a block is sha256(block + "\n" + prev + "\n" + payload), written in hexadecimal. Recompute it from the three fields, compare it with hash, then look for the same block number in /v1/chain and compare hash and prev. The tracking page does exactly that.

node check one step
const crypto = require("crypto");

function stepIsValid(step) {
  const text = step.block + "\n" + step.prev + "\n" + step.payload;
  return crypto.createHash("sha256").update(text).digest("hex") === step.hash;
}

The public chain

Every step of every payment is one block of a single chain. A block holds the hash of the block before it, and block 1 points to 64 zeros, so an old step cannot be changed without breaking every block written after it. The blocks hold no name, no amount and no transaction id: only someone who has a tx_id sees the detail of a payment. The chain is kept by Donut API on its own server. It is a record that anyone can check, not a network of computers. You can browse it on the chain page.

GET/v1/chain
ParameterDescription
limitOptional. How many blocks to return.
beforeOptional. Return the blocks below this number. Pass the next_before of the answer before to go further back.
curl request
curl "https://api.donutapi.app/v1/chain?limit=3"
Response (real data)
200 OK application/json
{
  "height": 15,
  "verified": true,
  "checked_blocks": 15,
  "genesis_prev": "0000000000000000000000000000000000000000000000000000000000000000",
  "blocks": [
    {
      "block": 15,
      "at": 1794417947112,
      "type": "expired",
      "hash": "e97cb24b5e04de3ed71c38880ec35efb3a157e36ad4b33f7245e04f7d1c1fa13",
      "prev": "b7f41e601a2312175c7a7d4e957adc740b44c0c1c15ab395d18858fd77833ae4"
    },
    {
      "block": 14,
      "at": 1794414563350,
      "type": "paid",
      "hash": "b7f41e601a2312175c7a7d4e957adc740b44c0c1c15ab395d18858fd77833ae4",
      "prev": "f2e71e643db349d80f6977974c5efaca13e44dd0d0d63ebc42664970e263754a"
    },
    {
      "block": 13,
      "at": 1794414535736,
      "type": "created",
      "hash": "f2e71e643db349d80f6977974c5efaca13e44dd0d0d63ebc42664970e263754a",
      "prev": "74a96ba6a6400570703d3b07f6683b5680e9355933327cc8a4d801dfdf7043af"
    }
  ],
  "next_before": 13,
  "verify": "hash = sha256(block + \"\\n\" + prev + \"\\n\" + payload), and prev is the hash of the block before (all zeros for block 1)"
}
FieldDescription
heightThe number of the latest block.
verified, checked_blocksThe result of the API checking its own chain, and how many blocks that check covered.
genesis_prevThe value of prev in block 1: 64 zeros.
blocksThe blocks, latest first, each with block, at, type, hash and prev, as in the steps of a transaction. There is no payload here.
next_beforeThe value to pass as before to get the blocks that come next, or null when block 1 is in the answer.
verifyA reminder of the hash formula.

Account webhooks

An account webhook runs your own program when something happens on your account. You give an https address once, in the Integrations card of the dashboard, and Donut API calls it when a payment is paid or when an account link is verified. You do not have to pass a webhook_url with each payment any more, although that still works next to it.

An account can have up to 3 addresses. For each one you pick the events, you can pause it, replace its signing secret, send a test call, and see the latest calls with the answer of your server. The calls are kept for 30 days.

EventWhen
payment.paidA payment of your account is paid.
payment.expiredA payment expired before it was paid.
payment.cancelledA payment was cancelled.
payment.refundedA relay payment was refunded.
link.verifiedAn account link is verified.
link.expiredAn account link expired.
webhook.testYou pressed Send a test in the dashboard. The body has "test": true and an example payment whose id is pay_test.

A new address starts with payment.paid and link.verified.

The address can be a Discord webhook. Donut API then posts a message in that channel instead of the JSON body below: what happened, the reference and the link to track the payment. There is nothing to code and no signature to check. A player name or a reference can never ping anyone in it.

Body (shape, with made-up values)
POST application/json
{
  "id": "evt_1042",
  "event": "payment.paid",
  "created_at": 1794440000000,
  "payment": { "id": "pay_c326970557141a1588d19847", "status": "paid" }
}

payment is the whole payment, as GET /v1/payments/<id> returns it. For a link event the body carries link instead, the whole link as GET /v1/links/<id> returns it. id is unique per event: keep the ids you have handled if your program must not act twice.

RuleDetail
Headerscontent-type: application/json, user-agent: DonutAPI-Webhook/1 and x-donut-signature: t=<seconds>,v1=<hex>.
Signaturev1 is HMAC-SHA256 of t + "." + raw body with the secret of the address, the same scheme as payment webhooks. The secret starts with whsec_ and is shown once, when the address is added or when you replace it.
AnswerAny 2xx status means received. Redirects are not followed and private addresses are refused.
Retries5 tries in all: at once, then after 10 seconds, 30 seconds, 2 minutes and 10 minutes. After that the call is marked failed.
OnceEach event is sent once per address.
StartOnly payments and links created after the address was added call it. Adding an address does not replay older events.
PauseA paused address receives nothing. What happened during the pause is sent when you resume it.
node a receiver that checks the signature
const crypto = require("crypto");
const http = require("http");
const SECRET = process.env.DONUT_WEBHOOK_SECRET; // whsec_...

http.createServer((req, res) => {
  let raw = "";
  req.on("data", (chunk) => (raw += chunk));
  req.on("end", () => {
    // x-donut-signature: t=<seconds>,v1=<hex>
    const sig = Object.fromEntries(String(req.headers["x-donut-signature"] || "").split(",").map((p) => p.split("=")));
    const expected = crypto.createHmac("sha256", SECRET).update(sig.t + "." + raw).digest("hex");
    const a = Buffer.from(expected), b = Buffer.from(sig.v1 || "");
    const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
    if (!fresh || a.length !== b.length || !crypto.timingSafeEqual(a, b)) { res.writeHead(401).end(); return; }

    const body = JSON.parse(raw);
    if (body.event === "payment.paid") {
      // Run your program here. body.payment is the payment, body.id is unique per event.
    }
    res.writeHead(200).end(); // any 2xx answer means received
  });
}).listen(3000);

Player trackers are not part of these events. A tracker keeps its own webhook_url.

Player trackers

A tracker follows one player and gives you what the game announces about them: when they join and leave, the payments they send and receive, and what they buy, sell and list at the auction house. One of our accounts in the game subscribes to the player, and the API turns what that account sees into events that you read, wait on, or receive by webhook.

Pro planEvery tracker route needs an API key on the Pro plan. Without a key the answer is 401, and a key on another plan gets 403. Paid plans cannot be bought yet. Call these routes from your server.

Know these limits before you build on a tracker:

  • The player sees that an account subscribed to them.
  • If the player turned off what subscribers can see, or turned on private transactions, nothing comes back.
  • A tracker only sees what happens after it was created.
  • Events are kept for 30 days.
  • Staff members and protected accounts cannot be tracked, and Bedrock players are not supported yet.
StatusMeaning
pendingThe tracker is being set up in the game. This takes from a few seconds to a minute.
watchingThe tracker sees the player. Events come in.
failedThe tracker could not be set up. The tracker carries a reason.
stoppedYou stopped it.

Create and manage trackers

POST/v1/trackers
Body fieldDescription
usernameRequired. The Java name of the player to track.
referenceOptional. Free text of 64 characters at most, returned as is.
webhook_urlOptional. A plain https address that receives the events as they come.
curl request
curl -X POST "https://api.donutapi.app/v1/trackers" \
  -H "Authorization: Bearer dk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"username":"ExamplePlayer","reference":"customer-42"}'
Response (shape, with made-up values)
201 Created application/json
{
  "id": "trk_0123456789abcdef01234567",
  "username": "ExamplePlayer",
  "status": "pending",
  "created_at": 1794430000000,
  "stopped_at": null,
  "reference": "customer-42",
  "online": null,
  "last_seen_at": null,
  "last_24h": {
    "since": 1794343600000,
    "events": 0,
    "sessions": 0,
    "payments_sent": { "count": 0, "total": 0 },
    "payments_received": { "count": 0, "total": 0 },
    "purchases": { "count": 0, "total": 0 },
    "sales": { "count": 0, "total": 0 },
    "listings": { "count": 0, "total": 0 },
    "deaths": 0
  }
}

Asking again for a player that the same key already tracks answers 200 with the existing tracker.

FieldDescription
idId of the tracker, trk_ followed by 24 characters.
username, referenceWhat you sent.
statuspending, watching, failed or stopped.
created_at, stopped_atWhen the tracker was created and when it was stopped. stopped_at is null while it runs.
onlinetrue or false, and null as long as it is not known.
last_seen_atWhen the player was last seen, or null.
last_24hWhat was seen in the last 24 hours: the number of events and sessions, and a count and a total for payments sent, payments received, purchases, sales and listings. Left out of the list route.
webhook_url, webhook_secretPresent only when you gave a webhook_url. webhook_secret is shown once, in this answer: store it.
GET/v1/trackers

Lists the trackers of your key, without last_24h, as {"trackers": [...], "max_active": n}. max_active is how many trackers your key can run at the same time.

GET/v1/trackers/<id>

Returns one tracker, with last_24h.

POST/v1/trackers/<id>/stop

Stops the tracker. Its status becomes stopped.

StatusMeaning
400The username is not valid, or it is a Bedrock name.
401{"error":"trackers need an API key"}
403{"error":"player trackers are part of the Pro plan"}, with your plan and an upgrade_url.
403{"error":"this player cannot be tracked"}: a staff member or a protected account.
404{"error":"no Java player with this name"}
429Your key already runs max_active trackers. Stop one first.
503Our tracking account is not in the game right now, or it cannot take another player. Retry later.

Tracker events

GET/v1/trackers/<id>/events
ParameterDescription
limitOptional. 50 by default, 200 at most.
afterOptional. The id of an event. You get the events that came after it. Without it you get the latest ones.
typeOptional. One type or several, separated by commas, for example payment_sent,purchase.
waitOptional, with after. The answer is held until the next event, for 25 seconds at most.
curl wait for what comes next
curl "https://api.donutapi.app/v1/trackers/trk_0123456789abcdef01234567/events?after=EVENT_ID&wait=25" \
  -H "Authorization: Bearer dk_your_key"
Response (shape, with made-up values)
200 OK application/json
{
  "tracker": "trk_0123456789abcdef01234567",
  "username": "ExamplePlayer",
  "events": [
    { "id": "<event id>", "at": 1794430100000, "type": "join" },
    { "id": "<event id>", "at": 1794430160000, "type": "payment_sent", "to": "OtherPlayer", "amount": 2500 },
    { "id": "<event id>", "at": 1794430200000, "type": "payment_received", "from": "OtherPlayer", "amount": 400 },
    { "id": "<event id>", "at": 1794430260000, "type": "purchase", "item": "Netherite Sword", "item_id": "netherite_sword", "quantity": 1, "amount": 13100000 },
    { "id": "<event id>", "at": 1794430400000, "type": "listing", "item": "Totem of Undying", "item_id": "totem_of_undying", "quantity": 1, "amount": 40000 },
    { "id": "<event id>", "at": 1794430700000, "type": "sale", "amount": 40700 },
    { "id": "<event id>", "at": 1794430900000, "type": "leave" }
  ],
  "next_after": "<event id>"
}

Events are listed oldest first. To follow a player, call again with after set to next_after.

TypeFields
join, leaveThe player came into the game or left it.
payment_sentto, the player who was paid, and amount.
payment_receivedfrom, the player who paid, and amount.
purchaseThe player bought something: item, item_id, quantity and amount.
listingThe player put something up for sale at the auction house: item, item_id, quantity and amount.
saleThe player sold something: amount, with item, item_id and quantity when the game names one item. When it only says that several items were sold, there is just the amount.

Every event has an id, a time at and a type. item is the name the game shows, and item_id is that name in lower case with underscores, such as netherite_sword. For an ordinary item it matches the Minecraft id, so you can use it to find an icon. An item renamed by a player matches nothing.

Two more types are planned and have not been seen in a real test yet: death, and other, which carries the raw line of the game in text. Ignore the types you do not know.

Tracker activity

GET/v1/trackers/<id>/activity

The habits of the tracked player, computed from the events of the tracker: when they are in the game, for how long, and how much money moves each day. It only covers what happened since the tracker was created, so a young tracker has little to say.

ParameterDescription
daysOptional. How many days back, from 1 to 30. 7 by default.
tzOptional. An IANA time zone name such as Europe/Paris. The hours and the dates of the answer are in that zone. UTC by default, and an unknown name answers 400.
curl one week, in Paris time
curl "https://api.donutapi.app/v1/trackers/trk_0123456789abcdef01234567/activity?days=7&tz=Europe/Paris" \
  -H "Authorization: Bearer dk_your_key"
Response (shape, with made-up values and shortened lists)
200 OK application/json
{
  "totals": {
    "online_minutes": 1260,
    "sessions": 14,
    "average_session_minutes": 90,
    "longest_session_minutes": 240,
    "peak_hour": 20,
    "usual_login_hour": 17,
    "busiest_weekday": "sat"
  },
  "hours": [30, 0, 0, 0, "...24 numbers, one per hour of the day"],
  "logins_by_hour": [1, 0, 0, 0, "...24 numbers"],
  "weekdays": [{ "day": "mon", "online_minutes": 150 }, "...7 entries, Monday first"],
  "heatmap": [[0, 0, 0, "...24 numbers"], "...7 rows, Monday first"],
  "days": [
    { "date": "2026-11-11", "online_minutes": 210, "sessions": 2, "sent": 40000, "received": 13100000, "spent": 0, "earned": 250000 }
  ],
  "sessions": [
    { "start": 1794430100000, "end": 1794437300000, "minutes": 120 },
    { "start": 1794441000000, "end": null, "minutes": 35 }
  ],
  "since": 1793900000000,
  "until": 1794504800000,
  "timezone": "Europe/Paris"
}
FieldDescription
totalsThe period in a few numbers. peak_hour and usual_login_hour are hours from 0 to 23, busiest_weekday goes from mon to sun. The three of them are null when there is nothing to tell them from.
hours24 numbers: the minutes spent in the game during each hour of the day, over the whole period.
logins_by_hour24 numbers: how many sessions started during each hour of the day.
weekdaysThe minutes in the game for each day of the week, Monday first.
heatmap7 rows, Monday to Sunday, of 24 numbers: the minutes in the game for each day of the week and each hour.
daysOne entry per calendar day that has data: time in the game, sessions, and the money paid (sent), received, spent on purchases and earned from sales, in whole dollars. A day with nothing is left out.
sessionsThe 100 latest sessions. end is null while the player is still in the game.
since, until, timezoneThe period that was counted and the time zone of the hours and dates.

With no session or a single one, the lists are mostly empty: show the totals and wait for more before drawing habits.

Tracker webhooks

When a tracker has a webhook_url, the API sends the events to it as they come, 50 at most per call.

Body (shape, with made-up values)
POST application/json
{
  "event": "tracker.events",
  "tracker": { "id": "trk_0123456789abcdef01234567", "username": "ExamplePlayer", "reference": "customer-42" },
  "events": [
    { "id": "<event id>", "at": 1794430100000, "type": "join" }
  ]
}

The call is signed like a payment webhook: check the x-donut-signature header with your webhook_secret before you trust the body.