Mock the Discord API โ€” test webhooks and bots without spamming a channel

The Discord webhook dev loop is embarrassing: POST, alt-tab, squint at the channel, delete the test message, repeat โ€” while your teammates watch test test 123 embeds scroll by. And that's the happy path. The failure paths are worse, because the real API won't produce them on cue: you can't ask Discord for a 429 to test your backoff, or a 50006 to test your error branch. Worst of all, generating errors against the real API is actively dangerous: Discord's rate-limit docs state that IPs making too many invalid requests (401, 403, or 429 responses) โ€” currently 10,000 per 10 minutes โ€” are temporarily banned from the API entirely. A test suite that hammers error paths against the real thing is a self-DoS with a fuse.

So: mock it. This page builds Discord-shaped endpoints that return the real API's exact bodies (we captured them live first), points the official @discordjs/rest client at them, and drills the paths the real API can't give you deterministically.

1. What the real API returns (verified live)

Everything in this section was captured against discord.com/api in September 2026. A successful webhook execute returns 204 No Content with an empty body โ€” append ?wait=true and you get 200 plus the created message object instead. Your payload must include at least one of content, embeds, components, file, or poll, or you get the classic:

HTTP 400  {"message": "Cannot send an empty message", "code": 50006}

A wrong or revoked webhook URL:

HTTP 404  {"message": "Unknown Webhook", "code": 10015}

The webhook route carries a per-webhook rate-limit bucket โ€” x-ratelimit-limit: 5 โ€” and bursting past it returns:

HTTP 429  {"message": "You are being rate limited.", "retry_after": 0.373, "global": false}

One observed nugget worth testing around: in our session the Retry-After header on that 429 said 1973 while the JSON body's retry_after said 0.373 โ€” four orders of magnitude apart. (The big number reflects the invalid-request penalty ramping up; the body reflects the bucket.) If your retry code reads one and your library reads the other, you have two different behaviors in production. That's exactly the kind of thing to pin down against a mock where you choose the values.

2. See what your code actually sends โ€” 10 seconds, no setup

Embeds fail in non-obvious ways: color is a decimal integer, timestamp is ISO 8601, field structures nest two levels deep. Before debugging any of that against Discord, look at the exact bytes your code produces. Point it at the shared demo bin:

curl -X POST 'https://mockbird.mockbird.workers.dev/m/demo/hooks/discord' \
  -H 'content-type: application/json' \
  -d '{"embeds":[{"title":"Deploy finished","color":5763719,
       "fields":[{"name":"env","value":"prod","inline":true}],
       "timestamp":"2026-09-21T19:30:00.000Z"}]}'
# โ†’ {"ok":true, "caught":"POST /hooks/discord", "body":{"embeds":[โ€ฆ]}, โ€ฆ}

The response echoes what arrived, and the demo's request inspector (public, in the dashboard under project demo) logs method, path, headers, and body. This alone answers the eternal "did my library actually serialize the embed?" question.

A real Discord webhook URL is a credential โ€” anyone holding it can post to your channel, so it doesn't belong in CI configs or logs. The mock URLs on this page hold no such power; they're safe to commit.

3. Build the Discord-shaped mock (one paste)

Create a project and add routes that speak the dialect from section 1. The /v10/โ€ฆ prefix matters โ€” it lets the official client target the mock unmodified (section 4):

curl -X POST 'https://mockbird.mockbird.workers.dev/api/projects' \
  -H 'content-type: application/json' -d '{"name":"discord-mock"}'
# โ†’ {"id":"YOURID", "adminKey":"YOURKEY"}
BASE='https://mockbird.mockbird.workers.dev/api/projects/YOURID/routes'
AK='x-admin-key: YOURKEY'

# webhook execute โ†’ 204 empty (contentType text/plain โ€” see note below)
curl -X POST "$BASE" -H "$AK" -H 'content-type: application/json' -d '{
  "method":"POST","path":"/v10/webhooks/:id/:token",
  "status":204,"contentType":"text/plain","body":""}'

# wait=true variant โ†’ 200 + message object, echoing your content
curl -X POST "$BASE" -H "$AK" -H 'content-type: application/json' -d '{
  "method":"POST","path":"/v10/webhooks/:id/:token/wait","status":200,
  "contentType":"application/json",
  "body":"{\"id\":\"1419028408860672000\",\"type\":0,\"content\":{{{body.content}}},\"channel_id\":\"1419020000000000000\",\"timestamp\":\"{{now}}\",\"webhook_id\":\"{{params.id}}\"}"}'

# byte-exact 429 drill, with the real bucket headers
curl -X POST "$BASE" -H "$AK" -H 'content-type: application/json' -d '{
  "method":"POST","path":"/drills/429","status":429,
  "contentType":"application/json",
  "headers":{"Retry-After":"1","X-RateLimit-Scope":"user","X-RateLimit-Limit":"5","X-RateLimit-Remaining":"0","X-RateLimit-Reset-After":"1"},
  "body":"{\"message\": \"You are being rate limited.\", \"retry_after\": 0.373, \"global\": false}"}'

# error-code drills (add /v10/โ€ฆ twins if you call them through @discordjs/rest)
curl -X POST "$BASE" -H "$AK" -H 'content-type: application/json' -d '{
  "method":"POST","path":"/v10/drills/50006","status":400,"contentType":"application/json",
  "body":"{\"message\": \"Cannot send an empty message\", \"code\": 50006}"}'
curl -X POST "$BASE" -H "$AK" -H 'content-type: application/json' -d '{
  "method":"POST","path":"/v10/drills/10015","status":404,"contentType":"application/json",
  "body":"{\"message\": \"Unknown Webhook\", \"code\": 10015}"}'

