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
--service. When it is the only enabled service, it listens on http://localhost:4000.
Default fixtures
With nodiscord 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 takeAuthorization: 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. Checksclient_id,redirect_uriagainst the application’sredirect_uris,scope, andresponse_type(codeortoken), then shows a consent page to pick a seeded user. Cancelling redirects witherror=access_denied- The
botscope 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 requestedpermissions, adds the bot, and dispatchesGUILD_CREATEto it. With aredirect_uri(orresponse_type=code), the redirect carriescode,guild_id, andpermissions, and the code exchange returns theguild POST /api/oauth2/token— Token exchange: also under/api/v10. Takes the client by HTTP Basic auth orclient_idandclient_secretin the form body. Grants:authorization_code(single use codes valid for 10 minutes, PKCE withS256orplain),refresh_token(rotates both tokens), andclient_credentials(a token for the application owner,applications.commands.updateincluded). Access tokens last 7 daysPOST /api/oauth2/token/revoke— Revoke token: revokes an access or refresh token, andGET /api/v10/oauth2/@medescribes 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 guildGET/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) withheartbeat_interval(41250 ms, orgateway.heartbeat_interval_ms). Heartbeats (op 1) getHEARTBEAT_ACK(op 11). A client that stops heartbeating is closed with 4009 after one and a half intervalsIDENTIFY(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 sendsREADYwithuser,guilds(unavailable),session_id,resume_gateway_url,shard, andapplication, followed by aGUILD_CREATEfor each guild when the Guilds intent is setRESUME(op 6) replays every event the session missed, in order, then sendsRESUMED. 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 getsINVALID_SESSION(op 9) withd: falseREQUEST_GUILD_MEMBERS(op 8) answers withGUILD_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
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 asINTERACTION_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),optionsas a map checked against the command’s options ({ "text": "hi", "times": 2 }; subcommands as nested maps) or a raw option array,command_type2 or 3 withtarget_idfor context menu commands, andapplication_idwhen two applications have a command with that nameautocomplete: ascommand, plusfocusednaming the option being typedcomponent:message_idandcustom_idof a button or select menu, andvaluesfor a selectmodal_submit:interaction_idof the interaction that opened the modal, andvalueskeyed by text inputcustom_id
{ 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 examplehttp://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, andprivate_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_intentsdefaults to all three (guild_members,guild_presences,message_content); list fewer to test a 4014 close -
botsinstalls an application’s bot with a managed role holdingpermissions. Seeded bots’ roles sit above the other roles, as if an admin dragged them up -
rate_limits.enabled: falsestops 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-streamtransport compression (compressinIDENTIFYis 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.