Guide 03

How to build a Discord bot: applications, intents, slash commands and interactions

Modern Discord bots are built around interactions: slash commands, buttons, select menus and modals. Get the permission model right and the rest is straightforward.

Guide4 min readUpdated

Create the application

  1. Go to the Developer Portal and create an application.
  2. Under Bot, reset and copy the token. Store it in a secret manager. Turn off Public Bot while you develop if you do not want others to install it.
  3. Note the Application ID and Public Key under General Information. The public key verifies HTTP interactions.

Bots join servers through an OAuth2 URL. The two scopes you normally need are bot and applications.commands. The permissions value is a bit field: request only what the features need.

https://discord.com/oauth2/authorize?client_id=APP_ID&scope=bot%20applications.commands&permissions=2147485696

That example value (2048 + 2147483648) grants only Send Messages and Use Application Commands. Avoid Administrator: a compromised token with admin rights can wipe a server. Server owners trust you more when the install screen asks for little.

Gateway intents

If your bot keeps a WebSocket connection to the Gateway, you subscribe to event groups called intents. Three are privileged and must be enabled in the portal:

  • GUILD_MEMBERS: member join, leave and updates.
  • GUILD_PRESENCES: online status and activities.
  • MESSAGE_CONTENT: the text of messages that do not mention the bot.

Most new bots do not need MESSAGE_CONTENT: slash commands deliver their options as structured data. Once a bot is in 100 or more servers it must be verified, and privileged intents require approval with a justification.

Slash commands with discord.js

import { Client, GatewayIntentBits, REST, Routes, SlashCommandBuilder, MessageFlags } from "discord.js";

const commands = [
  new SlashCommandBuilder().setName("ping").setDescription("Check that the bot is alive"),
  new SlashCommandBuilder().setName("remind").setDescription("Set a reminder")
    .addIntegerOption(o => o.setName("minutes").setDescription("In how many minutes").setRequired(true).setMinValue(1).setMaxValue(1440))
    .addStringOption(o => o.setName("text").setDescription("What to remind you about").setRequired(true).setMaxLength(200)),
].map(c => c.toJSON());

// Register once (deploy step). Guild commands update instantly; global ones may take a while to show.
await new REST().setToken(process.env.DISCORD_TOKEN)
  .put(Routes.applicationGuildCommands(process.env.APP_ID, process.env.DEV_GUILD_ID), { body: commands });

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.on("interactionCreate", async (i) => {
  if (!i.isChatInputCommand()) return;
  if (i.commandName === "ping") return i.reply({ content: `Pong: ${client.ws.ping} ms`, flags: MessageFlags.Ephemeral });
  if (i.commandName === "remind") {
    await i.deferReply({ flags: MessageFlags.Ephemeral });       // buys time beyond 3 seconds
    await scheduleReminder(i.user.id, i.options.getInteger("minutes"), i.options.getString("text"));
    await i.editReply("Saved. I will DM you.");
  }
});
client.login(process.env.DISCORD_TOKEN);

The 3-second rule

Every interaction must receive an initial response within 3 seconds, or the user sees "The application did not respond". If work takes longer, defer (deferReply or response type 5) and then edit or send follow-ups. The interaction token stays valid for 15 minutes.

Ephemeral responses are visible only to the user who invoked the command: use them for errors, confirmations and anything personal.

Components and modals

Buttons and select menus carry a custom_id (up to 100 characters) you route on. Encode a short action name and an ID, never trust it as authorization, and re-check permissions on every click. Modals collect text input and are ideal for reports or forms.

HTTP interactions instead of a Gateway

If your bot only uses commands and components, you do not need a persistent connection. Set an Interactions Endpoint URL in the portal and Discord will POST each interaction to you. This works well on serverless platforms such as Cloudflare Workers.

You must verify every request with the Ed25519 signature in X-Signature-Ed25519 over X-Signature-Timestamp + rawBody using your application's public key, and respond to type: 1 (PING) with {"type":1}. Discord sends invalid signatures on purpose to test you.

import { verifyKey } from "discord-interactions";

const ok = await verifyKey(rawBody, req.headers.get("X-Signature-Ed25519"),
                           req.headers.get("X-Signature-Timestamp"), env.DISCORD_PUBLIC_KEY);
if (!ok) return new Response("bad signature", { status: 401 });
const interaction = JSON.parse(rawBody);
if (interaction.type === 1) return Response.json({ type: 1 });

Embeds and webhooks

Embeds are rich cards with a title, description, fields, images and a color. Design one with the Discord embed builder. For one-way notifications (CI results, monitoring) a channel webhook is simpler than a bot: it needs no token or Gateway, only the webhook URL, which you must keep secret.

Always set allowed_mentions so user-supplied text cannot ping @everyone.

Rate limits

Discord returns rate-limit headers on every REST response (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset-After, X-RateLimit-Bucket) and 429 with retry_after when you exceed a bucket. There is also a global limit of about 50 requests per second per bot. The major libraries queue and respect these automatically; if you call the API directly, honor them. Repeatedly ignoring 429s can lead to a temporary IP ban.

Policy essentials

Follow the Developer Policy: do not store message content longer than needed, do not sell or share data, provide a privacy policy and terms URL in the portal, and do not build self-bots (automating a user account is against the terms).

Check your understanding

1. How long do you have for the initial response to an interaction?
2. Which intent is privileged?