Discord Bots

Build Custom Node.js Webhooks for Discord Moderation

Tech Setup4 min read
TS

Tech Setup

Published August 14, 2026 · Editorial policy

Build Custom Node.js Webhooks for Discord Moderation

Spam bots, raid scripts, and malicious links are bleeding your developer community dry, and the out-of-the-box Discord moderation bots lack the exact heuristic checks your server needs. You need custom logic to parse incoming message payloads, cross-reference user metadata against an internal database, and automatically ban or quarantine offenders without manual intervention. By the end of this guide, you will deploy a production-ready Node.js webhook handler built with Express that catches automated abuse, verifies cryptographic signatures, and interacts directly with the Discord API.

Project Architecture & Setup

You are building an HTTP server that listens for Discord webhook events, processes them asynchronously, and executes moderation actions. Instead of relying on a persistent WebSocket connection via discord.js—which can bloat memory and introduce connection dropouts—you will use Discord's HTTP Interactions and Webhook endpoints.

Create a new directory for your project, initialize the Node.js environment, and install the required dependencies:

mkdir discord-mod-webhook
cd discord-mod-webhook
npm init -y
npm install express dotenv nacl-signature-verifier axios
npm install --save-dev nodemon

Your package.json should target modern Node.js versions (v18+ recommended) and use ES modules or CommonJS. For this implementation, we will use CommonJS for broad compatibility with standard crypto libraries. Update your package.json to include a dev script:

"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}

Create a .env file in the root directory to store your sensitive credentials. Never hardcode these values:

PORT=3000
DISCORD_PUBLIC_KEY=your_discord_application_public_key_here
DISCORD_BOT_TOKEN=your_bot_token_with_moderation_permissions_here
GUILD_ID=your_target_discord_server_id

Understanding Discord's Security Handshake

Discord requires all incoming webhook and interaction requests to be cryptographically verified using Ed25519 signatures. Every HTTP request sent to your server includes three specific headers: X-Signature-Ed25519, X-Signature-Timestamp, and the raw request body.

If you do not validate these headers before processing the payload, bad actors can spoof requests to your endpoint, triggering unauthorized bans or spamming your logs.

Writing the Webhook Server

Create a file named server.js. This file will instantiate your Express application, configure raw body parsing (which is mandatory for cryptographic verification), and handle the initial Discord ping event.

require('dotenv').config();
const express = require('express');
const axios = require('axios');
const nacl = require('tweetnacl');

const app = express();
const PORT = process.env.PORT || 3000;

// Discord requires the raw request body buffer to verify Ed25519 signatures.
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf;
  }
}));

// Health check endpoint
app.get('/', (req, res) => {
  res.status(200).send('Discord Moderation Webhook is running.');
});

app.post('/webhook', async (req, res) => {
  const signature = req.headers['x-signature-ed25519'];
  const timestamp = req.headers['x-signature-timestamp'];
  const rawBody = req.rawBody;

  if (!signature || !timestamp || !rawBody) {
    return res.status(401).send('Missing signature headers.');
  }

  // Verify the request came from Discord
  const isVerified = nacl.sign.detached.verify(
    Buffer.from(timestamp + rawBody),
    Buffer.from(signature, 'hex'),
    Buffer.from(process.env.DISCORD_PUBLIC_KEY, 'hex')
  );

  if (!isVerified) {
    console.warn('Invalid request signature detected.');
    return.status(401).send('Invalid request signature.');
  }

  const interaction = req.body;

  // Handle Discord's mandatory PING verification
  if (interaction.type === 1) {
    return res.json({ type: 1 });
  }

  // Handle custom application events or webhook triggers
  if (interaction.type === 2) {
    // Slash command or interaction logic goes here
    return res.json({
      type: 4,
      data: { content: 'Processing moderation check...' }
    });
  }

  res.sendStatus(200);
});

app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

To test this locally, you need a tunneling tool like ngrok or Cloudflare Tunnels since Discord cannot send HTTP requests to localhost. Run your server and open a tunnel:

npm run dev
# In another terminal window:
ngrok http 3000

