Build on Echoed
120+ documented REST endpoints, real-time WebSocket events, webhooks, OAuth2, LiveKit voice and a platform for games that run inside chats. Build moderation and anti-raid bots, leveling systems, polls, reaction roles, music bots, forum helpers — or a multiplayer game your friends play in a DM.
Introduction
Echoed has three ways to build on it:
- Bots — accounts that join servers and act through the Bot API and real-time events: moderation, anti-raid, levels, music, forum helpers, anything a member could do.
- Webhooks — a URL that posts into one channel. No account, no socket; ideal for CI, alerts and feeds. See Webhooks.
- Apps — "Login with Echoed" through OAuth2, and games that people play inside a DM or a server's game lounge.
Quick Start (bots)
- Create a bot in Settings → Developer → Bot Profile
- Copy its Bot API Key (starts with
zbot_) - Submit it for review. Until it is approved, add it to your own test server with a development invite token
- Call the API with the
X-Bot-Tokenheader, and connect to the socket to receive events
The fastest way to learn the API is to read a real bot: see Example Bots.
Authentication
All Bot API requests carry your bot's API key. X-Bot-Token is the usual header; X-Bot-API-Key and Authorization: Bot <key> are accepted too, checked in that order.
X-Bot-Token: zbot_your_api_key_here Content-Type: application/json
A bot must pass review before it can use the API. Until then every call answers 403 with "type": "bot_not_approved" — except in servers that added it with a development invite token. Changing the bot's name, description or avatar sends it back to the review queue automatically.
A few endpoints are marked user login. Those are called by a person (the bot's owner, or a server admin) with their normal Echoed session in Authorization: Bearer <token>, not with the bot key. They are what the Echoed app itself uses, documented so you can script them.
Base URL
All Bot API endpoints are prefixed with:
https://go.echoed.gg/v1/bots
For example, to validate your token: GET https://go.echoed.gg/v1/bots/validate
Rate Limits
The Bot API enforces a per-bot sliding-window rate limit of 120 requests per minute across all endpoints. When the limit is hit, the API returns 429 Too Many Requests with a retryAfter field (seconds) in the response body.
{
"message": "Rate limited. Please wait.",
"code": 429,
"retryAfter": 12
}
messages/bulk-delete endpoint over many single deletes — one bulk request handles up to 100 messages.Error Handling
The API returns standard HTTP status codes. Error responses include a JSON body with a message field.
| Status | Meaning |
|---|---|
200 | Success |
201 | Created (new resource) |
400 | Bad Request — invalid parameters |
401 | Unauthorized — invalid or missing token |
403 | Forbidden — insufficient permissions |
404 | Not Found — resource doesn't exist |
409 | Conflict — e.g., already banned |
429 | Too Many Requests — rate limit exceeded (see Rate Limits) |
500 | Internal Server Error |
503 | Service Unavailable — database/transient |
{
"message": "Bot is not a member of this server",
"success": false
}
Example Bots
Two production bots are open source. Both are TypeScript and use exactly the API on this page. Start from whichever is closer to what you are building.
| Repository | What it shows |
|---|---|
| Echoed_Panda_BOT | A full multipurpose bot: levels with role rewards, moderation and auto-mod, anti-raid (lockdown + verification level), welcome flows, reaction roles, custom commands, scheduled messages, music in voice channels, social-feed alerts, and per-command channel/role rules. Node 20 + socket.io-client + Postgres. Its web dashboard (dashboard/, Next.js) signs people in with Echoed OAuth2. Read it for the event loop, permission checks, voice, and how a large bot is organised. |
| memebot | A single-purpose bot in a few files: listens for !meme, fetches from Reddit, posts an image. Per-channel cooldowns, caching, NSFW filtering and reconnect handling. Read it first if you have never written a bot — it is the minimum a well-behaved bot needs. |
git clone https://github.com/shaongitbd/memebot
cd memebot
npm install
cp .env.example .env # set BOT_TOKEN to your zbot_ key
npm run dev
Testing Before Review
Review takes time, and you need somewhere to run your bot meanwhile. A development invite token (edev_…) lets the admin of a server add your unreviewed bot there, after seeing a warning that it has not been reviewed and which permissions it asks for.
- One token per bot. It opens at most 5 servers and expires after 30 days.
- Generating a new one replaces the old one. Servers that already added the bot keep it.
- Approval deletes the token: from then on, admins add the bot from the directory.
- The bot stays hidden from discovery and locked out of every other server until it is approved.
You can do all of this from Settings → Developer → Bot Profile. The endpoints below are what that screen calls. They take a user login: the bot's owner signed in as the bot account, or the receiving server's admin.
{
"token": "edev_3f9c…",
"maxServers": 5,
"usedSlots": 1,
"approved": false,
"createdAt": "2026-09-20T10:00:00Z",
"expiresAt": "2026-10-20T10:00:00Z",
"expired": false
}
201 with the same shape as GET, or 409 bot_already_approved if the bot has passed review.{ "token": "edev_3f9c…" }
{
"botId": "bot-user-id",
"name": "Panda Dev",
"avatar": "https://…",
"description": "…",
"developer": "…",
"requestedPermissions": 4352, // send_messages + add_reactions
"reviewState": "pending",
"approved": false,
"slotsRemaining": 4
}
MANAGE_SERVER. acknowledgeRisk must be true.{
"token": "edev_3f9c…",
"grantedPermissions": 0,
"acknowledgeRisk": true
}
Bot Profile
{
"valid": true,
"bot_id": "67e537d200011f5275d2",
"message": "Bot API key is valid."
}
{
"id": "67e537d200011f5275d2",
"username": "my-awesome-bot",
"isBot": true,
"botDescription": "A helpful server bot",
"botCategory": "utility",
"isPublic": true,
"avatarUrl": "https://s3.echoed.gg/..."
}
PATCH /v1/bots/me below.{
"botDescription": "Updated bot description",
"botCategories": ["utility", "moderation"],
"botSupportServer": "https://echoed.gg/invite/...",
"botVersion": "1.2.0"
}
name and/or avatar URL. At least one field must be present. Pass an empty string for avatar to clear it. name is bounded to 32 characters; avatar URLs are bounded to 1024 characters. The bot-key lookup table is automatically synced so cached display data stays consistent.{
"name": "Panda Bot",
"avatar": "https://cdn.example.com/panda.png"
}
{
"success": true,
"name": "Panda Bot",
"avatar": "https://cdn.example.com/panda.png"
}
Per-Server Customization
A bot can have a different display name in each server it's a member of — the same way a regular user does. Useful for region-specific deployments ("Panda EU" vs "Panda NA"), themed servers, or matching a server's naming convention. The per-server name shadows the global one for messages, member lists, and mentions.
memberNicknameUpdated socket event so clients update without refetching.{
"nickname": "Panda EU"
}
{
"success": true,
"nickname": "Panda EU"
}
name in this server.multipart/form-data with a single file field. Allowed formats: png, jpg, jpeg, gif, webp. Each upload replaces the previous server avatar for this bot in this server (old objects are deleted from storage). Realtime broadcast: every member of the server receives a memberAvatarUpdated socket event.{
"success": true,
"path": "public/servers/server-id/members/bot-id/avatar.png",
"url": "https://s3.echoed.gg/public/servers/.../avatar.png",
"timestamp": 1735531200000
}
avatar in this server.Member Customization
Admin-style endpoints — the bot edits other members' per-server display data. Useful for moderation bots that implement !nick @user newname style commands. Required permission on the bot: MANAGE_SERVER. The server owner cannot be modified.
Authority is the bot's responsibility. These endpoints check the bot's perm. The command-issuing user's authority should be checked by the bot before calling — for example, gating !nick on the sender holding MANAGE_SERVER.
{
"nickname": "NewName"
}
{
"success": true,
"userId": "target-user-id",
"nickname": "NewName"
}
file field. Allowed formats: png, jpg, jpeg, gif, webp.{
"success": true,
"userId": "target-user-id",
"path": "public/servers/.../avatar.png",
"url": "https://s3.echoed.gg/...",
"timestamp": 1735531200000
}
Voice
Bots can join a voice channel and publish audio (music, TTS, soundboards, etc.). Echoed uses LiveKit as the SFU; the bot endpoint mints an AccessToken so the bot can connect via @livekit/rtc-node (or any LiveKit client SDK) and publish a microphone track.
Bot must hold CONNECT + SPEAK in the target channel (channel-scoped — overrides honored). Returned token is a 24-hour LiveKit AccessToken with canPublishSources: ['microphone']; it cannot be used for video or screen-share.
callId = channelId).{
"success": true,
"url": "wss://livekit.echoed.gg",
"token": "eyJhbGciOiJIUzI1NiIs...",
"room": "call-channel-id",
"callId": "channel-id",
"identity": "bot-user-id",
"expiresIn": 86400
}
Connect with livekit.Room.connect(url, token), then publish a LocalAudioTrack backed by an AudioSource (48 kHz, 2 channels). Frame size: 20 ms (960 samples per channel).
Room.disconnect()); this endpoint is for bookkeeping and audit logs.null. This is how a music bot's !play follows the person who asked, instead of making them name a channel.{ "channelId": "voice-channel-id" } // or { "channelId": null }
Servers
{
"count": 2,
"servers": [
{
"serverId": "6894d3d90011f842607c",
"serverName": "My Server",
"serverIcon": "",
"invitedAt": "2026-01-06T13:41:44Z",
"invitedBy": "user-id"
}
]
}
{
"id": "6894d3d90011f842607c",
"name": "My Server",
"description": "A cool server",
"ownerId": "user-id",
"memberCount": 42,
"channelCount": 8,
"iconUrl": "",
"createdAt": "2025-08-07T16:27:05Z"
}
Channels
{
"channels": [
{
"id": "647a23fede03765e0348",
"name": "general",
"type": "text",
"description": "",
"createdAt": "2026-01-05T11:20:43Z"
}
],
"total": 8
}
text, video, tasks, calendar. Requires MANAGE_CHANNELS.{
"name": "bot-updates",
"type": "text",
"description": "Automated bot announcements",
"isPrivate": false,
"isNsfw": false,
"categoryId": "category-id",
"position": 0
}
MANAGE_CHANNELS.{
"name": "renamed-channel",
"description": "Updated description",
"isPrivate": false,
"isNsfw": true,
"slowModeSeconds": 10,
"categoryId": "new-category-id",
"position": 2
}
slowModeSeconds is clamped to 0–21600 (6 hours).
{
"channelId": "channel-id",
"categoryId": "category-id",
"position": 0
}
Channel Permissions
Per-channel overrides for a role or a single member: what a bot needs to lock a channel during a raid, hide a mod-log from members, or build a ticket channel only its opener can see. They work exactly like the override editor in the app.
- The bot needs
MANAGE_CHANNELS. - Every permission the bot changes must be one it holds in that channel itself, unless it is an administrator. A bot cannot hand out rights it was never given.
target_typeisroleoruser. A role target is a role id from this server, oreveryone. A user target must be a member.- Unknown permission names are rejected, not ignored.
{
"channelId": "channel-id",
"permissionOverrides": [
{
"targetType": "role",
"targetId": "everyone",
"allow": [],
"deny": ["send_messages", "add_reactions"],
"allowBits": 0,
"denyBits": 4352
}
]
}
PUT that allows and denies nothing removes the override.{
"allow": [],
"deny": ["send_messages", "add_reactions"]
}
Permission Names
view_channels manage_channels manage_roles manage_server create_invite kick_members ban_members administrator send_messages read_message_history use_external_emojis add_reactions attach_files embed_links manage_messages connect speak mute_members deafen_members move_members use_voice_activity priority_speaker use_camera screen_share create_tasks create_threads create_events
Use allowBits / denyBits instead of names if you prefer. Bits outside what a channel override can store are rejected.
Categories
{
"name": "Bot Channels",
"position": 0
}
{
"categories": [
{ "categoryId": "id1", "position": 0 },
{ "categoryId": "id2", "position": 1 }
]
}
Members
| Parameter | Type | Description |
|---|---|---|
limit | integer | Max results (default 10) |
offset | integer | Pagination offset |
MANAGE_MESSAGES before running a moderation command.Optional query parameter — ?channel_id={id}: when supplied, the returned list reflects per-channel role/user overrides. Without it the response is server-level only. Use the channel-scoped form for any command that operates on a specific channel (e.g. !purge in #announcements should check MANAGE_MESSAGES there, not server-wide).
{
"userId": "user-id",
"serverId": "server-id",
"permissions": [
"VIEW_CHANNELS", "SEND_MESSAGES",
"READ_MESSAGE_HISTORY", "CONNECT", "SPEAK"
]
}
{
"userId": "user-id",
"serverId": "server-id",
"channelId": "channel-id",
"permissions": [
"VIEW_CHANNELS", "SEND_MESSAGES",
"READ_MESSAGE_HISTORY", "MANAGE_MESSAGES"
]
}
Moderation
Punitive member actions: kick, ban, unban, and timeout. Each requires the matching permission on the bot. The server owner cannot be targeted.
KICK_MEMBERS.{ "reason": "Optional reason" }
BAN_MEMBERS. Returns 409 if already banned.{ "reason": "Reason for ban" }
BAN_MEMBERS.Timeout
Timeouts mute a member for a duration: they cannot send messages or join voice. Enforcement is server-side, so clients can't bypass it. Requires MUTE_MEMBERS.
durationSeconds (capped at 28 days). Re-applying overwrites the previous timeout.{
"durationSeconds": 300,
"reason": "Spamming"
}
{
"success": true,
"userId": "user-id",
"timeoutUntil": "2026-04-28T17:25:00Z",
"durationSeconds": 300,
"reason": "Spamming"
}
active: false if no timeout is set or it has expired.{
"active": true,
"userId": "user-id",
"timeoutUntil": "2026-04-28T17:25:00Z",
"reason": "Spamming",
"setBy": "bot-id"
}
Anti-Raid & Safety
The server-level safety controls, open to bots so an anti-raid detector can close a real door instead of kicking accounts one by one after they arrive. All four endpoints need MANAGE_SERVER — the same permission a person needs to change these settings in the app.
untilSeconds (30 to 86,400). Members already in are unaffected. The lockdown ends by itself when the time runs out.{
"untilSeconds": 600,
"reason": "Raid detected: 25 joins in 10 seconds"
}
{
"success": true,
"until": "2026-09-27T18:10:00Z",
"reason": "Raid detected: 25 joins in 10 seconds"
}
{ "success": true } whether or not one was active.previous level afterwards. Members who have any role are never held back.| Level | Requirement |
|---|---|
0 | None |
1 | Verified email |
2 | Verified email, account at least 5 minutes old |
3 | Verified email, account at least 5 days old, member of the server for 30 minutes |
{ "level": 2 }
{ "success": true, "level": 2, "previous": 0 }
SERVER_MEMBER_ADD arrives.{
"userId": "user-id",
"username": "newcomer",
"displayName": "Newcomer",
"avatarUrl": "",
"hasAvatar": false,
"accountAgeSeconds": 312,
"createdAt": "2026-09-27T17:55:00Z"
}
src/antiRaid is a complete detector built on these endpoints: it counts joins, locks down, raises the verification level, and restores both when the wave passes.Roles
MANAGE_ROLES. permissions accepts an array of permission names or an integer bitmask.{
"name": "Moderator",
"color": "#FF5733",
"permissions": ["SEND_MESSAGES", "MANAGE_MESSAGES", "KICK_MEMBERS"],
"position": 5
}
@everyone, Server Owner) cannot be edited. Requires MANAGE_ROLES.{
"name": "Senior Moderator",
"color": "#9B59B6",
"permissions": ["MANAGE_MESSAGES", "KICK_MEMBERS", "BAN_MEMBERS"],
"position": 8,
"mentionable": true,
"hoist": true
}
{
"roles": ["role-id-1", "role-id-2"]
}
Messages
limit (max 100), before, after for pagination.content may be empty as long as embeds or attachmentIds is provided. See Rich Embeds for the embed object schema (max 10 per message).{
"channelId": "channel-id",
"content": "Hello from my bot! 🤖",
"attachmentIds": [],
"mentions": [],
"replyToId": "",
"embeds": []
}
Sending Messages with Attachments
To send a message with file attachments, upload the file first, then reference the returned fileId in your message.
multipart/form-data with a file field. Max file size: 50 MB. Supported types: images, videos, audio, documents, archives, and code files.{
"success": true,
"fileId": "abc123",
"path": "channels/server-id/channel-id/abc123.png",
"url": "https://s3.echoed.gg/files/channels/...",
"filename": "screenshot.png",
"size": 184320,
"contentType": "image/png",
"width": 1920,
"height": 1080,
"blurhash": "U.P,rXfQkCbH"
}
width, height, and blurhash are only returned for image uploads.
{
"channelId": "channel-id",
"content": "Check out this image!",
"attachmentIds": ["abc123"],
"mentions": [],
"replyToId": ""
}
MANAGE_MESSAGES. Either content or embeds (or both) can be supplied; omitted fields are left untouched. Pass an empty embeds array to clear all embeds while keeping content.{
"content": "Edited message content"
}
{
"embeds": [
{
"type": "rich",
"title": "Now playing",
"description": "▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱\n2:14 / 6:42",
"color": 16763688
}
]
}
Edits are emitted to all server members as a message:updated realtime event so clients re-render in place — useful for progress-bar embeds in music bots.
MANAGE_MESSAGES.MANAGE_MESSAGES.{
"messageIds": ["id1", "id2", "id3"]
}
{
"success": true,
"deleted": ["id1", "id3"],
"count": 2
}
Rich Embeds
Bots can ship up to 10 rich embeds per message — coloured cards with a title, description, fields, thumbnail/image, author and footer. Pass an embeds array on POST /v1/bots/{server_id}/messages/send. content may be empty when at least one embed is present.
Embed Object
Every field is optional except type. Use "rich" for bot-authored cards.
{
"type": "rich", // "rich" | "image" | "video" | "gifv" | "article" | "link" | "audio"
"url": "https://...", // makes the title clickable
"title": "Card title",
"description": "Body copy. Markdown is rendered.",
"color": 16763688, // 0xFFC928 — left-bar accent, decimal int
"timestamp": "2026-04-29T12:00:00Z", // ISO-8601, rendered next to footer
"author": { "name": "…", "url": "…", "icon_url": "…" },
"thumbnail": { "url": "https://…/thumb.png" },
"image": { "url": "https://…/banner.png" },
"fields": [
{ "name": "Field", "value": "Value", "inline": true }
],
"footer": { "text": "Footer copy", "icon_url": "…" }
}
Sub-Objects
{
"name": "Author or site name",
"url": "https://…",
"icon_url": "https://…/avatar.png"
}
{
"url": "https://…/asset",
"width": 1280,
"height": 720
}
{
"name": "Heading",
"value": "Body — markdown supported.",
"inline": false
}
{
"text": "3 active",
"icon_url": "https://…/icon.png"
}
Example: Embed-Only Message
Empty content with one rich embed — the canonical pattern for status / list / leaderboard cards.
{
"channelId": "channel-id",
"content": "",
"embeds": [
{
"type": "rich",
"title": "Leaderboard",
"description": "**1.** alice — 12,400 xp\n**2.** bob — 9,810 xp\n**3.** carol — 7,205 xp",
"color": 16763688,
"footer": { "text": "Top 3 of 142" },
"timestamp": "2026-04-29T12:00:00Z"
}
]
}
Limits & Notes
- Up to 10 embeds per message — extras are silently dropped.
coloris a decimal integer (e.g.0xFFC928→16763688). Omit for the client's default neutral.timestampmust be ISO-8601 (UTC). Pass an empty string to suppress.- Image/thumbnail/video/audio URLs are proxied through Echoed's media CDN — clients receive a signed
proxy_urlalongside your originalurl. - Auto-unfurled link embeds (when users post URLs) use the same shape — your bot can mirror that look by sending a rich embed of
type: "rich"with anauthorblock.
Reactions
Bots can add and remove their own reactions on messages. Useful for polls (seed 👍/👎), giveaways (seed 🎉), and reaction-role setup messages.
To listen for reactions added by other users, subscribe to the MESSAGE_REACTION_ADD WebSocket event.
{emoji} path segment must be URL-encoded. Idempotent — re-adding an existing reaction is a no-op. Requires ADD_REACTIONS in the channel.Pinned Messages
Pin and unpin messages in a channel. Both endpoints require MANAGE_MESSAGES in the target channel.
Direct Messages
{
"username": "target-user",
"content": "Hey! This is a DM from my bot."
}
{
"message": "DM sent successfully.",
"messageId": "message-id",
"receiverId": "user-id",
"content": "Hey! This is a DM from my bot."
}
Threads & Forums
A thread channel is a forum: a list of posts, each with a title, an optional category, and a flat conversation of messages. The post's body is its first message; replies are ordinary messages after it. A bot's post is identical to a person's apart from its message type.
Paths take the channel id right after the server id. Reading needs read_threads, opening a post needs create_threads and send_messages, and pin, lock and category changes need manage_threads.
PUT / DELETE /v1/bots/{server_id}/messages/{message_id}). Thread messages are channel messages.Posts
limit (default 25, max 50), sort (activity | created), category_id, state (open | closed), cursor. Pinned posts come first on page one. Pass nextCursor back as cursor until hasMore is false.{
"posts": [
{
"id": "post-id",
"title": "Patch 1.4 feedback",
"categoryId": "category-id",
"authorId": "user-id",
"rootMessageId": "message-id",
"isPinned": false, "isLocked": false, "isClosed": false,
"replyCount": 12,
"excerpt": "First impressions after a weekend of…",
"previewImages": [],
"lastActivityAt": "2026-09-27T16:40:00Z",
"createdAt": "2026-09-26T09:12:00Z"
}
],
"total": 48,
"nextCursor": "opaque-cursor",
"hasMore": true
}
content becomes its first message and takes everything a message does: attachments (upload first), mentions, embeds. Returns 201 with post and rootMessage.{
"title": "Weekly patch notes",
"content": "Here is what changed this week…",
"categoryId": "category-id",
"attachmentIds": [],
"mentions": [],
"embeds": []
}
{ "title": "…", "categoryId": "…" }, either field optional. The bot must be the post's author or hold manage_threads.manage_threads.Moderation
{ "postId": "…", "isPinned": true }.isLocked). A locked post takes no replies from anyone without manage_threads.manage_threads holder.Messages
limit (default 50, max 100), after (the previous page's nextAfter).201 with messageId and the reply.{
"content": "Fixed in 1.4.1, thanks for the report.",
"replyToId": "message-id",
"attachmentIds": [],
"mentions": [],
"embeds": []
}
q, post_id, author_id, author_name, before, after, sort, has, mentions, limit, offset — the same filters as message search.Following & Read State
DELETE on the same path unfollows. Returns { "success": true, "postId": "…", "isFollowing": true }.Categories
Forum categories are per server and shared by all its thread channels. Changing them needs manage_threads.
{ "categories": [ … ] }.{ "name": "Bug reports", "description": "…" }. Returns 201 with category.name, description or isActive, each optional.{ "categoryOrder": ["id-1", "id-2", …] }. The list must hold every category in the server exactly once.Tasks
Manage tasks in Task-type channels. Your bot can create, update, and delete tasks for project management and automation.
{
"channelId": "task-channel-id",
"title": "Fix login bug",
"description": "Users can't login with OAuth",
"assigneeId": "user-id",
"dueDate": "2026-04-01T00:00:00Z",
"priority": "high"
}
| Field | Values |
|---|---|
priority | low, medium, high, urgent |
status | backlog, todo, in_progress, review, done |
Calendar Events
Manage events in Calendar-type channels. Create, update, and manage event participants.
{
"channelId": "calendar-channel-id",
"title": "Game Night",
"description": "Weekly gaming session",
"startTime": "2026-04-01T20:00:00Z",
"endTime": "2026-04-01T23:00:00Z",
"location": "Voice Channel #gaming",
"isAllDay": false
}
Event Participants
{ "userId": "user-id" }
{ "userId": "user-id" }
Search
| Parameter | Type | Description |
|---|---|---|
q | string | Search query (min 2 chars, required) |
channel_id | string | Filter by channel |
author_id | string | Filter by message author |
before | ISO 8601 | Messages before this date |
after | ISO 8601 | Messages after this date |
sort | string | newest (default) or oldest |
limit | integer | Max results |
offset | integer | Pagination offset |
Audit Logs
Read the server audit log to enrich a mod-log channel, build moderation dashboards, or reconcile bot actions with admin actions.
MANAGE_SERVER.| Parameter | Type | Description |
|---|---|---|
limit | integer | Max entries to return (1–100, default 50) |
before | ISO 8601 | Return entries before this timestamp |
action | string | Filter by action name (e.g. kick_member, timeout_member) |
{
"logs": [
{
"timestamp": "2026-04-28T17:20:00Z",
"logId": "log-id",
"actorId": "bot-id",
"action": "timeout_member",
"targetType": "member",
"targetId": "user-id",
"reason": "Spamming",
"details": { "durationSeconds": 300 }
}
],
"count": 1,
"serverId": "server-id",
"botId": "bot-id"
}
Directory & Installs
The public bot directory, and the calls a server admin makes to add or remove a bot. The install routes take a user login with MANAGE_SERVER, not a bot key — a bot cannot add itself anywhere. For an "Add to server" button on your own website, use the OAuth2 invite-bot endpoint instead.
page, limit (default 20, max 50), search, kind (official | community).{
"bots": [
{
"id": "bot-id",
"name": "Panda",
"avatar": "https://…",
"description": "Levels, moderation, music…",
"tags": ["moderation", "music"],
"developer": "…",
"website": "https://…",
"official": true,
"requestedPermissions": 4352
}
],
"pagination": { "page": 1, "limit": 20, "total": 37, "totalPages": 2 }
}
utility, moderation, fun, games, music, social, economy, …) as { "categories": [{ "value", "label", "description" }] }. Use these values as your bot's tags.MANAGE_SERVER.MANAGE_SERVER. An unreviewed bot is refused with 403; use a development token.{ "botUserId": "bot-id", "grantedPermissions": 4352 }
MANAGE_SERVER.WebSocket Events
Connect via Socket.IO at https://socket.echoed.gg to receive real-time events. Authenticate with your bot token after connecting; on success the bot is auto-subscribed to every server it's invited to — no separate subscribe step is required.
Connecting
// npm install socket.io-client import { io } from "socket.io-client"; const socket = io("https://socket.echoed.gg", { transports: ["websocket"], }); socket.on("connect", () => { socket.emit("authenticate", { botToken: "zbot_your_token_here", }); }); socket.on("authenticated", (payload) => { console.log("Bot ready, sessionId:", payload.sessionId); });
# pip install python-socketio[client] import socketio sio = socketio.Client() @sio.on("connect") def on_connect(): sio.emit("authenticate", {"botToken": "zbot_your_token_here"}) sio.connect("https://socket.echoed.gg") sio.wait()
Heartbeat
Send a heartbeat every 30 seconds to keep the connection alive. The token must be included on every heartbeat.
setInterval(() => {
socket.emit("heartbeat", { botToken: "zbot_your_token_here" });
}, 30000);
Event Types
Each Socket.IO event name is the literal event below (e.g. MESSAGE_CREATE). The payload is the event data directly — not wrapped in a {type, data} envelope. Listen for events by name:
socket.on("MESSAGE_CREATE", (data) => { // data.id, data.channelId, data.senderId, data.content, ... if (data.senderId === botUserId) return; // ignore own messages handleMessage(data); });
Available Events
| Event | Description |
|---|---|
| Messages | |
MESSAGE_CREATE | New message posted in any channel the bot can see |
MESSAGE_UPDATE | Message edited |
MESSAGE_DELETE | Message deleted |
MESSAGE_DELETE_BULK | Bulk delete — payload has messageIds array |
MESSAGE_EMBEDS_UPDATE | Async URL unfurl completed for a message |
| Reactions | |
MESSAGE_REACTION_ADD | Reaction added to a message |
MESSAGE_REACTION_REMOVE | Reaction removed from a message |
MESSAGE_REACTION_REMOVE_ALL | All reactions cleared from a message |
MESSAGE_REACTION_REMOVE_EMOJI | All instances of one emoji cleared |
| Members | |
SERVER_MEMBER_ADD | Member joined the server — trigger welcome flows |
SERVER_MEMBER_REMOVE | Member left the server |
SERVER_MEMBER_UPDATE | Member's roles changed |
SERVER_MEMBER_NICKNAME_UPDATE | Nickname changed |
SERVER_MEMBER_AVATAR_UPDATE | Server-specific avatar changed |
SERVER_MEMBER_KICK | Member was kicked |
SERVER_MEMBER_BAN | Member was banned |
| Channels & Categories | |
CHANNEL_CREATE | Channel created |
CHANNEL_UPDATE | Channel edited (name, description, slowmode, etc.) |
CHANNEL_DELETE | Channel deleted |
CHANNEL_REORDER | Channels reordered within a category |
CHANNEL_PINS_UPDATE | Pinned messages changed in a channel |
CATEGORY_CREATE / CATEGORY_UPDATE / CATEGORY_DELETE / CATEGORY_REORDER | Category lifecycle |
| Server | |
SERVER_CREATE | Server created |
SERVER_UPDATE | Server settings changed |
SERVER_DELETE | Server deleted |
SERVER_INVITE_CREATE | Invite created/refreshed |
| Threads & Forums | |
THREAD_POST_CREATE / THREAD_POST_UPDATE / THREAD_POST_DELETE | Post lifecycle, including pin, lock, close and reopen. Replies arrive as MESSAGE_CREATE |
THREAD_CATEGORY_CREATE / UPDATE / DELETE, THREAD_CATEGORIES_REORDER | Forum category changes |
| Tasks & Calendar | |
TASK_CREATE / TASK_UPDATE / TASK_DELETE | Task lifecycle |
CALENDAR_EVENT_CREATE / CALENDAR_EVENT_UPDATE / CALENDAR_EVENT_DELETE | Calendar event lifecycle |
CALENDAR_PARTICIPANT_JOIN / LEAVE / ADD / REMOVE | Event participation changes |
| Other | |
TYPING_START / TYPING_STOP | Typing indicators |
PRESENCE_UPDATE | User online/idle/dnd status changes |
NOTIFICATION_CREATE | New notification for a user |
UNREAD_COUNT_UPDATE | Unread count changed |
PERMISSION_UPDATE | Permission cache invalidated — re-fetch member permissions |
MESSAGE_CREATE Payload
{
"id": "message-id",
"channelId": "channel-id",
"serverId": "server-id",
"senderId": "user-id",
"content": "Hello everyone!",
"messageType": "user",
"attachments": [],
"mentions": [],
"replyToId": "",
"createdAt": "2026-04-28T17:00:00Z",
"author": {
"id": "user-id",
"name": "Username",
"avatarUrl": "https://...",
"isBot": false
}
}
data.senderId === botUserId — without this, every reply your bot posts triggers another MESSAGE_CREATE event and risks an infinite loop.Incoming Webhooks
A webhook is a secret URL that posts into one channel. No bot account, no socket, no review: anything that can make an HTTP request can use one. Good for CI results, deploy notices, monitoring alerts and RSS-style feeds. For anything that needs to read or react, write a bot.
Create one in the app under Server Settings → Webhooks (or Webhooks in a channel's right-click menu) and copy its URL. The URL contains the token, so whoever has it can post as the webhook.
content (up to 4,000 characters), embeds, or both. username and avatar_url override the webhook's name and picture for this one message. Embeds follow the same format and limits as bot embeds; mentions in content notify people as usual.{
"content": "Deploy finished",
"username": "CI",
"avatar_url": "https://example.com/ci.png",
"embeds": [
{
"title": "api v2.14.0",
"description": "All checks passed in 4m 12s",
"url": "https://ci.example.com/runs/812",
"color": 4886754,
"fields": [{ "name": "Branch", "value": "main", "inline": true }]
}
]
}
{ "success": true, "messageId": "message-id" }
curl -X POST "https://go.echoed.gg/api/webhooks/$ID/$TOKEN" \ -H "Content-Type: application/json" \ -d '{"content": "Build #812 passed"}'
| Status | Meaning |
|---|---|
400 | Neither content nor embeds, content over 4,000 characters, or an invalid embed (the message says which field) |
401 | Wrong token |
404 | The webhook was deleted, or its channel no longer exists |
Managing Webhooks
These take a user login with MANAGE_SERVER. Every webhook in the response carries its ready-to-use url.
{
"success": true,
"webhooks": [
{
"webhookId": "webhook-id",
"channelId": "channel-id",
"name": "CI",
"avatar": "https://…",
"token": "…",
"creatorId": "user-id",
"createdAt": "2026-09-27T12:00:00Z",
"url": "https://go.echoed.gg/api/webhooks/webhook-id/…"
}
]
}
{ "channelId": "…", "name": "CI", "avatar": "https://…" } (avatar optional). Returns { "success": true, "webhook": { … } }.name, avatar, channelId, each optional.OAuth2
Integrate "Login with Echoed" into your application, access user data, and invite bots to servers using the standard OAuth2 authorization code flow. The same OAuth2 app is also how you publish games.
Panda's dashboard is a working example: a Next.js app that signs people in with Echoed and reads their profile and servers from /oauth2/userinfo. The whole flow is in dashboard/lib/echoed.ts.
Authorization Flow
- Register an OAuth2 app in Settings → Developer → OAuth2 Apps and add your redirect URIs. The client secret is shown once — copy it then, or regenerate it later from the app
- Send the user to the authorization URL with your
client_idand the scopes you need - Echoed shows its consent screen, signing the user in first if needed. On approval it redirects back with an authorization code and your
state - Exchange the code for an access token (and a refresh token with
offline_access) - Use the access token to call the OAuth2 API endpoints
If the user denies, or the request is invalid, the redirect carries error and error_description instead of a code. Always check that state matches the one you sent.
https://go.echoed.gg/oauth2/authorize? response_type=code& client_id=your_client_id& redirect_uri=https://yourapp.com/callback& scope=openid profile email servers& state=random_csrf_token
This URL forwards to the consent screen on the Echoed web app. Link to it rather than to the consent page directly, so your integration keeps working if that page moves.
POST https://go.echoed.gg/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code& code=authorization_code_from_callback& redirect_uri=https://yourapp.com/callback& client_id=your_client_id& client_secret=your_client_secret
{
"access_token": "MKquTIm08LMU...",
"refresh_token": "dR4nG7kP...",
"expires_in": 3600,
"token_type": "Bearer"
}
POST https://go.echoed.gg/oauth2/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token& refresh_token=dR4nG7kP...& client_id=your_client_id& client_secret=your_client_secret
PKCE (apps without a server)
A single-page or mobile app cannot keep a client secret. Register it with client type Public and use PKCE: generate a random code_verifier, send its SHA-256 as code_challenge with code_challenge_method=S256 on the authorization URL, then send the code_verifier (and no secret) when exchanging the code. plain is accepted but gives no protection — use S256.
| Client type | Code exchange | Refresh |
|---|---|---|
| Confidential (default) | client_secret required; PKCE optional on top | client_secret required |
| Public | PKCE required, no secret | No secret |
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = base64url(new Uint8Array( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) )); // authorize: ...&code_challenge=${challenge}&code_challenge_method=S256 // token: grant_type=authorization_code&code=...&client_id=...&code_verifier=${verifier}
Scopes
| Scope | Access |
|---|---|
openid | Basic identity (user ID, issuer) |
profile | Name, username, avatar, created date |
email | Email address |
servers | User's servers, invite bots to servers |
friends | User's friends list |
games | Publish games on the Games platform. Players' profiles reach a game as id, username, display name and avatar only |
offline_access | Receive a refresh token for long-lived access |
Token Lifetimes
| Token | Lifetime |
|---|---|
| Authorization Code | 10 minutes |
| Access Token | 1 hour |
| Refresh Token | 30 days |
OAuth2 Endpoints
Bearer token in Authorization header.{
"sub": "user-id",
"name": "Display Name",
"username": "username",
"avatar_url": "https://s3.echoed.gg/...",
"email": "user@example.com",
"owned_servers": [
{ "id": "server-id", "name": "My Server", "type": "public" }
],
"servers_count": 5
}
servers scope.friends scope.servers scope and bot management permission.{ "bot_id": "bot-user-id" }
application/x-www-form-urlencoded.token=access_token_value& token_type_hint=access_token& client_id=your_client_id& client_secret=your_client_secret
/oauth2/revoke; a confidential client must include its client_secret. Returns { "active": false } for a token that is unknown, expired, revoked, or issued to a different app.{
"active": true,
"client_id": "your_client_id",
"username": "username",
"scope": "openid profile servers",
"token_type": "Bearer",
"exp": 1790000000
}
offline_access only if your app needs long-lived access.Games
Echoed runs games inside chats. People start one in a DM, or together in a server's game lounge, where the room votes on what to play and anyone not seated can watch. Your game is a web page that Echoed embeds, plus a small backend of yours that talks to Echoed.
You never receive a player's Echoed login. Your backend gets a short-lived session token for one player in one match, and that player's public profile: id, username, display name and avatar.
How a match runs
- A player launches your game. Echoed opens your embed URL in a sandboxed iframe with a launch token in the URL fragment.
- Your page sends the launch token to your backend. It is single-use and expires after 3 minutes.
- Your backend exchanges it using your client credentials, and learns who the player is, which game and match this is, who is seated, and whether this connection is a player or a spectator.
- You run the game however you like. Everyone launched into the same match gets the same match id, so group connections by it.
- When the match ends, your backend reports each player's result. Echoed records it on the leaderboards and shows it where the match was played.
https://go.echoed.gg/games. Everything on this page is what Echoed's own game backend does — 13 games on one server, from a solo score game to a four-player board game.Register a Game
- In Echoed, open Settings → Developer → OAuth2 Apps and create an app with the Games scope ticked. Copy the client secret now: it is shown once. If you lose it, regenerate it from the key button on the app.
- Press the gamepad button on the app and add a game. One app can publish many games; each gets its own game id.
| Field | Meaning |
|---|---|
| Name | Shown on the picker, the lounge vote and every result card |
| Embed URL | The https:// page Echoed loads. Required |
| Icon, cover | https:// images. Echoed proxies them, so players never load them from your host directly |
| Description, category | Shown on the picker |
| Min / max players | min 1 allows solo play; max ≥ 2 allows multiplayer in a DM. A lounge only offers your game when the room's player count fits the range |
| Supports spectators | Lets people in a lounge who aren't seated watch live |
Visibility. Your games work for you straight away, so you can test them in your own DMs. They appear for everyone else — in the DM picker and in lounge votes — once the app is public and approved in review.
Your Game Page
Echoed loads your embed URL with everything in the fragment:
<embedUrl>#launch=<launchToken>&match=<matchId>[&rematch=1]&theme=<dark|light>
The iframe has sandbox="allow-scripts allow-same-origin allow-forms" and allow="autoplay; fullscreen". The token is in the fragment so it never reaches a server log. Read it from location.hash, send it to your backend, and don't store it: it is useless once exchanged. theme is the player's Echoed light or dark mode — match it if you can.
const params = new URLSearchParams(location.hash.slice(1)); const launchToken = params.get("launch"); const matchId = params.get("match"); document.documentElement.dataset.theme = params.get("theme") || "dark"; // Hand the token to YOUR backend; it does the exchange. const socket = io("https://games.example.com", { auth: { launchToken, matchId } });
Echoed listens for one message from your page:
postMessage to window.parent | Effect |
|---|---|
{ type: 'echoed:rematch' } | In a DM, when the fragment has rematch=1: Echoed starts a new match and reloads your page with a new launch token. The other player gets an invite |
Player Counts
Your min and max players decide where your game can be played:
| Range | DM | Game lounge |
|---|---|---|
| 1 | "Play solo" — a score run | Not offered |
| 1–N | Both "Play solo" and a two-player game | Offered (seats 2 or more) |
| 2–N | The two of you | Offered |
| 3 or more minimum | Not offered (a DM has two people) | Offered |
In a lounge the room votes on games whose minimum it meets. If more people want to play than your maximum, the earliest to join are seated and the rest watch (if you support spectators). match.players always holds exactly the seated players, so a 2–4 player game may get 2, 3 or 4 — size the game to it. If someone leaves a game of three or more partway through, decide whether the others play on; report the leaver as loss either way.
Launch Token Exchange
POST https://go.echoed.gg/games/auth/exchange Authorization: Basic base64(client_id:client_secret) Content-Type: application/json { "launchToken": "…" }
{
"sessionToken": "…",
"expiresIn": 7200,
"role": "player",
"player": { "id": "u_1", "username": "siam", "displayName": "Siam", "avatarUrl": "https://…" },
"game": { "id": "game_abc", "name": "Chess", "embedUrl": "https://games.example/play?game=chess" },
"match": {
"id": "m_123",
"mode": "multiplayer",
"context": { "type": "lounge", "id": "…" },
"players": ["u_1", "u_2"]
}
}
roleisplayerorspectator. Stream a spectator the game and refuse their input; Echoed refuses score reports from spectator sessions.match.playersis the seat order, the same for every connection to the match. Seat 0 isplayers[0].match.modeissolofor a one-player run.match.context.typeisdmorlounge. You don't have to treat them differently.game.idsays which of your games was launched. If one backend hosts several, route on it or ongame.embedUrl— both come from your registration, so a player can't change them. Never route on anything the page tells you.
The response also carries gameId and a session object, kept for compatibility with the preview API. They hold the same values; use game and match.
Authorization: Bearer <sessionToken>.Reporting Results
POST https://go.echoed.gg/games/scores Authorization: Bearer <sessionToken> Content-Type: application/json { "outcome": "win", "score": 0 }
outcome | Meaning |
|---|---|
win / loss / draw | A multiplayer result. Counts towards wins, losses and draws. score is optional |
| omitted | A solo run. score (an integer) is the result; the best score is kept |
409 — treat it as success, because it means the first one arrived. That makes retrying safe, and means a replayed request can never count a win twice.What happens next depends on where the match was played:
- DM — the first report posts a result card into the conversation ("Siam won!", "It's a draw!").
- Solo — the card shows the score, and says so when it is a new best in that chat.
- Lounge — the match ends when a
winordrawis reported, or when every seated player has reported. The whole room sees the result together and can share it to a channel or start the next vote. In a game of more than two, report eliminated players aslosswhen they are knocked out and the winner aswinat the end.
Leaderboards
Echoed keeps two for every game, and players see both in the game view: a global board of everyone who has played it, and a board for this chat / this lounge with only the matches played there. Multiplayer games rank by wins; games with a maximum of one player rank by best score. You don't have to do anything to get them — they come from your score reports.
Errors & Limits
Every error is { "error": "…" } with a status code.
| Endpoint | Status | Meaning | Do |
|---|---|---|---|
| exchange | 400 | Missing launch token or credentials | Fix the request |
401 | Wrong client credentials, or the launch token is invalid, expired or used | Ask the player to launch again | |
403 | The token is for a game another app owns | Check which credentials you deployed | |
| scores | 401 | Session token unknown or expired (2 hours) | Nothing to retry |
403 | Spectator session | Expected; don't retry | |
409 | This player's result is already in | Treat as success | |
400 | outcome isn't win, loss, draw or omitted | Fix the request | |
| any | 429 | Rate limited | Retry after the Retry-After header |
503 | Echoed is briefly unavailable | Retry with backoff |
Limits: 120 exchanges and 300 score reports per minute from one IP address.
Rules
- Keep the client secret on your server. Exchange and report only from there.
- Referee the game on your server. Echoed trusts your backend's report, so nothing a player's browser says should decide it.
- You get a player's id, username, display name and avatar. Nothing else.
- Serve your page over HTTPS.
Endpoint Reference
| Endpoint | Auth | Purpose |
|---|---|---|
POST /games/auth/exchange | Client (Basic) | Launch token → session |
GET /games/players/@me | Session | The player's profile |
POST /games/scores | Session | Report a result |
GET /games/apps?clientId= | User login (app owner) | List an app's games |
POST /games/apps | User login (app owner) | Register a game: clientId, name, embedUrl, iconUrl, coverUrl, shortDescription, category, minPlayers, maxPlayers, supportsSpectators. Returns the game with its id |
GET / PUT / DELETE /games/apps/{gameId} | User login (app owner) | Read, replace or remove one game |
The other /games routes (launching, lounge sessions, leaderboards, share cards) are called by the Echoed app on the player's behalf. A game never needs them.