My profile picture. My mom says I'm a handsome boy!
← All Posts

Creating a Discord HTTP Slash Command Bot with Fastify

I've been dying to try out Discord's new slash commands for a few months now, but unfortunately the framework I use for my bots (discord.js) hasn't shipped official support for them yet. I finally got impatient enough to decide to write a slash command bot using the new HTTP API (instead of the persistent Gateway connection), which is a lot easier to use without a library wrapper.

As it turns out, there aren't a lot of tutorials on how to do this yet. I thought it would be useful to publish how I did it - using the HTTP API is a great way to create simple bots! My project idea was to create a bot that imitates the old /slap <user> IRC command.

If you want to see what we'll be building, you can add my hosted version to your server here.

Creating a Discord Bot

First things first - we need to create an official Discord Application for our slash command bot. Sign into the Discord Developer Portal and click "New Application" in the top right corner. Give it a name, and click create. Next up, navigate to "Bot" in the sidebar and click the "Add Bot" button. Verify with "Yes, do it!" on the warning prompt that comes up. With that, we'll have created our application! There are a few values we'll want to grab out of this portal.

  1. Token: On the "Bot" sidebar tab there's a Token value you can copy - we'll be using this to authenticate our server. This value should never be published in public!
  2. Application ID: On the "General Information" sidebar tab there's a field called Application ID. We'll need this to create an invite link.
  3. Public Key: On the "General Information" sidebar tab there's a field called Public Key. We'll be using this to authenticate interaction requests.

We'll be copying these three values into a .env file in our project - more on that below. Also take note of the "Interactions Endpoint URL" field on the "General Information" tab. We'll be using that to link to our server.

Keep this tab open and the values handy. Now let's dive into programming our new bot!

Coding the Bot

Let's start by creating a new folder for our project and initializing it with npm.

terminal
$mkdir trout && cd trout
$npm init

For this particular project, let's use esm by default. All of the supported Node.js versions now fully support it (you can check what version you're on by running node -v in your terminal - it should be 10+) so let's make it a habit to use esm!

We can also add a start script that loads our bot's tokens from a .env file and boots the server.

package.jsonjson
1{
2 "name": "discord-trout",
3 "version": "1.0.0",
4 "description": "Sometimes you gotta slap a person with a large trout",
5 "main": "server.js",
+ "type": "module",
7 "scripts": {
+ "start": "node -r dotenv/config server.js"
- "test": "echo \"Error: no test specified\" && exit 1"
10 },
11 ...
12}

Let's get things kicked off by installing some dependencies for our project.

  1. dotenv is used to load our Bot's secret Tokens from a .env file. This file should not be committed into your source code. If you are using git, add .env to your .gitignore file.
  2. fastify is the server framework we'll be using. If you've used express before, it's ergonomically similar - there are performance benefits to using it though, and it comes with out of the box async/await support.
  3. discord-interactions provides some utilities that we'll need when interacting with the Discord API.
terminal
$npm install discord-interactions fastify dotenv

Now we can create a .env file and copy over our bot information from Discord's Applications page. Replace the values with the ones from the bot you created earlier.

.envtext
1APPLICATION_ID=123
2PUBLIC_KEY=abc
3TOKEN=xyz

With that done, we can move onto the code! We'll start by creating a minimal fastify server. We'll copy most of the code from the fastify getting started guide, and add a GET request for the root URL.

server.jsjavascript
1import fastify from "fastify";
2
3const server = fastify({
4 logger: true,
5});
6
7server.get("/", (request, response) => {
8 server.log.info("Handling GET request");
9});
10
11server.listen(3000, async (error, address) => {
12 if (error) {
13 server.log.error(error);
14 process.exit(1);
15 }
16 server.log.info(`server listening on ${address}`);
17});

If you start the server with npm start you should be able to navigate to http://localhost:3000 and see a message in your terminal that fastify has received and responded to a request!

Terminal output from the server

Now it's time for the fun part - let's start writing the bot logic. The slash command documentation starts off by telling us that we need to respond to a PING interaction type that it will send via a POST request. Let's modify our server code to define a new POST route on the root url, and then have it look at the request object to determine if it's a PING interaction or not. If it is, we should respond with the appropriate PONG interaction. The discord-interactions library we installed earlier has a couple of helpers we can use to make our code clearer!

