Bots

How to Make a Discord Bot in 2026: Build a Working Reminder Bot

๐Ÿ“… January 15, 2025๐Ÿ”„ Updated July 19, 2026โฑ 16 min readโœ Discord Timestamp Team

Build a Discord bot that does something useful: a reminder and scheduling bot that posts timestamps every member reads in their own local timezone.

Most "make a Discord bot" tutorials stop at a bot that replies "Pong!" and skip the two steps that make it actually work: inviting it to a server, and registering its commands. Follow one of those and you end up with a process that connects to Discord and does nothing visible anywhere.

This guide goes end to end, and builds something worth keeping โ€” a bot that handles scheduling:

  • /remind 2h take the pizza out posts a live relative timestamp and pings you when the time is up
  • /schedule 2026-08-01T19:00:00Z posts an event time that every member sees in their own local time
  • /whois 175928847299117063 decodes a Discord ID into the date that account was created

We build and run the timestamp generator and snowflake decoder this site is named after, so the time handling here is the part we know best. Everything else is checked against Discord's current documentation, which has moved in ways that quietly break older tutorials: the privileged intent rules changed in June 2026, discord.js needs a much newer Node than most guides state, and the "Add Bot" button they tell you to click no longer exists.

Budget an hour. You need a text editor, a terminal, and a Discord server where you have the Manage Server permission.

Install the Right Node Version

discord.js is the JavaScript library that wraps Discord's API, and it is strict about Node. The current release, discord.js 14.27.0, requires "Node.js 24.17.0 or newer". Node 22 will not run it, which is what trips up anyone following a 2025 tutorial.

Install Node 24, which became the LTS line on 28 October 2025 under the codename Krypton. Check what you have:

node --version
# v24.17.0 or higher

Create the App and Copy the Token

Go to the Discord Developer Portal and select Create App. Give it a name โ€” this is what members will see in the member list and audit log.

There is no "Add Bot" step any more. Discord's own quick-start states that "Newly-created apps have a bot user enabled by default", so the bot user already exists by the time the app is created. Any guide telling you to click Add Bot was written against an older portal.

What you do need from the Bot tab is the token. Discord will not show you an existing token, so the instruction is to "click 'Reset Token' to generate a new bot token". Copy it straight into a password manager โ€” as the docs warn, "You won't be able to view your token again unless you regenerate it."

While you are in the portal, copy the Application ID from the General Information page. You need it to register commands later.

Treat the token exactly like a root password. Anyone holding it controls the bot in every server it has joined. If it leaks, hit Reset Token again; the old one dies immediately.

Intents: What This Bot Needs (Almost Nothing)

Intents tell Discord which categories of events to send you. Three of them are privileged and must be switched on in the portal before you request them: GUILD_PRESENCES, GUILD_MEMBERS and MESSAGE_CONTENT, per Discord's gateway documentation.

This is where a lot of tutorials give bad advice. They tell you to enable all three "for development." Do not. A slash-command bot like this one needs only the non-privileged Guilds intent, and asking for less is both faster to ship and easier to keep.

The thresholds also changed recently, and they are widely misreported. Discord's docs state that "As of June 10th, 2026, we've made some changes to the Privileged Intents review process". The current rules:

SituationWhat applies
Fewer than 10,000 users"Apps with fewer than 10,000 users can access privileged intents by enabling them in the Developer Portal"
More than 10,000 unique users who can see your app across all its serversReview required for continued access, then reapproval annually
100+ serversA separate process: app verification, which is what lets a bot scale past 100 servers

The old "100 servers" figure applied to intents before June 2026 and no longer does. Server count and intent review are now two different gates, and confusing them is the single most common factual error in current bot tutorials.

One practical consequence: if you request a privileged intent in code without enabling it in the portal, Discord closes the connection with code 4014, documented as "Disallowed intent(s)". If your bot connects and instantly drops, that is almost always why.

Invite the Bot to Your Server

The app exists but is in no server, so nothing you write will have anywhere to run. This step is missing from a surprising number of tutorials.

In the Developer Portal sidebar, open OAuth2, then URL Generator. The discord.js guide's instruction is to "Select the bot and applications.commands options" under Scopes. You need both: bot puts the bot user in the server, and applications.commands is what "allows your app to add commands to a guild".

A permissions checklist appears once bot is ticked. For this bot, check only:

  • View Channels
  • Send Messages
  • Embed Links

The generator ORs those checkboxes into a single permissions integer โ€” 1024 | 2048 | 16384, or 19456 โ€” and assembles a URL shaped like this:

https://discord.com/api/oauth2/authorize?client_id=YOUR_APP_ID&permissions=19456&scope=bot%20applications.commands

Copy it, open it in a browser while logged into Discord, pick your server and authorise. The bot appears in the member list, offline. That is expected โ€” nothing is running yet.

