- Alerts
- Node.js
- Getting started
Discord webhook in Node.js: send alerts with embeds, retries and rate limits
Send alerts with a Discord webhook from Node.js: create the webhook, post embeds, stop accidental pings, handle 429 rate limits and the errors you will hit.
- Author
- Extenify Team
- Published
- Reading time
- 11 min read

On this page
A Discord webhook is the shortest path from your Node.js code to a channel your team already reads. There is no bot to host, no OAuth flow and no library to install: you create a webhook in the channel settings, copy its URL, and send it a JSON body with fetch. That is why so many developers use a Discord webhook in Node.js for deploy notices, failed jobs, signups and store alerts such as a new 1-star review or a competitor's release.
The first message takes five minutes. The problems come later: a review that contains @everyone and pings the whole server, a burst of alerts that runs into a 429, a deleted webhook that your code keeps calling until Discord blocks your IP, or an alert that silently never arrived. This guide covers the whole setup with no dependencies, using Node.js 18 or newer, and is based on Discord's webhook, message and rate limit documentation as of October 2026.
Key takeaways
- The webhook URL contains its own token, and Discord says endpoints that take the token in the URL "do not require authentication." Anyone with the URL can post. Keep it in a secret store.
- Add
?wait=true. Without it, Discord's docs say "a message that is not saved does not return an error", so a failed alert can look like a success.- Always send
allowed_mentions: { parse: [] }when the message includes text you did not write, such as review text.- Limits:
contentup to 2,000 characters, up to 10 embeds, embed description up to 4,096, and 6,000 characters across all embeds.- Discord says "rate limits should not be hard coded into your app." Read
X-RateLimit-Remaining,X-RateLimit-Reset-Afterand the 429 body'sretry_afterinstead.- If a webhook returns 404, stop using it. Repeated attempts count toward a temporary IP restriction.
Step 1: Create a Discord webhook and keep the URL secret
You need the Manage Webhooks permission in the server. Then:
- Hover over the channel, open Edit Channel (the gear icon), and go to Integrations.
- Choose Webhooks, then New Webhook.
- Give it a clear name such as "Store alerts", pick the channel, and optionally upload an avatar.
- Click Copy Webhook URL.
The URL looks like https://discord.com/api/webhooks/<id>/<token>. The token is the credential. Discord's webhook reference describes the token-based endpoints, including Execute Webhook, as ones that "do not require authentication", which means the URL alone is enough to post as that webhook, edit its messages or delete it. Treat it like a password:
- Store it in an environment variable or a secret manager, never in source code, a browser extension or a mobile app. If a client needs to trigger alerts, it calls your server, and your server calls Discord.
- Never log it. Log the webhook ID only.
- If it leaks, delete the webhook in the same Integrations screen and create a new one. The old URL stops working immediately.
Step 2: Send your first Discord webhook message from Node.js
A test from the terminal:
curl -s -X POST "$DISCORD_WEBHOOK_URL?wait=true" \
-H 'content-type: application/json' \
-d '{"content": "Hello from the server"}'
The same in Node.js with built-in fetch:
// discord-hello.mjs
const res = await fetch(`${process.env.DISCORD_WEBHOOK_URL}?wait=true`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: 'Hello from the server' }),
});
console.log(res.status, await res.json());
The wait query parameter matters more than it looks. By default it is false, and Discord's documentation says that in that case "a message that is not saved does not return an error". You get a 204 No Content and no way to tell whether the message exists. With wait=true, Discord waits for confirmation and returns the created message, including its id, which you need if you want to edit or delete the alert later.
The body must contain at least one of content, embeds, a file or a poll. content is plain text with Discord markdown, "up to 2000 characters." You can also override the webhook's display name and avatar per message with username and avatar_url, which is handy when one webhook carries alerts from several products.
Format alerts with embeds
Plain text works for one-line notices. For anything with structure (a review, a release, a metric that moved), an embed is easier to scan: a colored bar, a linked title, a description and short fields side by side. These are the limits from the message documentation:
| Embed part | Limit |
|---|---|
title |
256 characters |
description |
4,096 characters |
fields |
up to 25 |
field name / value |
256 / 1,024 characters |
footer.text |
2,048 characters |
| All embeds in one message combined | 6,000 characters |
| Embeds per message | 10 |
A builder for a review alert, truncating every part so a long review never makes the whole request fail:
// embeds.mjs
const cut = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}...` : s);
export function reviewEmbed({ product, store, rating, text, version, url }) {
return {
title: cut(`New ${rating}-star review: ${product}`, 256),
url,
description: cut(text || '(no text)', 3500),
color: rating <= 2 ? 0xd93036 : 0x2b8a3e, // an integer, not a hex string
fields: [
{ name: 'Store', value: cut(store, 1024), inline: true },
{ name: 'Version', value: cut(version ?? 'unknown', 1024), inline: true },
],
timestamp: new Date().toISOString(), // ISO 8601
};
}
Two details trip people up. color is an integer, so write 0xd93036, not "#d93036". And timestamp must be an ISO 8601 string, which toISOString() produces.
Stop accidental pings with allowed_mentions
Review text, commit messages and user names are text you did not write. If one of them contains @everyone, a role mention or a user ID in mention format, your alert can ping the whole server. Discord's message documentation explains that to "completely suppress all mentions in the message," you send allowed_mentions with "parse": []:
{ embeds: [reviewEmbed(review)], allowed_mentions: { parse: [] } }
If you do want to ping an on-call role for critical alerts, list it explicitly with allowed_mentions: { roles: ['<role id>'] } and put <@&role id> in content. The docs note that parse is mutually exclusive with users and roles, so do not send both.
Send low-priority alerts silently
For a daily digest or a "new 5-star review" note, set flags: 4096. That is SUPPRESS_NOTIFICATIONS (1 << 12), which Discord describes as a message that "will not trigger push and desktop notifications." The message still appears in the channel with an unread badge, so nobody's phone buzzes at 3 a.m. for good news.
Handle Discord webhook rate limits
Every response carries rate limit headers. The useful ones:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Requests allowed in the current bucket |
X-RateLimit-Remaining |
Requests left in it |
X-RateLimit-Reset-After |
Seconds until it resets, with decimals |
X-RateLimit-Scope |
On a 429 only: user, global or shared |
Developers commonly report around 5 requests per 2 seconds per webhook (for example in Hookdeck's Discord webhook guide), but Discord is explicit that "rate limits should not be hard coded into your app" because they change. A 429 response has a JSON body with retry_after (seconds, as a float) and global. Two more rules from the rate limit page are worth knowing:
- IP addresses that make too many invalid requests are temporarily restricted. The limit is "10,000 per 10 minutes", and 401, 403 and 429 responses count. A retry loop that ignores
retry_aftercan turn a burst of alerts into an outage. - "If a webhook returns a 404 status you should not attempt to use it again." Someone deleted it; mark it broken and tell a human.
A sender that respects all of this, plus a per-webhook queue so alerts go out one at a time:
// discord.mjs: send a Discord webhook message with retries and pacing.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function sendDiscord(webhookUrl, payload, { maxAttempts = 5 } = {}) {
const url = new URL(webhookUrl);
url.searchParams.set('wait', 'true');
const body = JSON.stringify({ allowed_mentions: { parse: [] }, ...payload });
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
// Bucket empty: wait for it to refill before the next send.
if (res.headers.get('x-ratelimit-remaining') === '0') {
await sleep(Number(res.headers.get('x-ratelimit-reset-after') ?? 1) * 1000);
}
return res.json(); // the created message, thanks to wait=true
}
const data = await res.json().catch(() => ({}));
if (res.status === 429) {
await sleep(Number(data.retry_after ?? 1) * 1000 + 250);
continue;
}
if (res.status >= 500) {
await sleep(2 ** attempt * 500);
continue;
}
// Any other 4xx will fail the same way again: do not retry.
const err = new Error(`Discord ${res.status} (${data.code ?? 'no code'}): ${data.message ?? res.statusText}`);
err.status = res.status;
err.code = data.code;
throw err;
}
throw new Error('Discord webhook: gave up after repeated rate limits or server errors');
}
const queues = new Map();
/** Send through a per-webhook queue so bursts go out in order, one at a time. */
export function enqueueDiscord(webhookUrl, payload) {
const previous = queues.get(webhookUrl) ?? Promise.resolve();
const next = previous.catch(() => {}).then(() => sendDiscord(webhookUrl, payload));
queues.set(webhookUrl, next);
return next;
}
The queue lives in one process. If you run several workers, send alerts from one of them, or put a shared queue in front. It is the same reasoning as the per-user limits in our guide to handling LLM API rate limits: one well-behaved sender beats many polite ones that collide.
Errors you will actually see
Discord returns a JSON body with a numeric code and a message. The ones that matter for webhooks, from the status code reference:
| Status | Code | Meaning | What to do |
|---|---|---|---|
| 400 | 50035 | Invalid form body, often a field over its limit | Log errors from the body, fix the payload |
| 400 | 50006 | Cannot send an empty message | Check that content or embeds is set |
| 400 | 50109 | The request body contains invalid JSON | Check content-type and serialization |
| 404 | 10015 | Unknown webhook | Stop sending, alert a human |
| 4xx | 40005 | Request entity too large | Shorten text or drop attachments |
| 429 | none | Rate limited | Wait retry_after seconds |
For 50035, the response's errors object points at the exact field, for example embeds.0.description, which is usually a review longer than you expected.
Post into threads and forum channels
A busy alert channel gets noisy fast. Two query and body parameters help:
?thread_id=<id>posts into an existing thread. The documentation notes the thread "will automatically be unarchived."- For a forum or media channel, you must pass either
thread_idorthread_name. Withthread_name, "a thread with that name will be created in the channel," so you can open one thread per release or per product and keep follow-ups together.
To fix a mistake, PATCH /webhooks/<id>/<token>/messages/<message id> edits a message the webhook sent, which is why keeping the id from wait=true is useful.
Design Discord alerts people keep reading
The code is the easy part. What makes a channel useful is restraint:
- One digest, not twenty pings. Batch daily changes into one message with up to 10 embeds, and send only urgent items on their own.
- Lead with the change. "Rating 4.6 -> 4.4 on Chrome after 2.4.1" beats "Daily report".
- Link to the source, through the embed
url, so nobody has to search. - Route by urgency. Critical alerts to a channel that notifies, everything else silent with
flags: 4096.
Our guide to store change alerts your team will actually read goes deeper on choosing thresholds, and the same patterns apply whether the destination is Discord, Slack or Telegram bot notifications. If the alerts you want are about AI spend rather than store data, our guide to tracking LLM cost per user shows how to compute the numbers worth sending.
Or get store alerts in Discord without writing the plumbing
If what you want in Discord is news about store listings, Extenify already does the collection, comparison and delivery. It is an app store rating alerts and extension review monitoring tool that tracks installs, weekly users, ratings, reviews and versions on the Chrome Web Store, Edge Add-ons, Firefox Add-ons, Google Play and the App Store, for your own products and your competitors. Paste a Discord webhook URL as a notification channel, confirm it with a six-digit code, and daily change alerts arrive in your channel alongside email, Slack, Telegram or Microsoft Teams. For app reviews specifically, our walkthrough of App Store review alerts in Slack compares the options. Every account starts with a 7-day free trial, and the pricing page lists the plans.
Frequently asked questions
How do I send a message to a Discord webhook in Node.js?
Send a POST request to the webhook URL with a JSON body such as {"content": "Hello"} and the header content-type: application/json. Node.js 18 and newer include fetch, so you do not need a library. Add ?wait=true to get the created message back and a real error if it fails.
Do Discord webhooks need a bot token?
No. The token is part of the webhook URL, and Discord documents the token-based endpoints as not requiring authentication. That is also why the URL must stay secret: anyone who has it can post to your channel.
What is the Discord webhook rate limit?
Discord does not publish a fixed number and says not to hard code limits. Developers commonly observe about 5 requests per 2 seconds per webhook. Read X-RateLimit-Remaining and X-RateLimit-Reset-After on every response, and on a 429 wait for the retry_after value in the body.
Why does my Discord webhook return 400?
Usually code 50035, "Invalid form body", which means a field broke a rule: content over 2,000 characters, an embed description over 4,096, more than 6,000 characters across embeds, or a color sent as a string. The errors object in the response names the field. Code 50006 means the message was empty.
How do I stop a Discord webhook from pinging @everyone?
Send "allowed_mentions": {"parse": []} with every message. Discord then suppresses all mentions, even if the text contains @everyone or a role mention. To ping one role on purpose, list it in allowed_mentions.roles instead.
Can I edit or delete a message sent by a webhook?
Yes. Send the original request with ?wait=true to get the message id, then call PATCH or DELETE on /webhooks/<id>/<token>/messages/<message id>.
Summary
A Discord webhook in Node.js is a single fetch call, but a reliable one needs a few habits: keep the URL secret, add wait=true so failures are visible, use embeds within their limits, suppress mentions for any text you did not write, read the rate limit headers instead of guessing, and stop calling a webhook that returns 404. With a small per-webhook queue and a digest-first design, the channel stays fast, quiet and trusted.
Image credits
- Cover photo: Lamp post with a loudspeaker before Begnas Lake by appealdahal on the WordPress Photo Directory, CC0.