server.jsjavascript
1import fastify from "fastify";
+import { InteractionResponseType, InteractionType } from "discord-interactions";
3
4const server = fastify({
5 logger: true,
6});
7
8server.get("/", (request, response) => {
9 server.log.info("Handling GET request");
10});
11
+server.post("/", async (request, response) => {
+ const message = request.body;
14
+ if (message.type === InteractionType.PING) {
+ server.log.info("Handling Ping request");
+ response.send({
+ type: InteractionResponseType.PONG,
+ });
+ } else {
+ server.log.error("Unknown Type");
+ response.status(400).send({ error: "Unknown Type" });
+ }
+});
25
26server.listen(3000, async (error, address) => {
27 if (error) {
28 server.log.error(error);
29 process.exit(1);
30 }
31 server.log.info(`server listening on ${address}`);
32});

If we start our server and try to connect our application to Discord at this point, we'll get a failure message. It turns out that our application can't accept every request that it is sent - we need to do some verification work to ensure the request is valid.

Discord API verification failure

There's a helper method in discord-interactions to verify requests, but it requires the raw request body to work. Fastify doesn't have built-in support for that, but luckily there is a community plugin we can set up to get things working. We'll want the plugin to run and transform the request before any handlers run, so we'll register it in fastify with a runFirst option set.

terminal
$npm install fastify-raw-body
server.jsjavascript
1import fastify from 'fastify';
+import rawBody from 'fastify-raw-body';
3import { InteractionResponseType, InteractionType } from 'discord-interactions';
4
5const server = fastify({
6 logger: true,
7});
8
+server.register(rawBody, {
+ runFirst: true,
+});
12
13server.get('/', (request, response) => {
14 server.log.info('Handling GET request');
15});
16
17// ...

Great! Now we have a request.rawBody value that discord-interactions needs. Next we need to set up a fastify hook that checks all incoming requests - we'll want this check to run before any route handler logic, so we'll register it as a preHandler hook. The discord-interactions library explains how to use it to verify a request, so we'll largely copy over what they have written while making some small tweaks to fit it to fastify instead of express.

server.jsjavascript
1import fastify from 'fastify';
+import rawBody from 'fastify-raw-body';
3import {
4 InteractionResponseType,
5 InteractionType,
+ verifyKey,
7} from 'discord-interactions';
8
9const server = fastify({
10 logger: true,
11});
12
13server.register(rawBody, {
14 runFirst: true,
15});
16
17server.get('/', (request, response) => {
18 server.log.info('Handling GET request');
19});
20
+server.addHook('preHandler', async (request, response) => {
+ // We don't want to check GET requests to our root url
+ if (request.method === 'POST') {
+ const signature = request.headers['x-signature-ed25519'];
+ const timestamp = request.headers['x-signature-timestamp'];
+ const isValidRequest = verifyKey(
+ request.rawBody,
+ signature,
+ timestamp,
+ process.env.PUBLIC_KEY
+ );
+
+ if (!isValidRequest) {
+ server.log.info('Invalid Request');
+ return response.status(401).send({ error: 'Bad request signature ' });
+ }
+ }
+});
39
40server.post('/', async (request, response) => {
41 const message = request.body;
42
43 if (message.type === InteractionType.PING) {
44 server.log.info('Handling Ping request');
45 response.send({
46 type: InteractionResponseType.PONG,
47 });
48 } else {
49 server.log.error('Unknown Type');
50 response.status(400).send({ error: 'Unknown Type' });
51 }
52});
53
54server.listen(3000, async (error, address) => {
55 if (error) {
56 server.log.error(error);
57 process.exit(1);
58 }
59 server.log.info(`server listening on ${address}`);
60});

Now if we start the server and try to connect it with the Discord applications page, we should get a success message!

Discord API verification success

It's finally time to begin working on our commands. We'll create two of them - one that implements the famous /slap command from IRC, and another that sends a bot invitation link to users who also want to add the bot to their own servers.

For our Slap command, we'll need the person interacting with the bot to input a user to target. The Discord API docs say that a user mention is type 6 - we'll mark that option as required, since we need someone to slap. The invite command doesn't require an input, so we'll just define it with a name and description.

Since we'll need to register the commands with Discord when we're done defining them, we'll put the definitions in a new commands.js file.

commands.jsjavascript
1export const SLAP_COMMAND = {
2 name: "Slap",
3 description: "Sometimes you gotta slap a person with a large trout",
4 options: [
5 {
6 name: "user",
7 description: "The user to slap",
8 type: 6,
9 required: true,
10 },
11 ],
12};
13
14export const INVITE_COMMAND = {
15 name: "Invite",
16 description: "Get an invite link to add the bot to your server",
17};

Then we can import them and add some helper definitions back in our server code:

server.jsjavascript
1import fastify from 'fastify';
2import rawBody from 'fastify-raw-body';
3import {
4 InteractionResponseType,
5 InteractionType,
6 verifyKey,
7} from 'discord-interactions';
+import { SLAP_COMMAND, INVITE_COMMAND } from './servers.js';
9
+const INVITE_URL = `https://discord.com/oauth2/authorize?client_id=${process.env.APPLICATION_ID}&scope=applications.commands`;
11
12
13const server = fastify({
14 logger: true,
15});
16
17// ...