Now POST /m/YOURID/v10/webhooks/anything/anything behaves like Discord's happy path, the /wait twin returns a message object echoing your content with a live timestamp, and each drill returns its byte-exact body.

The 204 content-type gotcha (this one bit us): Discord's real 204 carries no JSON. If your mock's 204 route claims application/json, well-behaved HTTP clients โ€” including @discordjs/rest โ€” try to parse the empty body and throw Unexpected end of JSON input. Set contentType to text/plain on empty-body routes and the official client is happy.

4. Point the official client at it (verified with @discordjs/rest 2.6.3)

The REST class takes an api option. One line, no patching:

const { REST } = require('@discordjs/rest');

const rest = new REST({
  api: 'https://mockbird.mockbird.workers.dev/m/YOURID',  // instead of discord.com/api
  version: '10',
}).setToken('fake-token-never-real');

// happy path โ€” resolves with {} on the mock's 204, exactly like the real API
await rest.post('/webhooks/123/token', { body: { content: 'hi' }, auth: false });

// wait variant โ€” full message object back
const msg = await rest.post('/webhooks/123/token/wait',
  { body: { content: 'waited' }, auth: false });
// โ†’ { id: '1419028408860672000', content: 'waited', channel_id: โ€ฆ, timestamp: โ€ฆ }

// error dialect โ€” the library raises REAL DiscordAPIErrors from the mock's bodies
try { await rest.post('/drills/50006', { body: {}, auth: false }); }
catch (e) { e.code; }     // DiscordAPIError, code 50006, "Cannot send an empty message"
try { await rest.post('/drills/10015', { body: {}, auth: false }); }
catch (e) { e.code; }     // DiscordAPIError, code 10015, "Unknown Webhook"

That last part is the point: your catch (e) { if (e.code === 10015) โ€ฆ } branches run against the same error class and code they'd see in production, because the mock speaks the same bodies. If your stack is discord.py or a plain fetch/requests call, the story is even simpler โ€” webhook code should take its URL from config anyway, so the mock is a config change, not a code change.

5. The retry drill the real API can't give you

You cannot ask the real Discord API to 429 you twice and then succeed. The mock can, deterministically, with ?mock_seq:

const key = 'ci-run-' + Date.now();
await rest.post(`/webhooks/123/tok?mock_seq=429,429,204&mock_seq_key=${key}`,
  { body: { content: 'retry drill' }, auth: false });
// resolves โ€” the library ate two 429s and retried through them (1.24s in our run)

We verified exactly that: @discordjs/rest transparently absorbed the two injected 429s and resolved on the third attempt. Same script, every CI run โ€” no real rate limit was harmed. The sequence's injected 429 bodies are Mockbird-generic (status codes are exact; the byte-exact Discord 429 body lives at your /drills/429 route), which is fine for exercising retry machinery. Two more drills from the same toolbox:

# a live rate limit mirroring the webhook bucket (5, then real 429s + Retry-After)
curl -X POST 'โ€ฆ/m/YOURID/v10/webhooks/1/t?mock_ratelimit=5' -d '{"content":"x"}' \
  -H 'content-type: application/json'

# does your notifier hang or fail-fast? stall the mock past your HTTP timeout
curl 'โ€ฆ/m/YOURID/v10/webhooks/1/t?mock_delay=4000' -X POST -d '{"content":"x"}' \
  -H 'content-type: application/json'

Honest comparison

Private test server + real webhooknock / MSW / in-process mocksGeneric webhook testersMockbird
Renders your embed visuallyโœ” the only wayโœ—โœ—โœ—
Errors (429/50006/10015) on cueโœ—โœ” in one processโœ—โœ” as a URL, any stack
Deterministic retry sequencesโœ—~ hand-rolledโœ—โœ” ?mock_seq
Safe to hammer from CIโœ— invalid-request ban riskโœ”~โœ” 10k req/day free
Shows the exact payload sent~ squint at the channel~โœ”โœ” inspector + echo
Speaks Discord's error dialect backโœ”you write itโœ—โœ” verified bodies

Straight answer: creating a private Discord server and a real webhook is free, takes two minutes, and is the right final check โ€” a mock cannot show you how an embed renders, and nothing here replaces that. Use in-process mocks (nock/MSW) for pure unit tests. Use Mockbird when the mock needs to be a URL: CI without credentials in logs, error-path and retry drills that would count against Discord's invalid-request limits, several services sharing one fake Discord, or just seeing what your code actually sent.

Notes & limits

Create yours

The paste block in section 3 is the whole setup โ€” or open the dashboard and click the routes together. Every project also gets a request inspector, GraphQL, and snapshot fixtures.

โšก Skip the terminal: this link creates a blank project in the dashboard โ€” add the routes above by clicking, no signup. Or start from a seeded e-commerce backend to mock your data API alongside your Discord notifications.

Verification: every mock call on this page (the demo bin embed echo, all six routes, the 204-empty and wait-variant responses, byte-exact 429/50006/10015 drill bodies with headers, @discordjs/rest resolving the 204 as {}, returning the wait message object, raising DiscordAPIError code 50006 and 10015, and transparently retrying through mock_seq=429,429,204 in 1.24s) was run against production on 21 Sep 2026 before publishing. Live-Discord facts were captured the same day: POST /api/webhooks/โ€ฆ with a wrong token โ†’ 404 {"message": "Unknown Webhook", "code": 10015}, a 12-request burst โ†’ 429 {"message": "You are being rate limited.", "retry_after": 0.373, "global": false} with x-ratelimit-limit: 5, x-ratelimit-scope: user, and a Retry-After header disagreeing with the body. If a step here doesn't work, that's a bug: tell us.