How a Bot Actually Connects

Understanding the two-channel model saves hours of debugging later.

Diagram: a Discord bot process holding a persistent gateway WebSocket to Discord while sending actions over the REST API

Your bot process holds a long-lived WebSocket connection to Discord's gateway. That connection stays open for the entire life of the process, and Discord pushes events down it: someone ran a slash command, someone joined, a message was posted. This is why a Discord bot is not a web app that wakes on request โ€” it is a program that must stay running and stay connected.

Actions go the other way over the REST API: sending a message, deleting one, registering commands. Those are ordinary HTTPS calls. discord.js manages both for you, including reconnecting the WebSocket when it drops, but the split matters when you choose hosting, because a platform that suspends idle processes will sever the gateway connection and your bot will simply stop responding.

Set Up the Project

mkdir discord-reminder-bot
cd discord-reminder-bot
npm init -y
npm install discord.js dotenv

Create .env:

DISCORD_TOKEN=your_bot_token
CLIENT_ID=your_application_id
GUILD_ID=your_test_server_id

To get GUILD_ID, enable Developer Mode in Discord (User Settings, Advanced), then right-click your server and Copy Server ID. If you have not done this before, our walkthrough on finding a Discord user ID covers the same Developer Mode toggle.

Then, before your first commit:

echo ".env" >> .gitignore
echo "node_modules" >> .gitignore

The Time Maths

Create time.js. Two small pure functions do all the interesting work.

const DISCORD_EPOCH = 1420070400000n;
const UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 };
const DURATION_RE = /(\d+)\s*([dhms])/gi;

// "1d 6h" -> 108000. Returns null if nothing parsed.
function parseDuration(input) {
  let total = 0;
  let matched = false;

  for (const [, amount, unit] of input.matchAll(DURATION_RE)) {
    total += Number(amount) * UNIT_SECONDS[unit.toLowerCase()];
    matched = true;
  }

  return matched ? total : null;
}

// Snowflakes exceed Number.MAX_SAFE_INTEGER, so this must be done in BigInt.
function snowflakeToUnixSeconds(id) {
  const ms = (BigInt(id) >> 22n) + DISCORD_EPOCH;
  return Number(ms / 1000n);
}

module.exports = { parseDuration, snowflakeToUnixSeconds };

That right-shift is the whole trick behind /whois. Every Discord ID is a snowflake, and Discord's API reference documents the layout as 42 bits of "Timestamp: 63 to 22" milliseconds since the Discord Epoch of 1420070400000, with the conversion given as (snowflake >> 22) + 1420070400000. Shift off the worker, process and increment bits, add the epoch, and you have the exact millisecond the object was created.

The BigInt is not optional. A 19-digit snowflake is larger than Number.MAX_SAFE_INTEGER, so doing this with ordinary numbers silently returns a wrong date. If you want to sanity-check your output, paste an ID into our snowflake decoder โ€” same maths, no code. There is more detail on the bit layout in what a Discord snowflake ID is.

The Commands

Create commands.js. Each command is a builder plus an execute function.

const { SlashCommandBuilder, MessageFlags } = require('discord.js');
const { parseDuration, snowflakeToUnixSeconds } = require('./time.js');

const remind = {
  data: new SlashCommandBuilder()
    .setName('remind')
    .setDescription('Set a reminder and post it as a live relative timestamp')
    .addStringOption((option) =>
      option.setName('when')
        .setDescription('How long from now, e.g. 45m, 2h, 1d 6h')
        .setRequired(true))
    .addStringOption((option) =>
      option.setName('what')
        .setDescription('What to remind you about')
        .setRequired(true)),

  async execute(interaction) {
    const when = interaction.options.getString('when');
    const what = interaction.options.getString('what');
    const seconds = parseDuration(when);

    if (seconds === null || seconds <= 0) {
      return interaction.reply({
        content: `I could not read "${when}". Try 45m, 2h or 1d 6h.`,
        flags: MessageFlags.Ephemeral,
      });
    }

    // setTimeout cannot hold a delay past ~24.8 days (see "Reminders That Survive a Restart"),
    // so refuse a long delay up front rather than confirm a reminder we will silently drop.
    if (seconds > 2_147_483) {
      return interaction.reply({
        content: 'I can only hold reminders up to about 24 days in memory. Try a shorter delay.',
        flags: MessageFlags.Ephemeral,
      });
    }

    const fireAt = Math.floor(Date.now() / 1000) + seconds;
    await interaction.reply(`Reminder set for <t:${fireAt}:F> (<t:${fireAt}:R>): ${what}`);

    const ms = seconds * 1000;
    const { channel, user } = interaction;
    setTimeout(() => {
      channel.send(`${user}, reminder: ${what}`).catch(console.error);
    }, ms);
  },
};