Next, let's write the code that handles these interactions! We'll go back into our POST route and add checks for someone using our slash commands (the Discord API refers to these events as an Application Command Interaction). The docs have an example schema for an interaction - we can use that to write our function definition.

To start with, we'll want different responses depending on which slash command was used. We can use a switch statement that looks at the command name, and then write out our different responses accordingly.

For our slap message, we want it to display as {user} slaps {target} around a bit with a large trout. Discord messages use simplified Markdown formatting, with special sequences you can use to mention different things - to get this display, we'll format our message like this:

javascript
`*<@${userId}> slaps <@${targetId}> around a bit with a large trout*`;

For our invitation link, let's make the response message ephemeral1 so we don't spam servers. The Discord API docs say that if you want to make a response ephemeral, all you need to do is pass flags: 64 into the response!

Let's see what the completed interaction handler looks like for our server.

server.jsjavascript
1//...
2
3server.post('/', async (request, response) => {
4 const message = request.body;
5
6 if (message.type === InteractionType.PING) {
7 server.log.info('Handling Ping request');
8 response.send({
9 type: InteractionResponseType.PONG,
10 });
+ } else if (message.type === InteractionType.APPLICATION_COMMAND) {
+ switch (message.data.name.toLowerCase()) {
+ case SLAP_COMMAND.name.toLowerCase():
+ response.status(200).send({
+ type: 4,
+ data: {
+ content: `*<@${message.member.user.id}> slaps <@${message.data.options[0].value}> around a bit with a large trout*`,
+ },
+ });
+ server.log.info('Slap Request');
+ break;
+ case INVITE_COMMAND.name.toLowerCase():
+ response.status(200).send({
+ type: 4,
+ data: {
+ content: INVITE_URL,
+ flags: 64,
+ },
+ });
+ server.log.info('Invite request');
+ break;
+ default:
+ server.log.error('Unknown Command');
+ response.status(400).send({ error: 'Unknown Type' });
+ break;
+ }
37 } else {
38 server.log.error('Unknown Type');
39 response.status(400).send({ error: 'Unknown Type' });
40 }
41});
42
43// ...

Now that we have our handler finished, it's time to register our commands with Discord! We'll create a special register script here that we'll call manually. We'll import our command definitions from server.js and then make a PUT request to the commands API with them.

register.jsjavascript
1import fetch from "node-fetch";
2import { SLAP_COMMAND, INVITE_COMMAND } from "./server";
3
4const response = await fetch(
5 `https://discord.com/api/v8/applications/${process.env.APPLICATION_ID}/commands`,
6 {
7 headers: {
8 "Content-Type": "application/json",
9 "Authorization": `Bot ${process.env.TOKEN}`,
10 },
11 method: "PUT",
12 body: JSON.stringify([SLAP_COMMAND, INVITE_COMMAND]),
13 }
14);
15
16if (response.ok) {
17 console.log("Registered all commands");
18} else {
19 console.error("Error registering commands");
20 const text = await response.text();
21 console.error(text);
22}

We can add an entry into our package.json file for ease of use:

package.jsonjson
1{
2 // ...
3 "scripts": {
+ "register": "node -r dotenv/config register.js",
5 "start": "node -r dotenv/config index.js"
6 },
7 // ...
8}

Running it should successfully register the commands:

terminal
$npm run register
Slash commands being registered

If you boot up your server, you should see the slash commands in your guild! Go ahead and try them out - everything should work!

Slash commands being used

Next Steps

Now that you have a finished slash command bot, there are a few places you can go from here. For starters, add some more commands! There's a lot you can do with slash commands. Have some fun trying things out! You can also look into hosting this bot on DigitalOcean, AWS, or something else so that you don't need to worry about running it locally. Personally, I took my finished bot and turned it into a serverless function hosted on Vercel. I'll be writing a (shorter) post on how I did that sometime this week, but if you're curious the current source code can be found at IanMitchell/discord-trout.

There's also a lot of room for code improvement - I have a few ideas of things I want to try to make more of a slash command framework, but there is a lot to explore. Try making writing some reusable functions to make your own personal framework!


If you have any questions or suggestions, shoot me a tweet or email. I'm happy to write more on this subject and do more tutorials on Discord bots if anyone is interested. If there's something you want to do but aren't sure how, let me know and I'm happy to try and help out!


  1. An ephemeral message is a message that's only sent to a single user - it won't be visible for anyone else in the server.