> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Discord

> Bot REST API, Gateway over WebSocket, interactions, OAuth2, and rate limits.

Stateful Discord emulation for bots: the REST API v10 for users, guilds, channels, messages, pins, reactions, roles, members, bans, and application commands; the Gateway over WebSocket with HELLO, IDENTIFY with intents, heartbeats, READY, RESUME, sharding, and event dispatch; interactions (slash commands, context menus, buttons, select menus, modals, autocomplete) delivered over the Gateway or to an interactions endpoint signed with Ed25519; OAuth2 with the authorization code grant, bot install with permissions, client credentials, refresh, and revoke; and per-route rate limits with Discord's headers and 429 bodies. Simulate endpoints send the user messages, reactions, and slash commands a bot needs to react to. The tests drive it with [`discord.js`](https://github.com/discordjs/discord.js) (14.27.0), [`@discordjs/rest`](https://www.npmjs.com/package/@discordjs/rest) (2.6.3), [`@discordjs/ws`](https://www.npmjs.com/package/@discordjs/ws) (2.0.4), and [`discord-interactions`](https://www.npmjs.com/package/discord-interactions) (4.4.0).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/discord` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `discord` |
| HTTP operations | 100, browsable at `http://localhost:3999/docs/discord` while it runs ([API reference](/api-reference.md)) |
| WebSocket endpoints | 1 |
| Vendor spec | [discord/discord-api-spec](https://github.com/discord/discord-api-spec) at `ed01408` |

```bash CLI theme={null}
npx @deploybaseline/cli start --service discord
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const discord = await createEmulator({ service: "discord" });
// discord.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "discord": {
    "users": [
      {
        "username": "ada",
        "email": "ada@example.com"
      }
    ],
    "applications": [
      {
        "name": "My Bot",
        "bot_token": "MTAwMDAwMDAwMDAwMDAwMDEx.baseline.discord-bot-token",
        "client_secret": "baseline-discord-client-secret",
        "redirect_uris": ["http://localhost:3000/callback"],
        "commands": [
          {
            "name": "ping",
            "description": "Replies with pong"
          }
        ]
      }
    ],
    "guilds": [
      {
        "name": "My Server",
        "owner": "ada",
        "channels": [
          {
            "name": "general",
            "topic": "Say hi"
          }
        ],
        "bots": [
          {
            "application": "My Bot",
            "permissions": ["ADMINISTRATOR"]
          }
        ]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service discord
```

The Gateway is a WebSocket, and the Discord provider ships the WebSocket kit, so there is nothing else to install (see [WebSockets](/websockets.md)). Discord starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`.

### Default fixtures

With no `discord` seed config (or one that seeds no users, applications, or guilds), the service starts with:

| | ID | Details |
| - | - | - |
| User | `100000000000000001` | `baseline` |
| Application | `100000000000000010` | "Baseline Bot", client secret `baseline-discord-client-secret`, redirect URI `http://localhost:3000/callback` |
| Bot user | `100000000000000011` | token `MTAwMDAwMDAwMDAwMDAwMDEx.baseline.discord-bot-token` |
| Guild | `100000000000000100` | "Baseline", owned by `baseline`, with the bot installed as an administrator |
| Channel | `100000000000000101` | `#general` |

Point discord.js at the emulator with the `rest.api` option. It reads the Gateway URL from `GET /gateway/bot`, so nothing else changes:

```ts theme={null}
import { Client, Events, GatewayIntentBits } from "discord.js";

const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
  rest: { api: `${process.env.DISCORD_EMULATOR_URL}/api` },
});

client.on(Events.MessageCreate, async (message) => {
  if (message.content === "!ping") await message.reply("pong");
});

await client.login("MTAwMDAwMDAwMDAwMDAwMDEx.baseline.discord-bot-token");
```

`@discordjs/rest` takes the same option: `new REST({ api: \`\${url}/api\` }).setToken(token)\`.

## URL mapping

| Real Discord URL | Emulator URL |
| - | - |
| `https://discord.com/api/v10/...` | `$DISCORD_EMULATOR_URL/api/v10/...` |
| `wss://gateway.discord.gg/?v=10&encoding=json` | `ws://localhost:4000/?v=10&encoding=json` (from `GET /api/v10/gateway/bot`) |
| `https://discord.com/oauth2/authorize` | `$DISCORD_EMULATOR_URL/oauth2/authorize` |
| `https://discord.com/api/oauth2/token` | `$DISCORD_EMULATOR_URL/api/oauth2/token` |
| `https://cdn.discordapp.com/attachments/...` | `$DISCORD_EMULATOR_URL/attachments/...` |

## Auth

Bot routes take `Authorization: Bot <token>` with the token of a seeded application's bot. OAuth2 user tokens go in `Authorization: Bearer <token>` and work on the routes Discord allows them, limited by their scopes: `GET /users/@me` needs `identify` (and shows `email` with the `email` scope), `GET /users/@me/guilds` needs `guilds`, `GET /users/@me/guilds/{guild.id}/member` needs `guilds.members.read`, and command routes take a client credentials token with `applications.commands.update`. A missing scope gets 403 `50026`; a bearer token on a bot-only route gets 401.

Unknown or revoked tokens get `401 {"message": "401: Unauthorized", "code": 0}`. Permission checks follow Discord's rules: the guild owner and `ADMINISTRATOR` pass everything, role permissions add up from `@everyone`, and channel overwrites apply in Discord's order (`@everyone`, roles, then the member). A channel the bot cannot see answers 403 `50001` (Missing Access); a missing permission answers 403 `50013`. Role and member edits respect the role hierarchy.

## OAuth2

* `GET /oauth2/authorize` — Authorization page: also under `/api/v10`. Checks `client_id`, `redirect_uri` against the application's `redirect_uris`, `scope`, and `response_type` (`code` or `token`), then shows a consent page to pick a seeded user. Cancelling redirects with `error=access_denied`
* The `bot` scope installs the application's bot: the page asks for a server the user can manage (Manage Server), creates the bot's managed role with the requested `permissions`, adds the bot, and dispatches `GUILD_CREATE` to it. With a `redirect_uri` (or `response_type=code`), the redirect carries `code`, `guild_id`, and `permissions`, and the code exchange returns the `guild`
* `POST /api/oauth2/token` — Token exchange: also under `/api/v10`. Takes the client by HTTP Basic auth or `client_id` and `client_secret` in the form body. Grants: `authorization_code` (single use codes valid for 10 minutes, PKCE with `S256` or `plain`), `refresh_token` (rotates both tokens), and `client_credentials` (a token for the application owner, `applications.commands.update` included). Access tokens last 7 days
* `POST /api/oauth2/token/revoke` — Revoke token: revokes an access or refresh token, and `GET /api/v10/oauth2/@me` describes the current authorization

## Errors

Errors use Discord's body, `{ "message": "...", "code": 10003 }`, with Discord's JSON error codes (10003 Unknown Channel, 10008 Unknown Message, 50001 Missing Access, 50013 Missing Permissions, 50035 Invalid Form Body, and so on). Invalid bodies get 50035 with the nested `errors` object Discord sends, each field carrying `_errors` such as `{ "code": "BASE_TYPE_REQUIRED", "message": "This field is required" }`. Unknown `/api/v10` routes get `{"message": "404: Not Found", "code": 0}`.

## Rate limits

Every `/api/v10` response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-RateLimit-Reset-After`, and `X-RateLimit-Bucket`. Buckets are per token, per route, and per major parameter (channel, guild, webhook, or interaction), so two channels count separately. An empty bucket answers 429 with `Retry-After`, `X-RateLimit-Scope: user`, and `{"message": "You are being rate limited.", "retry_after": 0.734, "global": false, "code": 0}`. A global limit per token sits on top (`X-RateLimit-Global: true`, `"global": true`); interaction callbacks are exempt from it, as on Discord. The defaults (50 requests a second per bucket and globally) stay out of the way of discord.js; lower them in seed config to test a bot's backoff.

## Users

`GET`/`PATCH /users/@me`, `GET /users/{user.id}`, `GET /users/@me/guilds` (`before`, `after`, `limit` up to 200, `with_counts`), `GET /users/@me/guilds/{guild.id}/member`, `DELETE /users/@me/guilds/{guild.id}`, `POST /users/@me/channels` (opens or reuses a DM).

## Guilds

`GET` (`with_counts`) and `PATCH /guilds/{guild.id}`.

## Channels

`GET`/`POST`/`PATCH` (positions) `/guilds/{guild.id}/channels`, `GET`/`PATCH`/`DELETE /channels/{channel.id}`, `PUT`/`DELETE /channels/{channel.id}/permissions/{overwrite.id}`, `POST /channels/{channel.id}/typing`. Text, voice, category, announcement, stage, forum, and media channels; names are normalized as Discord does (`Release Notes` becomes `release-notes`).

## Messages

`GET /channels/{channel.id}/messages` (`before`, `after`, `around`, `limit` up to 100; needs Read Message History), `GET`/`PATCH`/`DELETE .../messages/{message.id}`, `POST .../messages` (content up to 2000 characters, up to 10 embeds within Discord's limits, components, `nonce` with `enforce_nonce`, replies with `message_reference`, `allowed_mentions`, `flags`, and file uploads as `multipart/form-data` with `payload_json`), and `POST .../messages/bulk-delete` (2 to 100 messages younger than two weeks). Mentions are parsed from content, and replies mention the replied-to author unless `allowed_mentions.replied_user` is false. Uploaded files are served at `GET /attachments/{channel.id}/{attachment.id}/{filename}`.

## Pins

`GET /channels/{channel.id}/messages/pins` and `PUT`/`DELETE .../messages/pins/{message.id}`, plus the older `/channels/{channel.id}/pins` routes. Pinning posts the "pinned a message" system message (type 6).

## Reactions

`PUT`/`DELETE .../reactions/{emoji}/@me`, `DELETE .../reactions/{emoji}/{user.id}`, `GET .../reactions/{emoji}` (`after`, `limit`, `type`), `DELETE .../reactions/{emoji}`, and `DELETE .../reactions`. The emoji is a URL encoded Unicode emoji or `name:id`.

## Roles

`GET`/`POST`/`PATCH` (positions) `/guilds/{guild.id}/roles`, `GET`/`PATCH`/`DELETE .../roles/{role.id}`.

## Members

`GET /guilds/{guild.id}/members` (needs the Server Members intent enabled for the app), `GET .../members/search`, `GET`/`PATCH`/`DELETE .../members/{user.id}` (nick, roles, timeout, mute, deaf; kick), `PUT .../members/{user.id}` with an OAuth2 `access_token` that has `guilds.join`, `PATCH .../members/@me`, and `PUT`/`DELETE .../members/{user.id}/roles/{role.id}`.

## Bans

`GET /guilds/{guild.id}/bans`, and `GET`, `PUT` (with `delete_message_seconds`), and `DELETE .../bans/{user.id}`.

## Application commands

Global and guild `GET`/`POST`/`PUT` (bulk overwrite) and `GET`/`PATCH`/`DELETE` by ID, with Discord's validation of names, descriptions, option nesting and order, choices, and the per-type limits.

## Application

`GET`/`PATCH /applications/@me` (description, tags, the limited intent flags, and `interactions_endpoint_url`), `GET /oauth2/applications/@me`, `GET /gateway`, and `GET /gateway/bot`.

## Gateway

Each REST change dispatches the matching Gateway event, so a bot sees its own REST writes the way it would on Discord.

`GET /api/v10/gateway/bot` returns the emulator's own WebSocket URL (`ws://`, or `wss://` when the base URL is `https://`), `shards: 1`, and a `session_start_limit` of 1000 identifies a day with `max_concurrency: 1`. Connect to it with `?v=10&encoding=json` (version 9 also works). The emulator follows the documented lifecycle:

* `HELLO` (op 10) with `heartbeat_interval` (41250 ms, or `gateway.heartbeat_interval_ms`). Heartbeats (op 1) get `HEARTBEAT_ACK` (op 11). A client that stops heartbeating is closed with 4009 after one and a half intervals
* `IDENTIFY` (op 2) checks the token (4004), the intents (4013 for unknown bits, 4014 for a privileged intent the application has not enabled), and the shard (4010), then sends `READY` with `user`, `guilds` (unavailable), `session_id`, `resume_gateway_url`, `shard`, and `application`, followed by a `GUILD_CREATE` for each guild when the Guilds intent is set
* `RESUME` (op 6) replays every event the session missed, in order, then sends `RESUMED`. A session stays resumable for five minutes after any close except 1000 and 1001; an unknown session or a sequence the buffer no longer covers gets `INVALID_SESSION` (op 9) with `d: false`
* `REQUEST_GUILD_MEMBERS` (op 8) answers with `GUILD_MEMBERS_CHUNK` (needs the Server Members intent). Presence and voice state updates (ops 3 and 4) are accepted
* Other close codes: 4001 unknown opcode, 4002 a payload that does not decode, 4003 a payload before identifying, 4005 a second identify, 4008 more than 120 payloads a minute, 4012 an unsupported version

Dispatched events: `GUILD_CREATE`, `GUILD_UPDATE`, `GUILD_DELETE`, `CHANNEL_CREATE`, `CHANNEL_UPDATE`, `CHANNEL_DELETE`, `CHANNEL_PINS_UPDATE`, `GUILD_ROLE_CREATE`, `GUILD_ROLE_UPDATE`, `GUILD_ROLE_DELETE`, `GUILD_MEMBER_ADD`, `GUILD_MEMBER_UPDATE`, `GUILD_MEMBER_REMOVE`, `GUILD_BAN_ADD`, `GUILD_BAN_REMOVE`, `MESSAGE_CREATE`, `MESSAGE_UPDATE`, `MESSAGE_DELETE`, `MESSAGE_DELETE_BULK`, `MESSAGE_REACTION_ADD`, `MESSAGE_REACTION_REMOVE`, `MESSAGE_REACTION_REMOVE_ALL`, `MESSAGE_REACTION_REMOVE_EMOJI`, `TYPING_START`, and `INTERACTION_CREATE`. Each goes only to sessions whose intents ask for it, whose bot is in the guild and can see the channel, and whose shard owns the guild (`(guild_id >> 22) % num_shards`). Without the Message Content intent, `content`, `embeds`, `attachments`, and `components` are empty except in DMs, in the bot's own messages, and in messages that mention the bot.

Sessions live in memory: they do not survive a restart, and a bot reconnects with a fresh `IDENTIFY` afterwards.

## Interactions

Slash commands, user and message context menu commands, button and select menu clicks, modal submissions, and autocomplete requests reach the application over the Gateway as `INTERACTION_CREATE`, or, when the application has an `interactions_endpoint_url`, as a POST to that URL signed with `X-Signature-Ed25519` and `X-Signature-Timestamp`. Verify it with the application's `verify_key` (from `GET /applications/@me`), as `discord-interactions`' `verifyKey` does. Setting the URL with `PATCH /applications/@me` first sends a signed `PING` that must get a `PONG`, then a request with a bad signature that must get 401, as Discord checks; a URL in seed config is taken as it is.

Answer with `POST /interactions/{interaction.id}/{interaction.token}/callback` (or the HTTP response body) within three seconds. The callback types are 1 (PONG), 4 (message), 5 (deferred message), 6 (deferred update), 7 (update the component's message), 8 (autocomplete choices), and 9 (modal). `with_response=true` returns the `{ interaction, resource }` body; otherwise the answer is 204. A second answer gets 400 `40060`, and one after three seconds gets 404 `10062`. Ephemeral responses (flag 64) are visible only to the invoking user. The token then works for 15 minutes on `POST /webhooks/{application.id}/{interaction.token}` (followups; the first after a deferral fills in the loading message) and `GET`/`PATCH`/`DELETE .../messages/@original` or `.../messages/{message.id}`. A wrong token answers 401 `50027`.

discord.js `interaction.reply`, `deferReply`, `editReply`, `followUp`, `fetchReply`, `update`, `showModal`, and `respond` all work.

## Simulating users

Bots react to people, so these endpoints act as a seeded (non-bot) user. They need no auth and answer with Discord shaped errors.

| Endpoint | Body |
| - | - |
| `POST /_discord/simulate/message` | `user_id` (or `username`), `channel_id` or `recipient_id` (a bot, for a DM), and message fields: `content`, `embeds`, `message_reference`, ... |
| `POST /_discord/simulate/message-update` | `user_id`, `message_id`, and the new fields |
| `POST /_discord/simulate/message-delete` | `user_id`, `message_id` |
| `POST /_discord/simulate/reaction` | `user_id`, `channel_id`, `message_id`, `emoji`, and `remove: true` to take it back |
| `POST /_discord/simulate/member-join` | `user_id`, `guild_id` |
| `POST /_discord/simulate/member-leave` | `user_id`, `guild_id` |
| `POST /_discord/simulate/interaction` | see below |
| `POST /_discord/simulate/gateway-disconnect` | `mode`: `close` (code 4000 by default, or `code`), `reconnect` (op 7, so the client resumes), or `invalidate` (op 9 with `d: false`, so the client identifies again); optional `application_id` |
| `GET /_discord/gateway/sessions` | open and resumable Gateway sessions |

`POST /_discord/simulate/interaction` takes `user_id`, `channel_id`, and a `type`:

* `command` (the default): `command` (the name), `options` as a map checked against the command's options (`{ "text": "hi", "times": 2 }`; subcommands as nested maps) or a raw option array, `command_type` 2 or 3 with `target_id` for context menu commands, and `application_id` when two applications have a command with that name
* `autocomplete`: as `command`, plus `focused` naming the option being typed
* `component`: `message_id` and `custom_id` of a button or select menu, and `values` for a select
* `modal_submit`: `interaction_id` of the interaction that opened the modal, and `values` keyed by text input `custom_id`

It waits up to three seconds for the answer and returns `{ interaction, delivery, status, response, message }`, where `response` is the callback (`{ type, data }`) and `message` the message it posted:

```bash theme={null}
curl -X POST $DISCORD_EMULATOR_URL/_discord/simulate/interaction \
  -H "Content-Type: application/json" \
  -d '{"user_id": "100000000000000001", "channel_id": "100000000000000101", "command": "ping"}'
```

## Seed config

```json theme={null}
{
  "discord": {
    "users": [
      {
        "id": "200000000000000001",
        "username": "ada",
        "global_name": "Ada Lovelace",
        "email": "ada@example.com"
      },
      {
        "username": "grace"
      }
    ],
    "applications": [
      {
        "name": "Deploy Bot",
        "owner": "ada",
        "bot_token": "MjAwMDAwMDAwMDAwMDAwMDEx.baseline.deploy-bot",
        "client_secret": "deploy-bot-secret",
        "private_key": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
        "redirect_uris": ["http://localhost:3000/callback"],
        "privileged_intents": ["guild_members", "message_content"],
        "commands": [
          {
            "name": "ping",
            "description": "Replies with pong"
          },
          {
            "name": "deploy",
            "description": "Deploy a service",
            "guild": "Acme",
            "options": [
              {
                "type": 3,
                "name": "service",
                "description": "Which service",
                "required": true
              }
            ]
          }
        ]
      }
    ],
    "guilds": [
      {
        "name": "Acme",
        "owner": "ada",
        "everyone_permissions": [
          "VIEW_CHANNEL",
          "SEND_MESSAGES",
          "READ_MESSAGE_HISTORY",
          "USE_APPLICATION_COMMANDS"
        ],
        "roles": [
          {
            "name": "Moderator",
            "permissions": ["MANAGE_MESSAGES", "KICK_MEMBERS"],
            "hoist": true
          }
        ],
        "channels": [
          {
            "name": "Text Channels",
            "type": "category"
          },
          {
            "name": "general",
            "topic": "Say hi",
            "parent": "Text Channels"
          },
          {
            "name": "staff",
            "parent": "Text Channels",
            "permission_overwrites": [
              {
                "role": "@everyone",
                "deny": ["VIEW_CHANNEL"]
              },
              {
                "role": "Moderator",
                "allow": ["VIEW_CHANNEL"]
              }
            ]
          },
          {
            "name": "Voice",
            "type": "voice"
          }
        ],
        "members": ["grace"],
        "bots": [
          {
            "application": "Deploy Bot",
            "permissions": ["ADMINISTRATOR"]
          }
        ],
        "messages": [
          {
            "channel": "general",
            "author": "ada",
            "content": "Hello from the seed"
          }
        ]
      }
    ],
    "gateway": {
      "heartbeat_interval_ms": 41250
    },
    "rate_limits": {
      "limit": 50,
      "window_ms": 1000,
      "global_per_second": 50,
      "routes": {
        "POST /channels/:channel_id/messages": {
          "limit": 5,
          "window_ms": 5000
        }
      }
    }
  }
}
```

* `interactions_endpoint_url` (not set above, for example `http://localhost:3000/api/interactions`).

* IDs are optional everywhere; generated ones are snowflakes. References (owners, members, authors, parents, roles in overwrites) take a username, name, or ID

* Permissions take a bitfield (number or string) or a list of names such as `SEND_MESSAGES`

* `bot_token`, `client_secret`, and `private_key` (an Ed25519 seed as 64 hex characters, from which the public key is derived) are generated when left out; the inspector shows them

* `privileged_intents` defaults to all three (`guild_members`, `guild_presences`, `message_content`); list fewer to test a 4014 close

* `bots` installs an application's bot with a managed role holding `permissions`. Seeded bots' roles sit above the other roles, as if an admin dragged them up

* `rate_limits.enabled: false` stops counting (the headers still show a full bucket)

## Inspector

`GET /` shows guilds (channels, members, roles and their permissions), recent messages, applications (bot tokens, client secrets, public keys, commands), recent interactions with how they were delivered and answered, Gateway sessions, and OAuth2 tokens.

## Limits

* The Gateway speaks JSON only: no ETF encoding and no `zlib-stream` transport compression (`compress` in `IDENTIFY` is ignored). Presence and voice state updates are accepted but not dispatched, and there is no voice
* No webhooks other than interaction webhooks, threads, forum posts, stickers, custom emoji management, scheduled events, audit logs, invites, stage instances, auto moderation, polls, entitlements, or application role connections
* Message components and embeds are checked for Discord's limits and stored as sent; components v2 layouts are accepted but not validated in depth
* Interactions are created only by the simulate endpoint; application command permissions are not enforced
* Gateway sessions and rate limit counters live in memory and reset on restart

## Conformance

| | |
| - | - |
| Routes | 101 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | fail, 5 allowed ([discord/discord-api-spec](https://github.com/discord/discord-api-spec) at `ed01408`) |
| SDKs tested | `@discordjs/rest@2.6.3`, `@discordjs/ws@2.0.4`, `discord-api-types@0.38.56`, `discord-interactions@4.4.0`, `discord.js@14.27.0` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.