API reference
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.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.
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 "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.
| When | What |
|---|---|
| July 3, 2026 | DonutSMP 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, 2026 | DonutSMP 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 2026 | In 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 "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.
| Header | Meaning |
|---|---|
| x-ratelimit-plan | keyless, free, developer or pro. |
| x-ratelimit-limit-minute | Calls allowed per minute. |
| x-ratelimit-remaining-minute | Calls left in the current minute. |
| x-ratelimit-limit-month | Calls allowed per month. |
| x-ratelimit-remaining-month | Calls left this month. |
Errors
Errors are JSON objects with an error field.
| Status | Body 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
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.
| Field | Description |
|---|---|
| ah.ts, orders.ts | When the snapshot was taken, as Unix time in milliseconds. |
| pages | Number of pages read. |
| lignes | Number of rows read. |
| complet | 1 when every page was read, 0 when the page cap was reached. |
| bots[].name | Name of the bot. |
| bots[].online | Whether the bot is in the game right now. A bot that is reconnecting is not online. |
| bots[].state | online, 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[].since | When the current block started, as Unix time in milliseconds. Left out when there is none. |
| bots[].last_seen | When the bot was last seen, as Unix time in milliseconds. |
| bots[].task | What the bot is doing: ah, orders or leaderboard during a snapshot, otherwise chat or player. null when the bot is offline. |
| bots[].uptime_24h | Share 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_summary | The 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. |
| traffic | How 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. |
| now | Server time, as Unix time in milliseconds. |
curl "https://api.donutapi.app/v1/status"{
"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
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.
| Parameter | Description |
|---|---|
| hours | How many hours back to look, from 1 to 720. |
| Field | Description |
|---|---|
| from, to | Start and end of the period, as Unix time in milliseconds. |
| step_minutes | Length of a slice in minutes: 5 for 24 hours, 34 for 7 days, 144 for 30 days. |
| logging_since | When the history started, as Unix time in milliseconds. |
| servers[].id, name, host | The address pinged, the same entries as donutsmp in /v1/status. |
| servers[].uptime | Share 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 "https://api.donutapi.app/v1/status/history?hours=24"{
"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
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.
elytra they drown the real offers, so there is no elytra price for now.| Parameter | Description |
|---|---|
| item | Minecraft item id, for example netherite_ingot. |
| limit | Maximum number of offers to return. |
| Field | Description |
|---|---|
| item | Item id. |
| name | Display name of the item. |
| count | Number of items in the offer. |
| price | Total price of the offer. |
| unit_price | Price per single item. |
| snapshot.at | When the data was read, as Unix time in milliseconds. |
| snapshot.source | search when the offers come from a search in the game. Left out for the sweep. |
| snapshot.complete | Whether everything was read. Always true for a search. |
| snapshot.pages | Number of pages read. Only for the sweep. |
curl "https://api.donutapi.app/v1/ah?item=netherite_ingot&limit=2"{
"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
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.
| Field | Description |
|---|---|
| item | Item id. |
| listings | Number of offers. |
| lowest_unit_price | Cheapest price per single item. |
| total_count | Number of items on sale. |
| priced_at | When the item was last searched in the game, as Unix time in milliseconds. Left out for items that only come from the sweep. |
curl "https://api.donutapi.app/v1/ah/items"{
"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
Open buy orders from the latest orders snapshot.
| Field | Description |
|---|---|
| item | Item id. |
| name | Display name of the item. |
| price_each | Price offered per item. |
| delivered | How many items were already delivered. |
| wanted | How many items the order asks for. |
curl "https://api.donutapi.app/v1/orders?limit=2"{
"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
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.
| Parameter | Description |
|---|---|
| item | Minecraft item id. |
| type | ah for the auction house or orders for buy orders. |
| hours | How many hours back to look. Your plan sets the maximum: 24 hours without a key. |
curl "https://api.donutapi.app/v1/history?item=ender_chest&type=ah&hours=24"{
"item": "ender_chest",
"type": "ah",
"points": [
{ "at": 1794383619041, "price": 18500, "listings": 201 },
{ "at": 1794387666192, "price": 13800, "listings": 1 }
]
}Public chat
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.
| Parameter | Description |
|---|---|
| limit | Number of messages to return. 50 by default, 200 at most. |
| after | Only return messages with an id above this one. |
| q | Words to search for, separated by spaces. Example: /v1/chat?q=base+raid. |
| Field | Description |
|---|---|
| id | Message id, increasing. |
| at | When the message was seen, as Unix time in milliseconds. |
| author | Anonymous id of the player. Never the player name. |
| text | The message, as written by the player. |
| latest_id | Id of the newest message. Pass it as after on your next call. |
curl "https://api.donutapi.app/v1/chat?limit=2"{
"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
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.
| Field | Description |
|---|---|
| day | The day, as YYYY-MM-DD in UTC. |
| partial | true when the day is not over yet. Left out for a complete day. |
| generated_at | When the stats were computed, as Unix time in milliseconds. |
| messages | Number of messages that day. |
| authors | Number of distinct authors that day. |
| top_words | Up to 10 words with their count, most said first. |
| top_chatter | The 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_hour | The hour with the most messages, from 0 to 23 in UTC, and how many. |
| topics | Up 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_model | A short text summary of the day written by an AI model, and the id of that model. It can be wrong. |
curl "https://api.donutapi.app/v1/chat/daily"{
"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
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.
| Parameter | Description |
|---|---|
| limit | Number of players to return, 200 at most. |
| Field | Description |
|---|---|
| rank | Position in the leaderboard, starting at 1. |
| player | Player name. |
| money | Balance, rounded as in the game. |
| snapshot.at | When the snapshot was taken, as Unix time in milliseconds. |
curl "https://api.donutapi.app/v1/leaderboard/money?limit=2"{
"snapshot": { "at": 1794387284205 },
"players": [
{ "rank": 1, "player": "Abuodee", "money": 5900000000000 },
{ "rank": 2, "player": "Wampiptiii01", "money": 3700000000000 }
]
}Player stats Beta
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.
| Field | Description |
|---|---|
| player | Player name. |
| at | When the stats were read, as Unix time in milliseconds. |
| cached | Whether the answer came from the cache. |
| stats.money | Balance, rounded as in the game. |
| stats.kills | Number of kills. |
| stats.deaths | Number of deaths. |
| stats.playtime | Time played, as a string such as 180d 12h. |
| stats.shards | Number of shards. Can be missing. |
| stats.blocks_placed | Blocks placed. Can be missing. |
| stats.blocks_broken | Blocks broken. Can be missing. |
| stats.mobs_killed | Mobs 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 "https://api.donutapi.app/v1/player/Abuodee"{
"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
}
}| Status | Meaning |
|---|---|
| 200 | Stats found. |
| 202 | The game did not answer within 15 seconds. Body: {"status":"pending","retry_in_seconds":5}. Retry after that delay. Not counted against your limits. |
| 400 | Invalid player name. |
| 404 | The game says this player does not exist. Answers in about a second. |
| 503 | Too many lookups waiting. Try again later. |
Staff Beta
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.
| Field | Description |
|---|---|
| staff | Always an array, empty when there is nobody to list. |
| staff[].player | Player name of the staff member. |
| staff[].uuid | Minecraft account id of the member, which stays the same when the name changes. Only in /v1/staff. |
| staff[].previous_name | The former name, after the member changed name. Left out otherwise. Only in /v1/staff. |
| staff[].role | Moderator or Developer, read from the colour of the staff marker. Left out when the role could not be determined. |
| staff[].spectator | true when the member is online in spectator mode, read from the game mode in the player list. Left out otherwise, never false. |
| staff[].online | Whether the bots see this member right now. Only in /v1/staff. |
| staff[].last_seen | When the member was last seen, as Unix time in milliseconds. Only in /v1/staff. |
| staff[].first_seen | When the bots first saw the member, as Unix time in milliseconds. Only in /v1/staff. |
| snapshot.at | When 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 "https://api.donutapi.app/v1/staff"{
"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 "https://api.donutapi.app/v1/staff/online"{
"snapshot": { "at": 1794391171965 },
"staff": [
{ "player": "BobIsFound", "role": "Moderator" },
{ "player": "Dough4", "role": "Moderator" },
{ "player": "Napooo_", "role": "Moderator" },
{ "player": "u_vv", "role": "Moderator" }
]
}Staff activity Beta
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.
| Parameter | Description |
|---|---|
| days | How many days back to look, from 1 to 90. 30 by default. |
| Field | Description |
|---|---|
| logging_since | When the log started, as Unix time in milliseconds. |
| window_days | Number of days covered. |
| timezone | Time zone of the hours and weekdays. |
| staff[].player | Player name of the staff member. |
| staff[].role | Moderator or Developer. Left out when the role could not be determined. |
| staff[].sessions | Number of sessions seen. |
| staff[].total_minutes | Minutes seen online. |
| staff[].median_session_minutes | Median length of a session, in minutes. |
| staff[].spectator_minutes | Minutes seen in spectator mode. |
| staff[].first_seen, last_seen | First and last time the member was seen in the window, as Unix time in milliseconds. |
| staff[].usual_hours | The hours of the day, from 0 to 23, when the member is most often online. |
| staff[].minutes_by_hour | 24 values: minutes online for each hour of the day, starting at midnight. |
| staff[].minutes_by_weekday | Minutes online for each day of the week, from sun to sat. |
curl "https://api.donutapi.app/v1/staff/activity?days=30"{
"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 } }
]
}Account linking
A link proves that a visitor owns a Minecraft account, with no password, no mod and no message in the chat. Use it to tie a Discord account or an account on your site to a DonutSMP name. You create a link for a username, show the player a /pay command for a small amount to a player picked at random, and the link becomes verified once that account has made that payment in the game.
401 without one. Get one in the dashboard. Call these routes from your server. Each call is counted against your limits like a normal call.- Create the link from your server. Keep its
idon your server. - Wait until
watchingistrue. The API needs a few seconds to get ready to see the payment. A payment made before that is not seen, so do not show the command yet. - The player runs
commandin the game. From the account named inusername, exactly as it is. - The payment is seen and the link becomes verified. Read the link with
GET, wait on it with?wait=25, or receive a webhook.
The amount and the player to pay are picked at random for each link, and the payment itself is the proof. Nobody writes to the player or follows them, and the only name they see is the player to pay. What ties the proof to your visitor is the id, which only your server knows. So never accept a proof that comes from the player's browser. Read the link from your server, or check the signature of the webhook, before you link anything.
Your interface has to tell the player three things:
- The payment costs game money.
amountis between 11 and 989, and DonutSMP adds its 2% tax to the payer. The money goes to the player named inpay_to, not to you and not to us. - If the link is still pending a minute after the payment, they should ask for a new link. Another player is picked each time.
- The command stops working at
expires_at. Show a countdown and let the player ask for a new link.
If watch_error shows up in the link, the player that was picked could not be watched. Create a new link to get another one.
| Body field | Description |
|---|---|
| username | Required. The Minecraft name to prove. A Bedrock name works with or without its leading dot. |
| reference | Optional. Free text of 64 characters at most, returned as is. Use it for your own user id. |
| webhook_url | Optional. A plain https address of 300 characters at most that is called when the link is verified or expires. |
| expires_in | Optional. How long the player has, in seconds, from 60 to 1800. 600 by default. |
| method | Optional. pay by default. chat is the older method, where the player sends a phrase in the public chat instead of paying. It only works when one of our bots is in the same chat area as the player, so prefer pay. |
curl -X POST "https://api.donutapi.app/v1/links" \
-H "Authorization: Bearer dk_your_key" \
-H "Content-Type: application/json" \
-d '{"username":"NotRyzn","reference":"demo"}'{
"id": "lnk_9e32189eb9d8b4a6441b9bbc1ea546b6",
"status": "pending",
"username": "NotRyzn",
"reference": "demo",
"created_at": 1794410550045,
"expires_at": 1794411150045,
"verified_at": null,
"uuid": null,
"method": "pay",
"pay_to": "<player>",
"amount": 353,
"command": "/pay <player> 353",
"watching": false,
"same_region": false
}| Field | Description |
|---|---|
| id | Id of the link, starting with lnk_. Keep it on your server. |
| status | pending while waiting for the payment, verified once the account has made it, expired when it was not seen before expires_at. |
| username, reference | What you sent. |
| created_at, expires_at | Unix time in milliseconds. |
| verified_at | When the payment was seen, in milliseconds. null until then. |
| uuid | Minecraft account id of the player, filled once the link is verified when we know it. It can stay null. |
| method | pay, or chat when you asked for the older method. |
| pay_to, amount | The player to pay and the amount in game money, from 11 to 989. Both are picked at random for each link. |
| command | The full command to show the player, built from pay_to and amount. |
| watching | Whether the API is ready to see the payment. While it is false, a payment is not seen. |
| same_region | Whether one of our bots is in the same chat area as the player. It only matters for the chat method. |
| watch_error | Present only when the player that was picked could not be watched. Create a new link to get another one. |
| webhook_url, webhook_secret | Present only when you gave a webhook_url. webhook_secret starts with whsec_ and is shown once, in this answer: store it. |
Returns the link. Add ?wait=25 and the answer is held until the status changes, for 25 seconds at most. To catch watching turning true, read the link every few seconds without wait, then use ?wait=25 once the command is shown. A link can only be read with the key that created it: any other key gets 404.
curl "https://api.donutapi.app/v1/links/lnk_9e32189eb9d8b4a6441b9bbc1ea546b6?wait=25" \
-H "Authorization: Bearer dk_your_key"| Status | Meaning |
|---|---|
| 400 | {"error":"invalid Minecraft username"}, or another field of the body is refused. |
| 401 | {"error":"links need an API key"} |
| 404 | Unknown link, or a link created with another key. |
| 429 | One key can hold 50 pending links at most. The usual rate limit answers apply too. |
| 503 | Our account in the game is offline right now, so the proof could not be seen. Retry later. |
Link webhooks
When a link has a webhook_url, the API calls it as soon as the payment is seen, without anyone reading the link, and again if the link expires. Do what you want there, for example give a Discord role or send an email.
{
"event": "link.verified",
"link": { "id": "lnk_9e32189eb9d8b4a6441b9bbc1ea546b6", "status": "verified" }
}event is link.verified or link.expired, and link is the whole link, as GET returns it. The delivery rules and the x-donut-signature header are the same as for payment webhooks: reply with a 2xx status within 5 seconds, 4 tries, private addresses refused, and a signature to check with your webhook_secret.
These routes are not the sign-in of the dashboard. A link you create never opens a dashboard session.
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.
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.
| Mode | How the money moves |
|---|---|
| direct | The 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. |
| relay | The 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
- Once: the recipient follows the payment account. In the game, the recipient types the
follow_commandgiven 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. - Create the payment. The answer carries
command, the/paycommand that sends the money straight to the recipient. - The payer types the command in the game. The game tells our payment account about it, and the payment becomes
paid. - 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
- Create the payment with
"mode":"relay".commandthen payspay_to, our payment account. - 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. - 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 asrefunded.
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 1000 | Direct | Relay |
|---|---|---|
| The payer sends | 1000 to the recipient, plus the 2% game tax on top. | 1000 to the payment account, plus the 2% game tax on top. |
| fee | 0, 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_tax | 0 | 20, the game tax on the transfer to the recipient. |
| net_amount | 1000, 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
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.
| Field | Description |
|---|---|
| username | The player you asked about. |
| direct_ready | true when the player follows the payment account and lets it see payment activity, so direct payments to this player can be created. |
| since | Since when the player is ready, as Unix time in milliseconds. null when not ready. |
| payment_account | The account the recipient has to follow. |
| follow_command | The command the recipient types in the game, ready to show. |
curl "https://api.donutapi.app/v1/payments/recipients/ZKR5" \
-H "Authorization: Bearer dk_your_key"{
"username": "ZKR5",
"direct_ready": false,
"since": null,
"payment_account": "<account>",
"follow_command": "/follow <account>"
}Create a payment
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 field | Description |
|---|---|
| recipient | Required. Name of the player who receives the money. |
| mode | Optional. direct by default, or relay. |
| amount | A 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. |
| payer | Optional. Name of the player who has to pay. When you give it, only that player can settle the payment. |
| reference | Optional. Free text of 64 characters at most, returned as is. Use it for your own order id. |
| webhook_url | Optional. A plain https address of 300 characters at most that receives the webhooks of this payment. |
| expires_in | Optional. How long the payer has, in seconds, from 60 to 3600. 900 by default. |
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}'{
"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"
}{
"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
}| Field | Description |
|---|---|
| id | Id of the payment, starting with pay_. |
| mode | direct or relay. |
| status | Where the payment stands. See the list of statuses. |
| amount | What 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_amount | The 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, reference | What you sent. payer is null when anyone can pay. |
| pay_to | The account the payer has to pay: the recipient in direct mode, the payment account in relay mode. |
| command | The 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_at | Unix time in milliseconds. After expires_at, a payment nobody paid becomes expired. |
| received_at, paid_by | When the money was seen, in milliseconds, and the player who sent it. null until then. |
| paid_at | When the recipient was paid, in milliseconds. null until then. |
| tx_id, track_url | The 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. |
| detail | An explanation, present only when there is one, for example why a transfer was refused. |
| webhook_url, webhook_secret | Present only when you gave a webhook_url. webhook_secret starts with whsec_ and is shown once, in this answer: store it. |
| Status | Meaning |
|---|---|
| 400 | The 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"} |
| 409 | In 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. |
| 409 | A 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. |
| 429 | One key can hold 20 pending payments at most. The usual rate limit answers apply too. |
| 503 | The payment account is not in the game right now. Retry later. |
Follow or cancel a payment
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 "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.
| Status | Meaning |
|---|---|
| pending | Waiting for the payer to type the command. |
| paid | Final. The recipient was paid. |
| expired | Final. Nobody paid before expires_at. |
| cancelled | Final. You cancelled it while it was pending. |
| received | Relay only. The money reached the payment account. |
| forwarding | Relay only. The account is sending it to the recipient. |
| refunding | Relay only. The transfer was refused and the account is sending the money back to the payer. |
| refunded | Relay only, final. The payer got the money back. detail says why. |
| forward_unconfirmed | Relay only, final. The game did not confirm the transfer to the recipient. |
| refund_unconfirmed | Relay 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.
Cancels a payment that is still pending.
| Status | Meaning |
|---|---|
| 404 | {"error":"payment not found"} |
| 405 | Wrong method for this route. |
| 409 | The 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.
{
"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.
| Rule | Detail |
|---|---|
| Answer | Reply with a 2xx status within 5 seconds. |
| Retries | The 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. |
| Address | Redirects are not followed and private addresses are refused. |
| User agent | DonutAPI-Webhook/1 |
| Signature | Header 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.
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.
/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.A tx_id is tx_ followed by 20 characters, digits and letters from a to f.
curl "https://api.donutapi.app/v1/tx/tx_9bbe5d52263ead748319"{
"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)"
}| Field | Description |
|---|---|
| id | The id of the transaction, the tx_id of the payment. |
| status, mode | The same values as in the payment. |
| amount, recipient, payer | The sum in game money, the player who is paid and the player who pays. |
| created_at, expires_at, paid_at | Unix time in milliseconds. |
| events | The steps of the payment, oldest first. Each one is a block of the public chain. |
| events[].block | The number of the block in the chain. |
| events[].at | When the step was written, in milliseconds. |
| events[].type | created, paid, received, forwarded, refunded, refund_failed, recipient_refused, unconfirmed, cancelled or expired. |
| events[].hash, events[].prev | The hash of this block and the hash of the block before it, 64 characters each. |
| events[].payload | The 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. |
| verify | A 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.
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.
| Parameter | Description |
|---|---|
| limit | Optional. How many blocks to return. |
| before | Optional. Return the blocks below this number. Pass the next_before of the answer before to go further back. |
curl "https://api.donutapi.app/v1/chain?limit=3"{
"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)"
}| Field | Description |
|---|---|
| height | The number of the latest block. |
| verified, checked_blocks | The result of the API checking its own chain, and how many blocks that check covered. |
| genesis_prev | The value of prev in block 1: 64 zeros. |
| blocks | The blocks, latest first, each with block, at, type, hash and prev, as in the steps of a transaction. There is no payload here. |
| next_before | The value to pass as before to get the blocks that come next, or null when block 1 is in the answer. |
| verify | A 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.
| Event | When |
|---|---|
| payment.paid | A payment of your account is paid. |
| payment.expired | A payment expired before it was paid. |
| payment.cancelled | A payment was cancelled. |
| payment.refunded | A relay payment was refunded. |
| link.verified | An account link is verified. |
| link.expired | An account link expired. |
| webhook.test | You 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.
{
"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.
| Rule | Detail |
|---|---|
| Headers | content-type: application/json, user-agent: DonutAPI-Webhook/1 and x-donut-signature: t=<seconds>,v1=<hex>. |
| Signature | v1 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. |
| Answer | Any 2xx status means received. Redirects are not followed and private addresses are refused. |
| Retries | 5 tries in all: at once, then after 10 seconds, 30 seconds, 2 minutes and 10 minutes. After that the call is marked failed. |
| Once | Each event is sent once per address. |
| Start | Only payments and links created after the address was added call it. Adding an address does not replay older events. |
| Pause | A paused address receives nothing. What happened during the pause is sent when you resume it. |
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.
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.
| Status | Meaning |
|---|---|
| pending | The tracker is being set up in the game. This takes from a few seconds to a minute. |
| watching | The tracker sees the player. Events come in. |
| failed | The tracker could not be set up. The tracker carries a reason. |
| stopped | You stopped it. |
Create and manage trackers
| Body field | Description |
|---|---|
| username | Required. The Java name of the player to track. |
| reference | Optional. Free text of 64 characters at most, returned as is. |
| webhook_url | Optional. A plain https address that receives the events as they come. |
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"}'{
"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.
| Field | Description |
|---|---|
| id | Id of the tracker, trk_ followed by 24 characters. |
| username, reference | What you sent. |
| status | pending, watching, failed or stopped. |
| created_at, stopped_at | When the tracker was created and when it was stopped. stopped_at is null while it runs. |
| online | true or false, and null as long as it is not known. |
| last_seen_at | When the player was last seen, or null. |
| last_24h | What 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_secret | Present only when you gave a webhook_url. webhook_secret is shown once, in this answer: store it. |
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.
Returns one tracker, with last_24h.
Stops the tracker. Its status becomes stopped.
| Status | Meaning |
|---|---|
| 400 | The 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"} |
| 429 | Your key already runs max_active trackers. Stop one first. |
| 503 | Our tracking account is not in the game right now, or it cannot take another player. Retry later. |
Tracker events
| Parameter | Description |
|---|---|
| limit | Optional. 50 by default, 200 at most. |
| after | Optional. The id of an event. You get the events that came after it. Without it you get the latest ones. |
| type | Optional. One type or several, separated by commas, for example payment_sent,purchase. |
| wait | Optional, with after. The answer is held until the next event, for 25 seconds at most. |
curl "https://api.donutapi.app/v1/trackers/trk_0123456789abcdef01234567/events?after=EVENT_ID&wait=25" \
-H "Authorization: Bearer dk_your_key"{
"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.
| Type | Fields |
|---|---|
| join, leave | The player came into the game or left it. |
| payment_sent | to, the player who was paid, and amount. |
| payment_received | from, the player who paid, and amount. |
| purchase | The player bought something: item, item_id, quantity and amount. |
| listing | The player put something up for sale at the auction house: item, item_id, quantity and amount. |
| sale | The 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
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.
| Parameter | Description |
|---|---|
| days | Optional. How many days back, from 1 to 30. 7 by default. |
| tz | Optional. 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 "https://api.donutapi.app/v1/trackers/trk_0123456789abcdef01234567/activity?days=7&tz=Europe/Paris" \
-H "Authorization: Bearer dk_your_key"{
"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"
}| Field | Description |
|---|---|
| totals | The 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. |
| hours | 24 numbers: the minutes spent in the game during each hour of the day, over the whole period. |
| logins_by_hour | 24 numbers: how many sessions started during each hour of the day. |
| weekdays | The minutes in the game for each day of the week, Monday first. |
| heatmap | 7 rows, Monday to Sunday, of 24 numbers: the minutes in the game for each day of the week and each hour. |
| days | One 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. |
| sessions | The 100 latest sessions. end is null while the player is still in the game. |
| since, until, timezone | The 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.
{
"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.