Skip to main content
All articles
· Updated April 24, 2026

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.

discord.jswebhookwebhookclientjavascriptnodejstypescripttutorialdiscord api
Discord Webhooks with discord.js (WebhookClient Complete Guide)

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 for Link-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-Remaining per bucket
  • Queues subsequent requests if remaining hits 0
  • Honors Retry-After from 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.tsWebhookClient 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