Take the HTTPS forwarding URL generated by ngrok (e.g., https://xxxx.ngrok-free.app/webhook) and paste it into the "Interactions Endpoint URL" field in your Discord Developer Portal under your Application's General Information tab. Discord will immediately send a PING request; your server will respond with the required verification payload, turning the URL input green.

Implementing Automated Moderation Logic

Now that your server securely ingests events, you need to write the heuristic checks. Let's build a moderation module that evaluates incoming messages for common attack vectors: known phishing domains, zero-day account ages, and invite link spam.

Create a file named moderation.js:

const axios = require('axios');

const DISCORD_API = 'https://discord.com/api/v10';

const BAD_DOMAINS = [
  'discord-free.com',
  'steam-nitro.ru',
  'airdrop-crypto.net'
];

/**
 * Evaluates a user message object for policy violations.
 * @param {Object} message - The Discord message object.
 * @returns {Object} result - Action to take (ban, delete, warn, none).
 */
async function evaluateMessage(message) {
  // Ignore bots and system messages
  if (message.author.bot || message.author.system) {
    return { action: 'none' };
  }

  const content = message.content.toLowerCase();

  // Check 1: Phishing Domains
  const containsBadDomain = BAD_DOMAINS.some(domain => content.includes(domain));
  if (containsBadDomain) {
    return {
      action: 'ban',
      reason: 'Automated filter: Phishing domain detected.',
      userId: message.author.id,
      channelId: message.channel_id,
      messageId: message.id
    };
  }

  // Check 2: Discord Invite Link Spam
  const inviteRegex = /(discord\.(gg|io|me|li)|discordapp\.com\/invite)\/.+/i;
  if (inviteRegex.test(content)) {
    // Allow invites if posted by roles with specific permissions, otherwise delete
    return {
      action: 'delete',
      reason: 'Automated filter: Unauthorized invite link.',
      channelId: message.channel_id,
      messageId: message.id
    };
  }

  return { action: 'none' };
}

/**
 * Executes the moderation action against the Discord REST API.
 * @param {Object} violation 
 */
async function executeAction(violation) {
  const headers = {
    Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN}`,
    'Content-Type': 'application/json'
  };

  try {
    if (violation.action === 'delete' || violation.action === 'ban') {
      // Delete the offending message first
      await axios.delete(
        `${DISCORD_API}/channels/${violation.channelId}/messages/${violation.messageId}`,
        { headers }
      );
      console.log(`Deleted message ${violation.messageId}: ${violation.reason}`);
    }

    if (violation.action === 'ban') {
      // Ban the user and prune their messages from the last 24 hours
      await axios.put(
        `${DISCORD_API}/guilds/${process.env.GUILD_ID}/bans/${violation.userId}`,
        {
          delete_message_seconds: 86400,
          reason: violation.reason
        },
        { headers }
      );
      console.log(`Banned user ${violation.userId} for: ${violation.reason}`);
    }
  } catch (error) {
    console.error('Failed to execute moderation action:', error.response?.data || error.message);
  }
}

module.exports = { evaluateMessage, executeAction };

Integrate this module into your server.js file. Note that standard guild message content requires the MESSAGE_CONTENT Privileged Gateway Intent to be enabled in your Discord Developer Portal, and your webhook must be configured to subscribe to the appropriate events via Discord's Developer portal or external event dispatchers.

const { evaluateMessage, executeAction } = require('./moderation');

// Inside your Express post handler, extend the event routing:
app.post('/webhook', async (req, res) => {
  // ... (keep signature verification logic from earlier)

  const packet = req.body;

  // Handle dispatched gateway events sent via webhooks/subscriptions
  if (packet.t === 'MESSAGE_CREATE' || packet.t === 'MESSAGE_UPDATE') {
    const violation = await evaluateMessage(packet.d);
    if (violation.action !== 'none') {
      await executeAction(violation);
    }
  }

  return res.sendStatus(200);
});

If This Doesn't Work

Even with clean code, webhook integrations fail due to specific network and state issues. Review these three common failure points if your system breaks:

  1. 401 Unauthorized Signature Errors: Ensure you are passing the raw request body buffer (req.rawBody) to nacl.sign.detached.verify. If Express parses the body into a standard JSON object before verification alters the whitespace or ordering, the cryptographic signature will fail every time.
  2. Missing Privileged Intents: If your code never registers message contents and message.content returns empty strings or undefined, you forgot to enable the Message Content Intent toggle inside the Bot section of the Discord Developer Portal.
  3. Webhook Timeouts: Discord expects an HTTP 200 response within 3 seconds of sending an interaction or event payload. If your moderation checks involve heavy database queries or asynchronous loops, respond with res.sendStatus(200) immediately before executing the API delete or ban calls.

Production Hardening & Rate Limit Management

When scaling your custom webhook to handle thousands of concurrent developers across high-traffic servers, synchronous execution of Discord API calls will trigger rate limits. Discord enforces a strict global and per-route rate limiting structure. If you hit HTTP status code 429 Too Many Requests, your bot risks temporary IP throttling or application suspension.

To prevent this, isolate your moderation queue using an in-memory queue or a lightweight Redis-backed worker. For single-server setups, a simple async queue array with a throttle timeout is sufficient:

const actionQueue = [];
let isProcessingQueue = false;

function enqueueAction(actionFn) {
  actionQueue.push(actionFn);
  processQueue();
}

async function processQueue() {
  if (isProcessingQueue || actionQueue.length === 0) return;
  isProcessingQueue = true;

  const action = actionQueue.shift();
  try {
    await action();
  } catch (error) {
    console.error('Queue execution error:', error.message);
  }

  // Enforce a 500ms gap between requests to respect rate limits safely
  setTimeout(() => {
    isProcessingQueue = false;
    processQueue();
  }, 500);
}

Wrap your Axios execution calls inside enqueueAction to serialize API mutations. This guarantees your bot operates within safe execution limits during raid attempts where hundreds of spam messages hit your endpoint simultaneously.