const schedule = {
  data: new SlashCommandBuilder()
    .setName('schedule')
    .setDescription('Post an event time every member sees in their own timezone')
    .addStringOption((option) =>
      option.setName('when')
        .setDescription('ISO 8601 with offset, e.g. 2026-08-01T19:00:00Z')
        .setRequired(true))
    .addStringOption((option) =>
      option.setName('what').setDescription('Name of the event')),

  async execute(interaction) {
    const when = interaction.options.getString('when');
    const what = interaction.options.getString('what') ?? 'Event';
    const ms = Date.parse(when);

    if (Number.isNaN(ms)) {
      return interaction.reply({
        content: 'Use an ISO 8601 time with an offset, e.g. `2026-08-01T19:00:00Z`.',
        flags: MessageFlags.Ephemeral,
      });
    }

    const unix = Math.floor(ms / 1000);
    await interaction.reply(`**${what}**\n<t:${unix}:F> (<t:${unix}:R>)`);
  },
};

const whois = {
  data: new SlashCommandBuilder()
    .setName('whois')
    .setDescription('Decode a Discord ID into the date it was created')
    .addStringOption((option) =>
      option.setName('id')
        .setDescription('A user, channel, message or server ID')
        .setRequired(true)),

  async execute(interaction) {
    const id = interaction.options.getString('id').trim();

    if (!/^\d{17,20}$/.test(id)) {
      return interaction.reply({
        content: 'That does not look like a Discord ID.',
        flags: MessageFlags.Ephemeral,
      });
    }

    const unix = snowflakeToUnixSeconds(id);
    await interaction.reply(`\`${id}\` was created <t:${unix}:D> (<t:${unix}:R>).`);
  },
};

module.exports = [remind, schedule, whois];

The payoff is in the replies. <t:1785610800:F> is Discord's own timestamp markup, and Discord's reference documents the full style table: F renders "Full Date, Short Time", R renders a self-updating relative time like "in 3 days" or "2 months ago", and the value is "expressed in seconds" and shown in each viewer's timezone and locale. Post one of these and a member in Sydney and a member in Lisbon each see the correct local time from the same message โ€” no timezone conversions in your code, and the relative countdown updates itself. Our full reference on Discord timestamp formats covers every style letter, and the Unix converter is handy while debugging the seconds value.

Note the ephemeral replies use flags: MessageFlags.Ephemeral. The old ephemeral: true option is deprecated; the current discord.js guide shows only flags: MessageFlags.Ephemeral.

Register the Commands

Nothing will appear in Discord until you tell Discord the commands exist. Create deploy-commands.js:

const { REST, Routes } = require('discord.js');
require('dotenv').config();

const commands = require('./commands.js').map((command) => command.data.toJSON());
const rest = new REST().setToken(process.env.DISCORD_TOKEN);

(async () => {
  try {
    const data = await rest.put(
      Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
      { body: commands },
    );
    console.log(`Registered ${data.length} guild commands.`);
  } catch (error) {
    console.error(error);
  }
})();

Run it once:

node deploy-commands.js

put replaces the full set, so this script is safe to re-run whenever a command definition changes. You only need to re-run it for definition changes โ€” editing an execute function needs a bot restart, not a redeploy.

Guild versus global is the difference worth knowing. Routes.applicationGuildCommands(clientId, guildId) registers to one server, and Discord's documentation states that "Guild commands update instantly" โ€” which is exactly what you want while iterating. Swapping to Routes.applicationCommands(clientId) registers globally to every server the bot is in. Global commands do not update instantly; Discord describes a "read-repair" mechanism where a stale command is rejected and reloaded for that user on first use. Develop against a guild, ship globally.

Discord's limits are generous: 100 global chat-input commands per app, and a rate limit of "200 application command creates per day, per guild."

The Bot Itself

Create index.js:

const { Client, Collection, Events, GatewayIntentBits, MessageFlags } = require('discord.js');
require('dotenv').config();

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

client.commands = new Collection();
for (const command of require('./commands.js')) {
  client.commands.set(command.data.name, command);
}

