DiscordPlace Bots API
Keep your bot's listing up to date and find out who voted for it. One key per bot.
Start in three calls
- 1
Get a key
Open your bot's page on DiscordPlace, tab SDK, and generate its key. One key per bot. It is shown once: keep it in an environment variable, never in your repository.
- 2
Check it
Call GET /bot. It changes nothing and tells you the key works, whether your bot is published, and what we last received from it.
- 3
Send your stats every 5 minutes
POST /bot/stats on a timer, not once at startup: the "online" dot on your page is the freshness of these calls.
curl "https://discordplace.com/api/v1/bot" \
-H "Authorization: Bearer $DISCORDPLACE_API_KEY"const res = await fetch('https://discordplace.com/api/v1/bot', {
headers: {
Authorization: `Bearer ${process.env.DISCORDPLACE_API_KEY}`,
},
});
if (!res.ok) throw new Error(`DiscordPlace ${res.status}`);
const data = await res.json();import os
import requests
res = requests.get(
"https://discordplace.com/api/v1/bot",
headers={"Authorization": f"Bearer {os.environ['DISCORDPLACE_API_KEY']}"},
timeout=10,
)
res.raise_for_status()
data = res.json()Authentication
Calls that act for your bot carry its key in the Authorization header. The bot is always the one the key belongs to: there is no bot ID in the address, so a key can never read or write another bot.
Authorization: Bearer <DISCORDPLACE_API_KEY>The API key of your bot, generated in the SDK tab of its page on discordplace.com.
- A regenerated key stops working at once. If your bot keeps sending the old one, its SDK tab says so.
- Reading a public listing needs no key, and works from a browser on any site.
- No cookie is ever set by this API.
Endpoints
https://discordplace.com/api/v1
- GET/botCheck your key
- POST/bot/statsSend your stats (heartbeat)
- POST/bot/syncSend your commands and profile
- GET/bot/votes/{userId}Has this user voted for your bot?
- POST/bot/usageReport usage (optional)
- GET/bots/{id}Public listing of a bot
- HOOKA user voted for your bot
GET/bot
Check your key
Returns the bot this key belongs to, whether it is published, and the last sync DiscordPlace received. Changes nothing: call it first to make sure your key works.
Bot key600 per minute, per key
Request
curl "https://discordplace.com/api/v1/bot" \
-H "Authorization: Bearer $DISCORDPLACE_API_KEY"const res = await fetch('https://discordplace.com/api/v1/bot', {
headers: {
Authorization: `Bearer ${process.env.DISCORDPLACE_API_KEY}`,
},
});
if (!res.ok) throw new Error(`DiscordPlace ${res.status}`);
const data = await res.json();import os
import requests
res = requests.get(
"https://discordplace.com/api/v1/bot",
headers={"Authorization": f"Bearer {os.environ['DISCORDPLACE_API_KEY']}"},
timeout=10,
)
res.raise_for_status()
data = res.json(){
"id": "1234567890123456789",
"name": "Example Bot",
"status": "published",
"online": true,
"lastSyncAt": "2026-10-13T09:55:00.000Z",
"servers": 1250,
"votes": {
"day": 3,
"week": 12,
"month": 41,
"total": 230
},
"rating": {
"average": 4.7,
"count": 9
},
"url": "https://discordplace.com/bots/1234567890123456789",
"sdk": {
"latest": "1.9.1"
}
}- status is "pending" while your bot waits for approval: votes, rating and url are then null.
- online is true while your syncs keep arriving at their usual pace. It is not read from Discord.
POST/bot/stats
Send your stats (heartbeat)
Updates the numbers on your listing and counts as a heartbeat: the "online" dot on your page depends on these calls arriving regularly. Call it every 5 minutes, not once at startup. Every field is optional; a field you leave out keeps its value.
Bot key60 per minute, per key
Body
guildCountinteger- Servers the bot is in.
userCountinteger- Sum of the member counts of those servers. Shown as "members across its servers", never as a user count.
latencynumber- Gateway ping in milliseconds.
shardCountinteger- Number of shards.
uptimeinteger- Process uptime in milliseconds.
versionstring- Your bot's own version string (50 characters at most).
Request
curl -X POST "https://discordplace.com/api/v1/bot/stats" \
-H "Authorization: Bearer $DISCORDPLACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"guildCount":1250,"userCount":310000,"latency":84,"shardCount":2}'const res = await fetch('https://discordplace.com/api/v1/bot/stats', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DISCORDPLACE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"guildCount": 1250,
"userCount": 310000,
"latency": 84,
"shardCount": 2
}),
});
if (!res.ok) throw new Error(`DiscordPlace ${res.status}`);
const data = await res.json();import os
import requests
res = requests.post(
"https://discordplace.com/api/v1/bot/stats",
headers={"Authorization": f"Bearer {os.environ['DISCORDPLACE_API_KEY']}"},
json={
"guildCount": 1250,
"userCount": 310000,
"latency": 84,
"shardCount": 2,
},
timeout=10,
)
res.raise_for_status()
data = res.json(){
"success": true,
"message": "Statistiques mises à jour",
"data": {
"id": "1234567890123456789",
"guildCount": 1250,
"isOnline": true,
"lastStatsUpdate": "2026-10-13T10:00:00.000Z"
}
}POST/bot/sync
Send your commands and profile
Everything /bot/stats takes, plus your name, avatar and the full list of your commands, which replaces the one on your listing. Send the whole list each time, at most 200 slash and 200 prefix commands, 1 MB per request. Leave out commands only your team can use.
Bot key60 per minute, per key
Body
namestring- Your bot's current username.
avatarUrlstring- Your bot's avatar on cdn.discordapp.com. Any other address is ignored.
slashCommandsarray- Your slash commands and context menus.
prefixCommandsarray- Your prefix commands: name and description only. Do not include the prefix in the name.
guildCountinteger- Servers the bot is in.
userCountinteger- Sum of the member counts of those servers. Shown as "members across its servers", never as a user count.
latencynumber- Gateway ping in milliseconds.
shardCountinteger- Number of shards.
uptimeinteger- Process uptime in milliseconds.
versionstring- Your bot's own version string (50 characters at most).
Request
curl -X POST "https://discordplace.com/api/v1/bot/sync" \
-H "Authorization: Bearer $DISCORDPLACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Example Bot","guildCount":1250,"slashCommands":[{"name":"ban","description":"Ban a member","options":[{"name":"member","description":"Who","type":"USER","required":true}]},{"name":"ticket","description":"Open a support ticket"}],"prefixCommands":[{"name":"help","description":"List commands"}]}'const res = await fetch('https://discordplace.com/api/v1/bot/sync', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DISCORDPLACE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"name": "Example Bot",
"guildCount": 1250,
"slashCommands": [
{
"name": "ban",
"description": "Ban a member",
"options": [
{
"name": "member",
"description": "Who",
"type": "USER",
"required": true
}
]
},
{
"name": "ticket",
"description": "Open a support ticket"
}
],
"prefixCommands": [
{
"name": "help",
"description": "List commands"
}
]
}),
});
if (!res.ok) throw new Error(`DiscordPlace ${res.status}`);
const data = await res.json();import os
import requests
res = requests.post(
"https://discordplace.com/api/v1/bot/sync",
headers={"Authorization": f"Bearer {os.environ['DISCORDPLACE_API_KEY']}"},
json={
"name": "Example Bot",
"guildCount": 1250,
"slashCommands": [
{
"name": "ban",
"description": "Ban a member",
"options": [
{
"name": "member",
"description": "Who",
"type": "USER",
"required": True,
},
],
},
{
"name": "ticket",
"description": "Open a support ticket",
},
],
"prefixCommands": [
{
"name": "help",
"description": "List commands",
},
],
},
timeout=10,
)
res.raise_for_status()
data = res.json(){
"success": true,
"message": "Informations synchronisées avec succès",
"data": {
"id": "1234567890123456789",
"name": "Example Bot",
"guildCount": 1250,
"slashCommandCount": 2,
"prefixCommandCount": 1,
"isOnline": true,
"lastStatsUpdate": "2026-10-13T10:00:00.000Z"
}
}GET/bot/votes/{userId}
Has this user voted for your bot?
True while the user's last vote is less than 12 hours old. Use it to reward voters: check when they run your reward command, no public server needed. A user who never voted returns voted: false, not an error.
Bot key600 per minute, per key
Parameters
userId- Discord ID of the user.
Request
curl "https://discordplace.com/api/v1/bot/votes/9876543210987654321" \
-H "Authorization: Bearer $DISCORDPLACE_API_KEY"const res = await fetch('https://discordplace.com/api/v1/bot/votes/9876543210987654321', {
headers: {
Authorization: `Bearer ${process.env.DISCORDPLACE_API_KEY}`,
},
});
if (!res.ok) throw new Error(`DiscordPlace ${res.status}`);
const data = await res.json();import os
import requests
res = requests.get(
"https://discordplace.com/api/v1/bot/votes/9876543210987654321",
headers={"Authorization": f"Bearer {os.environ['DISCORDPLACE_API_KEY']}"},
timeout=10,
)
res.raise_for_status()
data = res.json(){
"success": true,
"voted": true,
"votedAt": "2026-10-13T08:12:00.000Z",
"expiresAt": "2026-10-13T20:12:00.000Z"
}- The bot is always the one your key belongs to: you cannot read another bot's votes.
- expiresAt is when the user can vote again. A pending bot gets 403 bot_not_approved.
POST/bot/usage
Report usage (optional)
Counters accumulated since your last report: commands run, interactions left unanswered, servers gained and lost, daily active users. They feed the "Activity and usage" section of your listing, marked as declared by the bot. Aggregates only: never send a user or server ID. Send a new batchId each time; resending the same one is ignored, so a retry is safe.
Bot key60 per minute, per key
Body
batchIdrequiredstring- Unique ID of this report, 8 to 64 characters (letters, digits, _ and -).
daystring- UTC day the counters belong to, YYYY-MM-DD. Today or yesterday only. Defaults to today.
usageobject- interactions, commands, components, modals, autocompletes, unanswered, guildsAdded, guildsRemoved, activeUsers, and commandCounts (a map of command name to count).
telemetryobject- Optional: library, runtime, guildLocales and guildSizes (maps of counts).
Request
curl -X POST "https://discordplace.com/api/v1/bot/usage" \
-H "Authorization: Bearer $DISCORDPLACE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"batchId":"a3f9c2e17b6d4f08","usage":{"interactions":420,"commands":310,"unanswered":4,"guildsAdded":3,"guildsRemoved":1,"activeUsers":96,"commandCounts":{"ban":40,"ticket":120}}}'const res = await fetch('https://discordplace.com/api/v1/bot/usage', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.DISCORDPLACE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"batchId": "a3f9c2e17b6d4f08",
"usage": {
"interactions": 420,
"commands": 310,
"unanswered": 4,
"guildsAdded": 3,
"guildsRemoved": 1,
"activeUsers": 96,
"commandCounts": {
"ban": 40,
"ticket": 120
}
}
}),
});
if (!res.ok) throw new Error(`DiscordPlace ${res.status}`);
const data = await res.json();import os
import requests
res = requests.post(
"https://discordplace.com/api/v1/bot/usage",
headers={"Authorization": f"Bearer {os.environ['DISCORDPLACE_API_KEY']}"},
json={
"batchId": "a3f9c2e17b6d4f08",
"usage": {
"interactions": 420,
"commands": 310,
"unanswered": 4,
"guildsAdded": 3,
"guildsRemoved": 1,
"activeUsers": 96,
"commandCounts": {
"ban": 40,
"ticket": 120,
},
},
},
timeout=10,
)
res.raise_for_status()
data = res.json(){
"success": true,
"duplicate": false
}GET/bots/{id}
Public listing of a bot
What anyone can see about a published bot: votes, rating, servers. No key needed, readable from a browser on any site. Use it to show your DiscordPlace numbers on your own site or dashboard.
No key200 per minute, per IP address
Parameters
id- Discord application ID of the bot.
Request
curl "https://discordplace.com/api/v1/bots/1234567890123456789"const res = await fetch('https://discordplace.com/api/v1/bots/1234567890123456789');
if (!res.ok) throw new Error(`DiscordPlace ${res.status}`);
const data = await res.json();import requests
res = requests.get(
"https://discordplace.com/api/v1/bots/1234567890123456789",
timeout=10,
)
res.raise_for_status()
data = res.json(){
"id": "1234567890123456789",
"name": "Example Bot",
"summary": "Moderation and tickets for small communities.",
"avatarUrl": "https://cdn.discordapp.com/avatars/1234567890123456789/a1b2c3.webp?size=256",
"tags": [
"MODERATION",
"TICKETS"
],
"verified": false,
"online": true,
"servers": 1250,
"commands": 42,
"votes": {
"day": 3,
"week": 12,
"month": 41,
"total": 230
},
"rating": {
"average": 4.7,
"count": 9
},
"listedAt": "2026-03-23T10:00:00.000Z",
"url": "https://discordplace.com/bots/1234567890123456789",
"widgetUrl": "https://discordplace.com/api/widget/bot/1234567890123456789"
}- servers, commands and rating are null when they were never measured. Show nothing rather than 0.
- votes.day, week and month are rolling windows (24 hours, 7 and 30 days). Responses are cached for 60 seconds.
Vote webhook
A user voted for your bot
If you set a webhook address in the SDK tab, DiscordPlace sends this to it at every vote. It needs an address reachable from the internet; if your bot has none, use GET /bot/votes/{userId} instead. Answer 2xx within 5 seconds, otherwise the delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. Each vote has a unique id: ignore one you already handled.
What you receive
{
"type": "vote",
"id": "cmg1x2y3z0001",
"botId": "1234567890123456789",
"userId": "9876543210987654321",
"votedAt": "2026-10-13T08:12:00.000Z",
"nextVoteAt": "2026-10-13T20:12:00.000Z"
}X-DiscordPlace-Signature- t is a Unix timestamp, v1 is HMAC-SHA256(secret, "<t>.<raw body>") in hex. Compute it on the raw body, before any JSON parsing, and reject a timestamp older than 5 minutes.
Verify the signature
import crypto from 'node:crypto';
// rawBody: the exact bytes you received, before any JSON.parse
export function verify(secret, rawBody, header) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || '');
if (!m) return false;
if (Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; // replay
const expected = crypto.createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest();
const received = Buffer.from(m[2], 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}import hashlib, hmac, re, time
# raw_body: the exact bytes you received, before any json.loads
def verify(secret: str, raw_body: bytes, header: str) -> bool:
m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not m:
return False
if abs(time.time() - int(m.group(1))) > 300: # replay
return False
signed = m.group(1).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m.group(2))- The "Send a test" button of the SDK tab sends the same body with type: "test".
Errors
Every refusal is JSON with success: false and an error field. Branch on the HTTP status.
- 400
- The body or a parameter is not valid.
- 401
- No key, or a key that is unknown or was regenerated. The SDK tab of your bot tells you if your bot is still sending an old key.
- 403
- Not available to a bot that is still waiting for approval.
- 404
- Unknown bot, or a bot that is not published.
- 413
- Body larger than 1 MB.
- 429
- Too many requests. Wait for the number of seconds in the Retry-After header.
- 500
- Our fault. Retry later; nothing was written.
Rate limits
- Limits are per minute. Calls made with a key are counted per key, so two bots on the same host do not share a budget.
- On top of that, one IP address cannot send more than 100 writes per minute in total.
- One sync every 5 minutes is the expected pace. More often changes nothing on your listing.
Prefer a library?
The official packages make these same calls for you, on a timer, and collect your commands automatically.
npm install discordplace-syncpip install discordplace-syncThe paths under /api/bots/sdk used by installed SDKs keep working and are the same handlers: nothing to migrate.