Skip to main content
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 (14.27.0), @discordjs/rest (2.6.3), @discordjs/ws (2.0.4), and discord-interactions (4.4.0).

At a glance

Start

The Gateway is a WebSocket, and the Discord provider ships the WebSocket kit, so there is nothing else to install (see WebSockets). 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: Point discord.js at the emulator with the rest.api option. It reads the Gateway URL from GET /gateway/bot, so nothing else changes:
@discordjs/rest takes the same option: new REST({ api: \$/api` }).setToken(token)`.

URL mapping

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. 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:

Seed config

  • 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

See CONFORMANCE.md for every provider.