client.once(Events.ClientReady, (readyClient) => {
  console.log(`Online as ${readyClient.user.tag}`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  const command = client.commands.get(interaction.commandName);
  if (!command) return;

  try {
    await command.execute(interaction);
  } catch (error) {
    console.error(`/${interaction.commandName} failed:`, error);
    const payload = { content: 'That command failed.', flags: MessageFlags.Ephemeral };
    if (interaction.replied || interaction.deferred) {
      await interaction.followUp(payload);
    } else {
      await interaction.reply(payload);
    }
  }
});

client.login(process.env.DISCORD_TOKEN);

Use Events.ClientReady, not the string 'ready'. In current discord.js the enum member ClientReady maps to the string 'clientReady' โ€” it was renamed to distinguish it from Discord's own gateway READY event. Code written as client.once('ready', ...) now emits a deprecation warning and stops firing in v15. The enum keeps working across both.

Start it:

node index.js

The bot goes online. Type / in your server and the three commands appear.

Reminders That Survive a Restart

The setTimeout above is deliberately naive, and there are two real constraints behind it.

First, timers are memory-only. Restart the process โ€” a deploy, a crash, a host reboot โ€” and every pending reminder is gone. For anything beyond a toy, write reminders to a database (SQLite is plenty) and reload the pending ones on startup.

Second, setTimeout has a hard ceiling. Node's documentation states that "When delay is larger than 2147483647 or less than 1 or NaN, the delay will be set to 1" โ€” so a delay past roughly 24.8 days does not wait, it fires immediately. That is why the command refuses anything longer than about 24 days up front, rather than confirming a reminder it would silently drop. Long reminders need a persisted due-time and a periodic sweep, not one long timer.

There is a third trap this code already sidesteps. The reminder is delivered with channel.send(), not interaction.followUp(), because Discord's docs state that "Interaction tokens are valid for 15 minutes". Any follow-up on a two-hour reminder would fail. Plenty of tutorials get this wrong because they only ever test with short delays.

While you are here: you have three seconds to send an initial response. Discord requires that you "must send an initial response within 3 seconds of receiving the event". If a command hits a database or an external API, call interaction.deferReply() first and edit the reply when the work finishes.

Hosting It 24/7

A gateway bot has to stay running, which rules out most "serverless" offers. Published figures only:

OptionCostFit for a gateway bot
RailwayHobby $5/month; new accounts get a "One-time $5 credit grant to try Railway"Good. Deploys from GitHub, keeps the process alive. There is no permanently free plan for an always-on bot.
DigitalOcean App PlatformPer-instance pricingGood, with a published SLA: DigitalOcean will use "commercially reasonable efforts to provide a Monthly Uptime Percentage of 99.95% for each App Platform ACI" โ€” not the 99.99% often quoted second-hand.
A plain VPSVaries by providerGood. Most control, most maintenance. Run under systemd or Docker so it restarts on reboot.
Google Cloud RunPay per instance-timePoor fit. See below.

Cloud Run gets recommended constantly on the strength of "it scales to zero." For a gateway bot that framing is misleading twice over. Google's own docs state that "A Cloud Run instance that has any open WebSocket connection is considered active, so CPU is allocated and the service is billed as instance-based billing" โ€” your bot never scales to zero while it is connected, so the cost saving does not apply. And WebSocket connections are subject to the request timeout, which defaults to 5 minutes and maxes out at 60, after which "the client will be disconnected when the request times out." Your bot gets forcibly disconnected at least hourly and must reconnect. It is workable, but it is not the cheap, hands-off option it is sold as.

We compare the always-on options in more depth in our guide to Discord bot hosting.

Mistakes Worth Avoiding

Stale API patterns. Beyond 'ready' and ephemeral: true, be suspicious of any tutorial built on message-prefix commands like !ping. Reading message text requires the privileged Message Content intent; slash commands require none of it.

Misreading bulkDelete. If you add a purge command, the signature is bulkDelete(messages, filterOld?). filterOld is widely mis-commented as "only delete messages newer than 14 days," which reads like a safety net. It is a skip: when true, discord.js strips messages older than two weeks out of the batch before sending it, because Discord's API rejects them with error 50034, "A message provided was too old to bulk delete". Those messages are not deleted. Expect the same shortfall when you fetch N messages and then filter by author or content โ€” you will delete however many survive the filter, not N. Report the actual returned count, never the requested one.

Committing the token. Rotating a leaked token is trivial; noticing the leak is not. Add .env to .gitignore before the first commit, not after.

Where to Go Next

You now have a bot that is invited, registered, and doing something no generic tutorial bot does. Sensible next steps: persist reminders to SQLite, add .setDefaultMemberPermissions() to gate admin-only commands, and use deferReply() anywhere you touch the network.

If the scheduling side is what you actually needed, it may be worth checking whether you need a bot at all. Discord's built-in scheduled events handle recurring community events natively, and a plain timestamp from our generator or a countdown solves the "what time is that for me?" problem in a pinned message with no code and no hosting bill.

For the API itself, Discord's developer documentation is authoritative and the discord.js guide is the best worked reference for the library. Both are kept current โ€” more than can be said for most bot tutorials, which quietly drift out of date as Discord's portal and API change.

Share this article

Related Articles

Building or scheduling with a bot?

Grab the timestamp codes your bot needs โ€” free, instant, no login.

This website is not affiliated with, endorsed by, or connected to Discord Inc. It's an independent Discord timestamp generator tool created for the Discord community.