Discord Webhooks with discord.js (WebhookClient Complete Guide)
Send, edit, delete and stream files through Discord webhooks using the discord.js WebhookClient. TypeScript-ready examples covering embeds, threads, components and rate limit handling.
If you’re already using discord.js for a bot, the library ships a WebhookClient that handles webhook calls for you — rate limit awareness, automatic retries, embed builders, file streams, all included. You don’t need a bot token to use it; the webhook URL is enough.
This guide covers the full webhook lifecycle with [email protected] (the current LTS for 2026): send, edit, delete, files, threads, and the gotchas that catch people coming from raw fetch.
Install
npm install discord.js
WebhookClient is bundled — no separate package needed.
Minimal Send
import { WebhookClient } from 'discord.js';
const webhook = new WebhookClient({ url: process.env.WEBHOOK_URL });
await webhook.send({ content: 'Hello from discord.js!' });
That’s it. WebhookClient parses the URL, extracts ID and token, and POSTs for you. It also handles rate limits transparently — if Discord returns 429, it waits and retries automatically.
From ID + Token
If you have them separate:
const webhook = new WebhookClient({
id: '123456789012345678',
token: 'abc123-secret-token-xyz',
});
Sending Embeds — EmbedBuilder
import { WebhookClient, EmbedBuilder } from 'discord.js';
const webhook = new WebhookClient({ url: process.env.WEBHOOK_URL });
const embed = new EmbedBuilder()
.setTitle('Deployment finished')
.setDescription('All services healthy')
.setColor(0x57f287)
.setTimestamp()
.addFields(
{ name: 'Duration', value: '2m 14s', inline: true },
{ name: 'Commit', value: 'abc1234', inline: true },
)
.setFooter({ text: 'CI bot' });
await webhook.send({ embeds: [embed] });
EmbedBuilder validates limits at build time — setTitle over 256 chars throws synchronously instead of waiting for Discord’s 400.
Multiple Embeds
await webhook.send({
embeds: [
new EmbedBuilder().setTitle('Service A').setColor(0x57f287),
new EmbedBuilder().setTitle('Service B').setColor(0xed4245),
],
});
Up to 10 embeds per message; total text across all embeds ≤ 6000 chars.
Edit & Delete
const message = await webhook.send({ content: 'Working…' });
const messageId = message.id;
// Wait, then update
await new Promise(r => setTimeout(r, 5000));
await webhook.editMessage(messageId, { content: 'Done' });
// Later
await webhook.deleteMessage(messageId);
webhook.send always returns the message object — no ?wait=true query string juggling like with raw fetch. The library handles that under the hood.
Custom Username & Avatar
await webhook.send({
content: 'CI build failed',
username: 'CI Reporter',
avatarURL: 'https://example.com/ci-avatar.png',
});
Override per-message; the webhook’s default name/avatar (set in Discord UI) is used if you omit these.
Sending Files — Buffers, Streams, Paths
discord.js accepts files in three forms:
import fs from 'node:fs';
import { AttachmentBuilder } from 'discord.js';
// 1. From local path
const file1 = new AttachmentBuilder('./report.pdf', { name: 'report.pdf' });
// 2. From Buffer
const buf = Buffer.from('plain text content');
const file2 = new AttachmentBuilder(buf, { name: 'note.txt' });
// 3. From a stream
const stream = fs.createReadStream('./large.log');
const file3 = new AttachmentBuilder(stream, { name: 'large.log' });
await webhook.send({
content: 'Reports attached',
files: [file1, file2, file3],
});
For files near the 25 MB limit, prefer streams — they don’t load the whole file into memory.
Embed with Attached Image
To use a file inside an embed, name it and reference via attachment://:
const chart = new AttachmentBuilder('./chart.png', { name: 'chart.png' });
const embed = new EmbedBuilder()
.setTitle('Q4 Revenue')
.setImage('attachment://chart.png');
await webhook.send({ embeds: [embed], files: [chart] });
Sending into a Thread
If your channel has threads (or forum posts), pass threadId:
await webhook.send({
content: 'Message into thread',
threadId: '987654321098765432',
});
For forum channels, you can also create a new post by setting threadName:
await webhook.send({
content: 'New thread body',
threadName: 'Daily build', // creates a new forum post
});
Edit/delete inside threads requires the same threadId:
await webhook.editMessage(messageId, { content: 'updated' }, { threadId });
Components — Buttons & Select Menus
You can attach interactive components even from a webhook:
import {
WebhookClient,
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
} from 'discord.js';
const webhook = new WebhookClient({ url: process.env.WEBHOOK_URL });
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setURL('https://discord-webhook.com/app')
.setLabel('Open Builder')
.setStyle(ButtonStyle.Link),
new ButtonBuilder()
.setCustomId('approve')
.setLabel('Approve')
.setStyle(ButtonStyle.Success),
);
await webhook.send({
content: 'New deploy ready for approval',
components: [row],
});
Note: webhooks can post buttons with
customId, but they cannot handle the click — only a bot with an interaction listener can. Use webhooks forLink-style buttons (no callback) or pair with a bot that listens for the interaction.
Rate Limits — How discord.js Handles Them
WebhookClient uses the library’s REST manager:
- Tracks
X-RateLimit-Remainingper bucket - Queues subsequent requests if remaining hits 0
- Honors
Retry-Afterfrom 429 responses with auto-retry - Backs off on 5xx errors with exponential delays
You generally don’t need to write your own retry loop — call webhook.send() in a tight loop and the library serializes for you. Configure global behavior:
import { REST } from 'discord.js';
const rest = new REST({
rejectOnRateLimit: ['/channels'], // throw instead of waiting
retries: 3,
});
For most use cases, defaults are fine. If you’re sending > 30 messages per minute per webhook, consider reading the rate limits guide for queue strategies.
TypeScript
The package ships .d.ts — WebhookClient is fully typed:
import { WebhookClient, type WebhookMessageCreateOptions } from 'discord.js';
const webhook = new WebhookClient({ url: process.env.WEBHOOK_URL! });
const opts: WebhookMessageCreateOptions = {
content: 'TS-typed payload',
username: 'TS Bot',
};
await webhook.send(opts);
WebhookMessageCreateOptions, EmbedBuilder, AttachmentBuilder — all exported. Autocomplete works in any modern TS-aware editor.
Error Handling
Errors come as DiscordAPIError with structured details:
import { DiscordAPIError } from 'discord.js';
try {
await webhook.send({ content: 'x'.repeat(2001) });
} catch (err) {
if (err instanceof DiscordAPIError) {
console.error(`Discord rejected: ${err.code} ${err.message}`);
// err.code is the Discord error code (e.g. 50035 = Invalid Form Body)
// err.rawError contains the full response body
} else {
throw err;
}
}
Common codes:
50035— Invalid Form Body (validation)10015— Unknown Webhook (URL invalid)10008— Unknown Message (edit/delete with wrong ID)
Cleanup
WebhookClient keeps an HTTP keep-alive connection. If you’re spawning short-lived processes (Lambda, cron), call destroy() to release sockets:
await webhook.send({ content: 'final' });
webhook.destroy();
In long-running services, just leave it open — connection reuse is what makes burst sends fast.
Try It Live
Build your payload visually in the Discord Webhook builder and copy out the JSON, then drop it directly into webhook.send(...). For other languages, see Python and PHP guides; for raw HTTP details, JavaScript fetch guide.
References
- discord.js docs: WebhookClient
- discord.js docs: EmbedBuilder
- Discord developer docs: Webhook Resource
Try it in our tool
Open Discord Webhook Builder