Developer Documentation

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)

  1. Create a bot in Settings → Developer → Bot Profile
  2. Copy its Bot API Key (starts with zbot_)
  3. Submit it for review. Until it is approved, add it to your own test server with a development invite token
  4. Call the API with the X-Bot-Token header, 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.

HTTP Headers
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.

Keep your bot token secret. Never commit it to version control or share it publicly. If compromised, regenerate it immediately from your bot settings.

Base URL

All Bot API endpoints are prefixed with:

Base URL
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.

429 Response
{
  "message": "Rate limited. Please wait.",
  "code": 429,
  "retryAfter": 12
}
For high-volume operations like message purges, prefer the 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
200Success
201Created (new resource)
400Bad Request — invalid parameters
401Unauthorized — invalid or missing token
403Forbidden — insufficient permissions
404Not Found — resource doesn't exist
409Conflict — e.g., already banned
429Too Many Requests — rate limit exceeded (see Rate Limits)
500Internal Server Error
503Service Unavailable — database/transient
Error Response
{
  "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.

RepositoryWhat 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.
Run one locally
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.

GET /v1/bots/me/dev-token
The bot's current token and how many servers it has opened. User login (bot account).
Response
{
  "token": "edev_3f9c…",
  "maxServers": 5,
  "usedSlots": 1,
  "approved": false,
  "createdAt": "2026-09-20T10:00:00Z",
  "expiresAt": "2026-10-20T10:00:00Z",
  "expired": false
}
POST /v1/bots/me/dev-token
Generate a token, replacing any existing one. Returns 201 with the same shape as GET, or 409 bot_already_approved if the bot has passed review.
DELETE /v1/bots/me/dev-token
Revoke the token. Servers that already added the bot keep it.
POST /v1/bots/dev-token/preview
Look up which bot a token belongs to, without adding it anywhere. User login (the admin about to add it).
Request → Response
{ "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
}
POST /v1/bots/{server_id}/invite/token
Add the unreviewed bot to this server. User login with MANAGE_SERVER. acknowledgeRisk must be true.
Request Body
{
  "token": "edev_3f9c…",
  "grantedPermissions": 0,
  "acknowledgeRisk": true
}
POST /v1/bots/me/resubmit
Send a rejected bot back to the review queue after fixing what the reviewer asked for. No-op if it is already pending or approved. User login (bot account).

Bot Profile

GET /v1/bots/validate
Validate your bot token and confirm it's active.
Response
{
  "valid": true,
  "bot_id": "67e537d200011f5275d2",
  "message": "Bot API key is valid."
}
GET /v1/bots/profile
Get your bot's profile information.
Response
{
  "id": "67e537d200011f5275d2",
  "username": "my-awesome-bot",
  "isBot": true,
  "botDescription": "A helpful server bot",
  "botCategory": "utility",
  "isPublic": true,
  "avatarUrl": "https://s3.echoed.gg/..."
}
PUT /v1/bots/profile
Dashboard endpoint — JWT auth only. Used by the bot owner from the Echoed dashboard to update marketplace metadata (description, categories, support server, version, features). For programmatic name/avatar updates, use PATCH /v1/bots/me below.
Request Body
{
  "botDescription": "Updated bot description",
  "botCategories": ["utility", "moderation"],
  "botSupportServer": "https://echoed.gg/invite/...",
  "botVersion": "1.2.0"
}
PATCH /v1/bots/me
Bot self-update via API key — change the bot's global display 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.
Request Body
{
  "name": "Panda Bot",
  "avatar": "https://cdn.example.com/panda.png"
}
Response
{
  "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.

PATCH /v1/bots/{server_id}/nickname
Set the bot's per-server nickname. Bounded to 32 characters. The bot must already be a member of the server. Realtime broadcast: every member of the server receives a memberNicknameUpdated socket event so clients update without refetching.
Request Body
{
  "nickname": "Panda EU"
}
Response
{
  "success": true,
  "nickname": "Panda EU"
}
DELETE /v1/bots/{server_id}/nickname
Clear the bot's per-server nickname. The bot reverts to its global name in this server.
POST /v1/bots/{server_id}/server-avatar
Upload a per-server avatar for the bot. Send as 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.
Response
{
  "success": true,
  "path": "public/servers/server-id/members/bot-id/avatar.png",
  "url": "https://s3.echoed.gg/public/servers/.../avatar.png",
  "timestamp": 1735531200000
}
DELETE /v1/bots/{server_id}/server-avatar
Clear the bot's per-server avatar. The bot reverts to its global 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.

PATCH /v1/bots/{server_id}/members/{user_id}/nickname
Set another member's per-server nickname. Bounded to 32 characters. Empty string clears (or use the DELETE variant).
Request Body
{
  "nickname": "NewName"
}
Response
{
  "success": true,
  "userId": "target-user-id",
  "nickname": "NewName"
}
DELETE /v1/bots/{server_id}/members/{user_id}/nickname
Clear another member's per-server nickname.
POST /v1/bots/{server_id}/members/{user_id}/server-avatar
Upload a per-server avatar for another member. Multipart form with a file field. Allowed formats: png, jpg, jpeg, gif, webp.
Response
{
  "success": true,
  "userId": "target-user-id",
  "path": "public/servers/.../avatar.png",
  "url": "https://s3.echoed.gg/...",
  "timestamp": 1735531200000
}
DELETE /v1/bots/{server_id}/members/{user_id}/server-avatar
Clear another member's per-server avatar.

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.

POST /v1/bots/{server_id}/voice/{channel_id}/join
Mint a LiveKit AccessToken for the bot to join the voice channel. Idempotent — repeated calls re-issue a fresh token. The call row is auto-created if no one is in the channel yet (deterministic callId = channelId).
Response
{
  "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).

POST /v1/bots/{server_id}/voice/{channel_id}/leave
Notify the backend that the bot is leaving. The actual disconnect happens client-side (Room.disconnect()); this endpoint is for bookkeeping and audit logs.
GET /v1/bots/{server_id}/members/{user_id}/voice-channel
Which voice channel a member is in right now, or null. This is how a music bot's !play follows the person who asked, instead of making them name a channel.
Response
{ "channelId": "voice-channel-id" }   // or { "channelId": null }

Servers

GET /v1/bots/servers
List all servers your bot has been invited to.
Response
{
  "count": 2,
  "servers": [
    {
      "serverId": "6894d3d90011f842607c",
      "serverName": "My Server",
      "serverIcon": "",
      "invitedAt": "2026-01-06T13:41:44Z",
      "invitedBy": "user-id"
    }
  ]
}
GET /v1/bots/{server_id}/info
Get detailed information about a specific server.
Response
{
  "id": "6894d3d90011f842607c",
  "name": "My Server",
  "description": "A cool server",
  "ownerId": "user-id",
  "memberCount": 42,
  "channelCount": 8,
  "iconUrl": "",
  "createdAt": "2025-08-07T16:27:05Z"
}

Channels

GET /v1/bots/{server_id}/channels
List all channels in a server.
Response
{
  "channels": [
    {
      "id": "647a23fede03765e0348",
      "name": "general",
      "type": "text",
      "description": "",
      "createdAt": "2026-01-05T11:20:43Z"
    }
  ],
  "total": 8
}
POST /v1/bots/{server_id}/channels
Create a new channel. Valid types: text, video, tasks, calendar. Requires MANAGE_CHANNELS.
Request Body
{
  "name": "bot-updates",
  "type": "text",
  "description": "Automated bot announcements",
  "isPrivate": false,
  "isNsfw": false,
  "categoryId": "category-id",
  "position": 0
}
PUT /v1/bots/{server_id}/channels/{channel_id}
Edit a channel. All fields optional — only the ones you send are updated. Requires MANAGE_CHANNELS.
Request Body
{
  "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).

DELETE /v1/bots/{server_id}/channels/{channel_id}
Delete a channel permanently.
PUT /v1/bots/{server_id}/channels/reorder
Move a channel to a different position or category.
Request Body
{
  "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_type is role or user. A role target is a role id from this server, or everyone. A user target must be a member.
  • Unknown permission names are rejected, not ignored.
GET /v1/bots/{server_id}/channels/{channel_id}/permissions
Every override on the channel, as permission names and as bits.
Response
{
  "channelId": "channel-id",
  "permissionOverrides": [
    {
      "targetType": "role",
      "targetId": "everyone",
      "allow": [],
      "deny": ["send_messages", "add_reactions"],
      "allowBits": 0,
      "denyBits": 4352
    }
  ]
}
PUT /v1/bots/{server_id}/channels/{channel_id}/permissions/{target_type}/{target_id}
Set one override, replacing what was there. Send names, bits, or both. A PUT that allows and denies nothing removes the override.
Request Body — lock a channel for @everyone
{
  "allow": [],
  "deny": ["send_messages", "add_reactions"]
}
DELETE /v1/bots/{server_id}/channels/{channel_id}/permissions/{target_type}/{target_id}
Remove one override. The same rule applies: the bot must hold every permission the removal changes.

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

POST /v1/bots/{server_id}/categories
Create a new channel category.
Request Body
{
  "name": "Bot Channels",
  "position": 0
}
PUT /v1/bots/{server_id}/categories/{category_id}
Edit a category's name or position.
DELETE /v1/bots/{server_id}/categories/{category_id}
Delete a category. Channels within it become uncategorized.
PUT /v1/bots/{server_id}/categories/reorder
Reorder multiple categories at once.
Request Body
{
  "categories": [
    { "categoryId": "id1", "position": 0 },
    { "categoryId": "id2", "position": 1 }
  ]
}

Members

GET /v1/bots/{server_id}/members
List server members with pagination.
ParameterTypeDescription
limitintegerMax results (default 10)
offsetintegerPagination offset
GET /v1/bots/{server_id}/members/search?q={query}
Search members by username. Query must be at least 2 characters.
GET /v1/bots/{server_id}/members/{user_id}/permissions
Get a member's resolved permissions as a list of permission names. Useful for command gating — check if the caller has e.g. 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).

Response (server-level)
{
  "userId": "user-id",
  "serverId": "server-id",
  "permissions": [
    "VIEW_CHANNELS", "SEND_MESSAGES",
    "READ_MESSAGE_HISTORY", "CONNECT", "SPEAK"
  ]
}
Response (with ?channel_id=...)
{
  "userId": "user-id",
  "serverId": "server-id",
  "channelId": "channel-id",
  "permissions": [
    "VIEW_CHANNELS", "SEND_MESSAGES",
    "READ_MESSAGE_HISTORY", "MANAGE_MESSAGES"
  ]
}
DELETE /v1/bots/{server_id}/members/{user_id}
Remove a member from the server (no ban). Equivalent to a kick that doesn't show up as a moderation action.

Moderation

Punitive member actions: kick, ban, unban, and timeout. Each requires the matching permission on the bot. The server owner cannot be targeted.

POST /v1/bots/{server_id}/members/{user_id}/kick
Kick a member. Requires KICK_MEMBERS.
Request Body
{ "reason": "Optional reason" }
POST /v1/bots/{server_id}/members/{user_id}/ban
Ban a member. Requires BAN_MEMBERS. Returns 409 if already banned.
Request Body
{ "reason": "Reason for ban" }
POST /v1/bots/{server_id}/members/{user_id}/unban
Unban a previously banned member. Requires 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.

POST /v1/bots/{server_id}/members/{user_id}/timeout
Time a member out for durationSeconds (capped at 28 days). Re-applying overwrites the previous timeout.
Request Body
{
  "durationSeconds": 300,
  "reason": "Spamming"
}
Response
{
  "success": true,
  "userId": "user-id",
  "timeoutUntil": "2026-04-28T17:25:00Z",
  "durationSeconds": 300,
  "reason": "Spamming"
}
DELETE /v1/bots/{server_id}/members/{user_id}/timeout
End a timeout early. Idempotent — clearing a non-existent timeout returns success.
GET /v1/bots/{server_id}/members/{user_id}/timeout
Read current timeout state. Returns active: false if no timeout is set or it has expired.
Response (active)
{
  "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.

POST /v1/bots/{server_id}/lockdown
Refuse every new join through an invite for untilSeconds (30 to 86,400). Members already in are unaffected. The lockdown ends by itself when the time runs out.
Request Body
{
  "untilSeconds": 600,
  "reason": "Raid detected: 25 joins in 10 seconds"
}
Response
{
  "success": true,
  "until": "2026-09-27T18:10:00Z",
  "reason": "Raid detected: 25 joins in 10 seconds"
}
DELETE /v1/bots/{server_id}/lockdown
End a lockdown early. Returns { "success": true } whether or not one was active.
PATCH /v1/bots/{server_id}/verification-level
Set who may join and talk. A gentler step than a lockdown: during a raid, raise it to 2 so fresh throwaway accounts are held back, then restore the previous level afterwards. Members who have any role are never held back.
LevelRequirement
0None
1Verified email
2Verified email, account at least 5 minutes old
3Verified email, account at least 5 days old, member of the server for 30 minutes
Request → Response
{ "level": 2 }

{ "success": true, "level": 2, "previous": 0 }
GET /v1/bots/{server_id}/members/{user_id}/profile
Account age and avatar for one member, for join-time checks ("new account, default avatar, joined in a burst"). Fetch it when SERVER_MEMBER_ADD arrives.
Response
{
  "userId": "user-id",
  "username": "newcomer",
  "displayName": "Newcomer",
  "avatarUrl": "",
  "hasAvatar": false,
  "accountAgeSeconds": 312,
  "createdAt": "2026-09-27T17:55:00Z"
}
Panda's 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

GET /v1/bots/{server_id}/roles
List all roles in a server.
POST /v1/bots/{server_id}/roles
Create a new role. Requires MANAGE_ROLES. permissions accepts an array of permission names or an integer bitmask.
Request Body
{
  "name": "Moderator",
  "color": "#FF5733",
  "permissions": ["SEND_MESSAGES", "MANAGE_MESSAGES", "KICK_MEMBERS"],
  "position": 5
}
PUT /v1/bots/{server_id}/roles/{role_id}
Edit an existing role. All fields optional. System and default roles (@everyone, Server Owner) cannot be edited. Requires MANAGE_ROLES.
Request Body
{
  "name": "Senior Moderator",
  "color": "#9B59B6",
  "permissions": ["MANAGE_MESSAGES", "KICK_MEMBERS", "BAN_MEMBERS"],
  "position": 8,
  "mentionable": true,
  "hoist": true
}
DELETE /v1/bots/{server_id}/roles/{role_id}
Delete a role. The role is removed from every member that had it.
GET /v1/bots/{server_id}/members/{user_id}/roles
Get all roles assigned to a specific member.
PUT /v1/bots/{server_id}/members/{user_id}/roles
Set all roles for a member (replaces existing).
Request Body
{
  "roles": ["role-id-1", "role-id-2"]
}
POST /v1/bots/{server_id}/members/{user_id}/roles/{role_id}
Add a single role to a member.
DELETE /v1/bots/{server_id}/members/{user_id}/roles/{role_id}
Remove a single role from a member.

Messages

GET /v1/bots/{server_id}/messages?channel_id={id}
Get messages from a channel. Supports limit (max 100), before, after for pagination.
GET /v1/bots/{server_id}/messages/{message_id}
Fetch a single message by ID. Useful for validating a target before pinning, polling reaction state on a poll, or fetching the source of a reply.
POST /v1/bots/{server_id}/messages/send
Send a message to a channel. content may be empty as long as embeds or attachmentIds is provided. See Rich Embeds for the embed object schema (max 10 per message).
Request Body
{
  "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.

POST /v1/bots/{server_id}/upload/{channel_id}
Upload a file attachment. Send as multipart/form-data with a file field. Max file size: 50 MB. Supported types: images, videos, audio, documents, archives, and code files.
Response
{
  "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.

Message with Attachment
{
  "channelId": "channel-id",
  "content": "Check out this image!",
  "attachmentIds": ["abc123"],
  "mentions": [],
  "replyToId": ""
}
PUT /v1/bots/{server_id}/messages/{message_id}
Edit a message. Bots can edit their own messages, or others' messages if they have 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.
Request Body — text only
{
  "content": "Edited message content"
}
Request Body — live-updating embed
{
  "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.

DELETE /v1/bots/{server_id}/messages/{message_id}
Delete a message. Bots can delete their own messages, or others' if they have MANAGE_MESSAGES.
POST /v1/bots/{server_id}/channels/{channel_id}/messages/bulk-delete
Delete up to 100 messages in a single request. Messages that don't exist or aren't in the target server are silently skipped and reported in the response. Requires MANAGE_MESSAGES.
Request Body
{
  "messageIds": ["id1", "id2", "id3"]
}
Response
{
  "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.

Embed
{
  "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

EmbedAuthor / Provider
{
  "name": "Author or site name",
  "url": "https://…",
  "icon_url": "https://…/avatar.png"
}
EmbedMedia (thumbnail / image / video / audio)
{
  "url": "https://…/asset",
  "width": 1280,
  "height": 720
}
EmbedField
{
  "name": "Heading",
  "value": "Body — markdown supported.",
  "inline": false
}
EmbedFooter
{
  "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.

POST /v1/bots/{server_id}/messages/send
{
  "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.
  • color is a decimal integer (e.g. 0xFFC928 → 16763688). Omit for the client's default neutral.
  • timestamp must 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_url alongside your original url.
  • 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 an author block.

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.

PUT /v1/bots/{server_id}/messages/{message_id}/reactions/{emoji}
Add the bot's own reaction to a message. The {emoji} path segment must be URL-encoded. Idempotent — re-adding an existing reaction is a no-op. Requires ADD_REACTIONS in the channel.
DELETE /v1/bots/{server_id}/messages/{message_id}/reactions/{emoji}
Remove the bot's own reaction. Bots can only remove their own reactions — clearing other users' reactions is not supported.

Pinned Messages

Pin and unpin messages in a channel. Both endpoints require MANAGE_MESSAGES in the target channel.

POST /v1/bots/{server_id}/channels/{channel_id}/messages/{message_id}/pin
Pin a message. Idempotent — pinning an already-pinned message returns success.
DELETE /v1/bots/{server_id}/channels/{channel_id}/messages/{message_id}/pin
Unpin a message. Returns success if the message wasn't pinned.

Direct Messages

POST /v1/bots/dm/send
Send a direct message to a user by username. The bot and target user must share at least one server, and DMs respect block status.
Request Body
{
  "username": "target-user",
  "content": "Hey! This is a DM from my bot."
}
Response
{
  "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.

To edit or delete a post's body or a reply, use the normal message routes (PUT / DELETE /v1/bots/{server_id}/messages/{message_id}). Thread messages are channel messages.

Posts

GET /v1/bots/{server_id}/{channel_id}/threads/posts
List posts, most recent activity first. Query: 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.
Response
{
  "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
}
POST /v1/bots/{server_id}/{channel_id}/threads/posts
Open a post. content becomes its first message and takes everything a message does: attachments (upload first), mentions, embeds. Returns 201 with post and rootMessage.
Request Body
{
  "title": "Weekly patch notes",
  "content": "Here is what changed this week…",
  "categoryId": "category-id",
  "attachmentIds": [],
  "mentions": [],
  "embeds": []
}
GET /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}
One post and the first page of its messages.
PATCH /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}
Change the title or category: { "title": "…", "categoryId": "…" }, either field optional. The bot must be the post's author or hold manage_threads.
DELETE /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}
Delete a post with its body, every reply and their reactions. Author or manage_threads.

Moderation

POST /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/pin
Toggle the pin. The response carries the new state: { "postId": "…", "isPinned": true }.
POST /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/lock
Toggle the lock (isLocked). A locked post takes no replies from anyone without manage_threads.
POST /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/close
Mark a post resolved. Allowed for its author or a manage_threads holder.
POST /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/reopen
Reopen a closed post. A moderator can reopen any; an author only a post they closed themselves.

Messages

GET /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/messages
A post's messages, oldest first — a post reads top to bottom, so the body is the first row of page one. Query: limit (default 50, max 100), after (the previous page's nextAfter).
POST /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/messages
Reply in a post. Returns 201 with messageId and the reply.
Request Body
{
  "content": "Fixed in 1.4.1, thanks for the report.",
  "replyToId": "message-id",
  "attachmentIds": [],
  "mentions": [],
  "embeds": []
}
GET /v1/bots/{server_id}/{channel_id}/threads/search
Search posts and replies in this thread channel. Query: q, post_id, author_id, author_name, before, after, sort, has, mentions, limit, offset — the same filters as message search.

Following & Read State

POST /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/follow
Follow a post as the bot. DELETE on the same path unfollows. Returns { "success": true, "postId": "…", "isFollowing": true }.
POST /v1/bots/{server_id}/{channel_id}/threads/posts/{post_id}/read
Mark a post read for the bot.

Categories

Forum categories are per server and shared by all its thread channels. Changing them needs manage_threads.

GET /v1/bots/{server_id}/threads/categories
List categories: { "categories": [ … ] }.
POST /v1/bots/{server_id}/threads/categories
Create one: { "name": "Bug reports", "description": "…" }. Returns 201 with category.
PATCH /v1/bots/{server_id}/threads/categories/{category_id}
Update name, description or isActive, each optional.
DELETE /v1/bots/{server_id}/threads/categories/{category_id}
Delete a category.
POST /v1/bots/{server_id}/threads/categories/reorder
Set the order: { "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.

GET /v1/bots/{server_id}/tasks?channelId={id}
Get all tasks from a task channel.
POST /v1/bots/{server_id}/tasks
Create a new task.
Request Body
{
  "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"
}
FieldValues
prioritylow, medium, high, urgent
statusbacklog, todo, in_progress, review, done
PUT /v1/bots/{server_id}/tasks/{task_id}
Update a task's status, description, priority, or other fields.
DELETE /v1/bots/{server_id}/tasks/{task_id}
Delete a task.

Calendar Events

Manage events in Calendar-type channels. Create, update, and manage event participants.

GET /v1/bots/{server_id}/calendar?channelId={id}
Get all events from a calendar channel.
POST /v1/bots/{server_id}/calendar
Create a new calendar event.
Request Body
{
  "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
}
PUT /v1/bots/{server_id}/calendar/{event_id}
Update an event's title, description, time, or location.
DELETE /v1/bots/{server_id}/calendar/{event_id}
Delete a calendar event.

Event Participants

GET /v1/bots/{server_id}/calendar/{event_id}/participants
List all participants for an event.
POST /v1/bots/{server_id}/calendar/{event_id}/participants/add
Add a participant to an event.
Request Body
{ "userId": "user-id" }
POST /v1/bots/{server_id}/calendar/{event_id}/participants/remove
Remove a participant from an event.
Request Body
{ "userId": "user-id" }

Audit Logs

Read the server audit log to enrich a mod-log channel, build moderation dashboards, or reconcile bot actions with admin actions.

GET /v1/bots/{server_id}/audit-logs
Fetch audit log entries for a server. Requires MANAGE_SERVER.
ParameterTypeDescription
limitintegerMax entries to return (1–100, default 50)
beforeISO 8601Return entries before this timestamp
actionstringFilter by action name (e.g. kick_member, timeout_member)
Response
{
  "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.

GET /v1/bots/discover
Approved public bots. No authentication. Query: page, limit (default 20, max 50), search, kind (official | community).
Response
{
  "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 }
}
GET /v1/bots/categories
The directory's category list (utility, moderation, fun, games, music, social, economy, …) as { "categories": [{ "value", "label", "description" }] }. Use these values as your bot's tags.
GET /v1/bots/{server_id}/bots
The bots installed in a server. User login with MANAGE_SERVER.
POST /v1/bots/{server_id}/invite
Add an approved bot to a server, granting it some or all of the permissions it requested. User login with MANAGE_SERVER. An unreviewed bot is refused with 403; use a development token.
Request Body
{ "botUserId": "bot-id", "grantedPermissions": 4352 }
POST /v1/bots/{server_id}/bots/{bot_id}/remove
Remove a bot from a server. User login with 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

Node.js (socket.io-client)
// 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);
});
Python (python-socketio)
# 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.

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:

Listening
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

EventDescription
Messages
MESSAGE_CREATENew message posted in any channel the bot can see
MESSAGE_UPDATEMessage edited
MESSAGE_DELETEMessage deleted
MESSAGE_DELETE_BULKBulk delete — payload has messageIds array
MESSAGE_EMBEDS_UPDATEAsync URL unfurl completed for a message
Reactions
MESSAGE_REACTION_ADDReaction added to a message
MESSAGE_REACTION_REMOVEReaction removed from a message
MESSAGE_REACTION_REMOVE_ALLAll reactions cleared from a message
MESSAGE_REACTION_REMOVE_EMOJIAll instances of one emoji cleared
Members
SERVER_MEMBER_ADDMember joined the server — trigger welcome flows
SERVER_MEMBER_REMOVEMember left the server
SERVER_MEMBER_UPDATEMember's roles changed
SERVER_MEMBER_NICKNAME_UPDATENickname changed
SERVER_MEMBER_AVATAR_UPDATEServer-specific avatar changed
SERVER_MEMBER_KICKMember was kicked
SERVER_MEMBER_BANMember was banned
Channels & Categories
CHANNEL_CREATEChannel created
CHANNEL_UPDATEChannel edited (name, description, slowmode, etc.)
CHANNEL_DELETEChannel deleted
CHANNEL_REORDERChannels reordered within a category
CHANNEL_PINS_UPDATEPinned messages changed in a channel
CATEGORY_CREATE / CATEGORY_UPDATE / CATEGORY_DELETE / CATEGORY_REORDERCategory lifecycle
Server
SERVER_CREATEServer created
SERVER_UPDATEServer settings changed
SERVER_DELETEServer deleted
SERVER_INVITE_CREATEInvite created/refreshed
Threads & Forums
THREAD_POST_CREATE / THREAD_POST_UPDATE / THREAD_POST_DELETEPost lifecycle, including pin, lock, close and reopen. Replies arrive as MESSAGE_CREATE
THREAD_CATEGORY_CREATE / UPDATE / DELETE, THREAD_CATEGORIES_REORDERForum category changes
Tasks & Calendar
TASK_CREATE / TASK_UPDATE / TASK_DELETETask lifecycle
CALENDAR_EVENT_CREATE / CALENDAR_EVENT_UPDATE / CALENDAR_EVENT_DELETECalendar event lifecycle
CALENDAR_PARTICIPANT_JOIN / LEAVE / ADD / REMOVEEvent participation changes
Other
TYPING_START / TYPING_STOPTyping indicators
PRESENCE_UPDATEUser online/idle/dnd status changes
NOTIFICATION_CREATENew notification for a user
UNREAD_COUNT_UPDATEUnread count changed
PERMISSION_UPDATEPermission cache invalidated — re-fetch member permissions

MESSAGE_CREATE Payload

Example
{
  "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
  }
}
Filter out the bot's own messages by checking 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.

POST /api/webhooks/{webhook_id}/{webhook_token}
Post a message. No other authentication. Send 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.
Request Body
{
  "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 }]
    }
  ]
}
Response
{ "success": true, "messageId": "message-id" }
curl
curl -X POST "https://go.echoed.gg/api/webhooks/$ID/$TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"content": "Build #812 passed"}'
StatusMeaning
400Neither content nor embeds, content over 4,000 characters, or an invalid embed (the message says which field)
401Wrong token
404The 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.

GET /api/servers/{server_id}/webhooks
List the server's webhooks.
Response
{
  "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/…"
    }
  ]
}
POST /api/servers/{server_id}/webhooks
Create one: { "channelId": "…", "name": "CI", "avatar": "https://…" } (avatar optional). Returns { "success": true, "webhook": { … } }.
PATCH /api/servers/{server_id}/webhooks/{webhook_id}
Rename, change the picture, or move it to another channel: name, avatar, channelId, each optional.
DELETE /api/servers/{server_id}/webhooks/{webhook_id}
Delete it. Its URL stops working immediately.

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

  1. 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
  2. Send the user to the authorization URL with your client_id and the scopes you need
  3. Echoed shows its consent screen, signing the user in first if needed. On approval it redirects back with an authorization code and your state
  4. Exchange the code for an access token (and a refresh token with offline_access)
  5. 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.

Step 1 — Authorization URL
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.

Step 2 — Exchange Code for Tokens
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
Token Response
{
  "access_token": "MKquTIm08LMU...",
  "refresh_token": "dR4nG7kP...",
  "expires_in": 3600,
  "token_type": "Bearer"
}
Refreshing (requires offline_access)
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 typeCode exchangeRefresh
Confidential (default)client_secret required; PKCE optional on topclient_secret required
PublicPKCE required, no secretNo secret
PKCE in the browser
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
openidBasic identity (user ID, issuer)
profileName, username, avatar, created date
emailEmail address
serversUser's servers, invite bots to servers
friendsUser's friends list
gamesPublish games on the Games platform. Players' profiles reach a game as id, username, display name and avatar only
offline_accessReceive a refresh token for long-lived access

Token Lifetimes

Token Lifetime
Authorization Code10 minutes
Access Token1 hour
Refresh Token30 days

OAuth2 Endpoints

GET /oauth2/userinfo
Get the authenticated user's profile. Requires Bearer token in Authorization header.
Response (with profile + servers scopes)
{
  "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
}
GET /oauth2/api/user/servers
Get user's servers where they can invite bots. Requires servers scope.
GET /oauth2/api/user/friends
Get the user's friends list. Requires friends scope.
POST /oauth2/api/servers/{server_id}/invite-bot
Invite a bot to a server. Requires servers scope and bot management permission.
Request Body
{ "bot_id": "bot-user-id" }
POST /oauth2/token
Exchange authorization code for tokens, or refresh an existing token. Uses application/x-www-form-urlencoded.
POST /oauth2/revoke
Revoke an access or refresh token.
Request Body (form-encoded)
token=access_token_value&
token_type_hint=access_token&
client_id=your_client_id&
client_secret=your_client_secret
POST /oauth2/introspect
Ask whether a token is still valid, from your server. Same form fields as /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.
Response (active)
{
  "active": true,
  "client_id": "your_client_id",
  "username": "username",
  "scope": "openid profile servers",
  "token_type": "Bearer",
  "exp": 1790000000
}
Tip: Request only the scopes you need. Users are more likely to approve fewer permissions. Use 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

  1. A player launches your game. Echoed opens your embed URL in a sandboxed iframe with a launch token in the URL fragment.
  2. Your page sends the launch token to your backend. It is single-use and expires after 3 minutes.
  3. 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.
  4. You run the game however you like. Everyone launched into the same match gets the same match id, so group connections by it.
  5. 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.
All Games endpoints live under 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

  1. 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.
  2. Press the gamepad button on the app and add a game. One app can publish many games; each gets its own game id.
FieldMeaning
NameShown on the picker, the lounge vote and every result card
Embed URLThe https:// page Echoed loads. Required
Icon, coverhttps:// images. Echoed proxies them, so players never load them from your host directly
Description, categoryShown on the picker
Min / max playersmin 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 spectatorsLets 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:

Embed URL
<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.

Page
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.parentEffect
{ 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:

RangeDMGame lounge
1"Play solo" — a score runNot offered
1–NBoth "Play solo" and a two-player gameOffered (seats 2 or more)
2–NThe two of youOffered
3 or more minimumNot 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 /games/auth/exchange
Trade a launch token for a session. Authenticate with your OAuth2 app's credentials as HTTP Basic auth. Only call this from your backend — the client secret must never be in a page.
Request
POST https://go.echoed.gg/games/auth/exchange
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json

{ "launchToken": "…" }
Response
{
  "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"]
  }
}
  • role is player or spectator. Stream a spectator the game and refuse their input; Echoed refuses score reports from spectator sessions.
  • match.players is the seat order, the same for every connection to the match. Seat 0 is players[0].
  • match.mode is solo for a one-player run.
  • match.context.type is dm or lounge. You don't have to treat them differently.
  • game.id says which of your games was launched. If one backend hosts several, route on it or on game.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.

GET /games/players/@me
The player's profile again, with Authorization: Bearer <sessionToken>.

Reporting Results

POST /games/scores
Report one player's result, with that player's session token. Report once for each seated player.
Request
POST https://go.echoed.gg/games/scores
Authorization: Bearer <sessionToken>
Content-Type: application/json

{ "outcome": "win", "score": 0 }
outcomeMeaning
win / loss / drawA multiplayer result. Counts towards wins, losses and draws. score is optional
omittedA solo run. score (an integer) is the result; the best score is kept
One report per player per match. A second report for the same player gets 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 win or draw is 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 as loss when they are knocked out and the winner as win at 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.

EndpointStatusMeaningDo
exchange400Missing launch token or credentialsFix the request
401Wrong client credentials, or the launch token is invalid, expired or usedAsk the player to launch again
403The token is for a game another app ownsCheck which credentials you deployed
scores401Session token unknown or expired (2 hours)Nothing to retry
403Spectator sessionExpected; don't retry
409This player's result is already inTreat as success
400outcome isn't win, loss, draw or omittedFix the request
any429Rate limitedRetry after the Retry-After header
503Echoed is briefly unavailableRetry 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

EndpointAuthPurpose
POST /games/auth/exchangeClient (Basic)Launch token → session
GET /games/players/@meSessionThe player's profile
POST /games/scoresSessionReport a result
GET /games/apps?clientId=User login (app owner)List an app's games
POST /games/appsUser 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.