# Discord movie bot tutorial

> This tutorial builds a Discord bot with discord.js v14 and a /movie slash command. The bot searches amodb, loads the best match with its ratings and answers with an embed holding the poster, scores, runtime and overview. It defers the reply, retries on 429 and caches answers. It takes about 25 minutes.

## What you build

You build a Discord bot with one slash command, `/movie title`. It replies with an embed that shows the poster, year, IMDb and Kinopoisk scores, runtime, genres and a short overview. You need Node.js 20.6 or newer, discord.js v14, a Discord application with a bot token and an amodb key. Plan on about 25 minutes.

## Step 1: Get your credentials

Pick a plan on [pricing](https://amodb.dev/pricing/). Rate is the only difference between plans, and the lowest allows 20 requests per minute. Create a key in your account dashboard.

In the Discord developer portal create an application, add a bot to it and copy the bot token. Open the OAuth2 page, use the URL generator, tick the `bot` and `applications.commands` scopes and open the link to add the bot to a test server. Copy the server id as well. Put the values in a `.env` file and keep it out of Git:

```bash
DISCORD_TOKEN=YOUR_BOT_TOKEN
DISCORD_GUILD_ID=YOUR_TEST_SERVER_ID
AMODB_KEY=YOUR_API_KEY
```

## Step 2: Create the project

```bash
mkdir movie-discord-bot && cd movie-discord-bot
npm init -y
npm pkg set type=module
npm install discord.js
```

The `type` setting turns on `import` syntax. See [authentication](https://amodb.dev/authentication/) for the header and query forms of the key.

## Step 3: Register the slash command

`SlashCommandBuilder` describes the command and its required `title` option. The bot sends it to Discord once it is ready. With `DISCORD_GUILD_ID` set, the command appears in that server at once. Leave the variable unset to register it everywhere.

```js
import { Client, Events, GatewayIntentBits, SlashCommandBuilder } from 'discord.js';

const movieCommand = new SlashCommandBuilder()
  .setName('movie')
  .setDescription('Look up a movie')
  .addStringOption((option) =>
    option.setName('title').setDescription('Movie title').setRequired(true).setMaxLength(100),
  );

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

client.once(Events.ClientReady, async (ready) => {
  await ready.application.commands.set([movieCommand.toJSON()], process.env.DISCORD_GUILD_ID);
  console.log(`Logged in as ${ready.user.tag}`);
});
```

## Step 4: Build the embed

The details call adds `append_to_response=ratings`, so scores arrive with the movie. Each source is an object with `value` and `votes`, or `null` when the score is not known. The embed prints `value` and says "not known" for `null`. The poster is `https://img.amodb.dev/t/p/w342` plus `poster_path`; see [images](https://amodb.dev/images-api/) for other sizes. An embed description can hold 4096 characters, and the code keeps 400. Add `EmbedBuilder` to the import line and define the `POSTER` constant, as the final listing does.

```js
function movieEmbed(movie) {
  const score = (source) => (source ? String(source.value) : 'not known');
  const genres = (movie.genres ?? []).map((genre) => genre.name).join(', ') || 'not known';
  const embed = new EmbedBuilder()
    .setTitle(`${movie.title} (${(movie.release_date || '').slice(0, 4) || 'n/a'})`)
    .setDescription((movie.overview || 'No overview yet.').slice(0, 400))
    .addFields(
      { name: 'IMDb', value: score(movie.ratings?.imdb), inline: true },
      { name: 'Kinopoisk', value: score(movie.ratings?.kinopoisk), inline: true },
      { name: 'Runtime', value: movie.runtime ? `${movie.runtime} min` : 'not known', inline: true },
      { name: 'Genres', value: genres },
    );
  if (movie.poster_path) embed.setThumbnail(POSTER + movie.poster_path);
  return embed;
}
```

## Step 5: Handle errors and rate limits

Call `deferReply()` first. Discord wants an answer within 3 seconds, and a search plus a details call, or a wait after a 429, can take longer. After a deferral you have 15 minutes to call `editReply()`.

| Case                    | Cause                           | Bot reaction                                                                           |
| ----------------------- | ------------------------------- | -------------------------------------------------------------------------------------- |
| 401                     | Key missing, wrong or suspended | Log it, tell the channel, exit. Every command would fail.                              |
| 429                     | Over your requests per minute   | Wait `Retry-After` seconds, capped at 10, retry twice, then ask the user to try later. |
| Empty `results`         | No title matches                | Reply that no movie was found.                                                         |
| Timeout after 8 seconds | Slow network                    | Reply that the service did not answer.                                                 |

```js
function describeError(error) {
  if (error instanceof AmodbError && error.status === 429) {
    return 'Too many lookups right now. Try again in a minute.';
  }
  return 'The movie service did not answer. Try again later.';
}
```

The retry loop in the final listing reads `Retry-After` from the response headers. See [errors](https://amodb.dev/errors/) and [rate limits](https://amodb.dev/rate-limits/) for every code.

## Step 6: Cache answers

The rate limit check runs before the edge cache, so only a cache in your bot saves requests per minute. This bot keeps a small `TtlCache` class: 10 minutes for search results and 6 hours for movie details, the same lifetimes as the edge. The class is in the final listing. Two people asking for the same title within 10 minutes then cost two requests in total, not four.

## Step 7: The complete bot

Save this as `bot.js`.

```js
import {
  Client,
  EmbedBuilder,
  Events,
  GatewayIntentBits,
  SlashCommandBuilder,
} from 'discord.js';

const { DISCORD_TOKEN, DISCORD_GUILD_ID, AMODB_KEY } = process.env;
if (!DISCORD_TOKEN || !AMODB_KEY) {
  console.error('Set DISCORD_TOKEN and AMODB_KEY first.');
  process.exit(1);
}

const API = 'https://api.amodb.dev/3';
const POSTER = 'https://img.amodb.dev/t/p/w342';

class AmodbError extends Error {
  constructor(status) {
    super(`amodb returned ${status}`);
    this.status = status;
  }
}

class TtlCache {
  #items = new Map();

  get(key) {
    const item = this.#items.get(key);
    if (!item) return undefined;
    if (item.expires < Date.now()) {
      this.#items.delete(key);
      return undefined;
    }
    return item.value;
  }

  set(key, value, ttlMs) {
    this.#items.set(key, { value, expires: Date.now() + ttlMs });
  }
}

const cache = new TtlCache();

async function amodb(path, params, ttlMs) {
  const url = new URL(API + path);
  url.search = new URLSearchParams(params).toString();
  const cached = cache.get(url.href);
  if (cached) return cached;

  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${AMODB_KEY}` },
      signal: AbortSignal.timeout(8_000),
    });
    if (res.status === 429 && attempt < 2) {
      const wait = Math.min(Number(res.headers.get('retry-after')) || 3, 10);
      await new Promise((resolve) => setTimeout(resolve, wait * 1000));
      continue;
    }
    if (!res.ok) throw new AmodbError(res.status);
    const body = await res.json();
    cache.set(url.href, body, ttlMs);
    return body;
  }
}

function movieEmbed(movie) {
  const score = (source) => (source ? String(source.value) : 'not known');
  const genres = (movie.genres ?? []).map((genre) => genre.name).join(', ') || 'not known';
  const embed = new EmbedBuilder()
    .setTitle(`${movie.title} (${(movie.release_date || '').slice(0, 4) || 'n/a'})`)
    .setDescription((movie.overview || 'No overview yet.').slice(0, 400))
    .addFields(
      { name: 'IMDb', value: score(movie.ratings?.imdb), inline: true },
      { name: 'Kinopoisk', value: score(movie.ratings?.kinopoisk), inline: true },
      { name: 'Runtime', value: movie.runtime ? `${movie.runtime} min` : 'not known', inline: true },
      { name: 'Genres', value: genres },
    );
  if (movie.poster_path) embed.setThumbnail(POSTER + movie.poster_path);
  return embed;
}

function describeError(error) {
  if (error instanceof AmodbError && error.status === 429) {
    return 'Too many lookups right now. Try again in a minute.';
  }
  return 'The movie service did not answer. Try again later.';
}

const movieCommand = new SlashCommandBuilder()
  .setName('movie')
  .setDescription('Look up a movie')
  .addStringOption((option) =>
    option.setName('title').setDescription('Movie title').setRequired(true).setMaxLength(100),
  );

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

client.once(Events.ClientReady, async (ready) => {
  await ready.application.commands.set([movieCommand.toJSON()], DISCORD_GUILD_ID);
  console.log(`Logged in as ${ready.user.tag}`);
});

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

  await interaction.deferReply();
  try {
    const title = interaction.options.getString('title', true).trim();
    const found = await amodb('/search/movie', { query: title, language: 'en-US' }, 10 * 60_000);
    const hit = found.results[0];
    if (!hit) {
      await interaction.editReply(`No movie found for "${title}".`);
      return;
    }
    const movie = await amodb(
      `/movie/${hit.id}`,
      { language: 'en-US', append_to_response: 'ratings' },
      6 * 60 * 60_000,
    );
    await interaction.editReply({ embeds: [movieEmbed(movie)] });
  } catch (error) {
    console.error('Lookup failed:', error);
    if (error instanceof AmodbError && error.status === 401) {
      console.error('amodb rejected AMODB_KEY (401). Fix the key and restart.');
      await interaction.editReply('The bot key was rejected. The owner has to fix it.').catch(() => {});
      await client.destroy();
      process.exit(1);
    }
    await interaction.editReply(describeError(error)).catch(() => {});
  }
});

process.on('unhandledRejection', (error) => console.error('Unhandled rejection:', error));

await client.login(DISCORD_TOKEN);
```

Start the bot with the `.env` file, then type `/movie` in the test server and enter a title.

```bash
node --env-file=.env bot.js
```

For `/movie fight club` the first search hit is movie 550, and the embed holds the title `Fight Club (1999)`, the overview cut to 400 characters and the thumbnail `https://img.amodb.dev/t/p/w342/jSziioSwPVrOy9Yow3XhWIBDjq1.jpg`, plus these fields:

| Field     | Value           |
| --------- | --------------- |
| IMDb      | 8.8             |
| Kinopoisk | 8.7             |
| Runtime   | 139 min         |
| Genres    | Drama, Thriller |

The first lookup of a title makes two calls, and later lookups inside the cache lifetime make none. A 401 stops the process after a message, and a timeout produces the generic reply.

## Next steps

- Add autocomplete for the `title` option with the [search](https://amodb.dev/search/) endpoint, which has a 10 minute edge cache.
- Add a `/trending` command using [trending and lists](https://amodb.dev/trending-lists/).
- Add a trailer button with the [videos and trailers](https://amodb.dev/videos-trailers/) endpoint.
- Build the same command for [Telegram](https://amodb.dev/telegram-movie-bot/).

## Questions and answers

### Why does the bot defer the reply?

Discord expects an answer to a slash command within 3 seconds. A search plus a details call, or a wait after a 429, can take longer. deferReply() extends that to 15 minutes, and editReply() then sends the embed.

### Which intents does the bot need?

Only Guilds. Slash commands arrive as interactions, so the bot never reads message content and needs no privileged intent. Add more intents only when a feature you build needs them.

### How should I store the Discord token and the amodb key?

In environment variables, for example a .env file loaded with the env-file option of Node.js, kept out of Git. If a token leaks, reset it in the developer portal. For amodb, create a new key in your account dashboard.

### Can the command show more than one result?

Yes. Build one embed for each of the first three entries in results, because a message can hold up to 10 embeds. Every extra details call costs one request, so watch your requests per minute.

### Does the poster in the embed need an API key?

No. The thumbnail URL points to img.amodb.dev, which serves images without a key, and Discord fetches it when the message appears. Only your bot uses the key, for the JSON calls.

## Related pages

- [Telegram movie bot tutorial](https://amodb.dev/telegram-movie-bot/): Build a Telegram bot with grammY and Node.js that answers /movie with a poster, year and rating from amodb, with retry on 429 and a small in-memory cache.
- [JavaScript movie API tutorial](https://amodb.dev/javascript-movie-api/): Write a Node.js script that loads details, credits and ratings for a list of movie ids in one request each, with a TTL cache, 429 retry and no dependencies.
- [Search API](https://amodb.dev/search/): Search API with the same paths and JSON as TMDB v3. Find movies, TV shows, people, collections, companies and keywords by text, with year filters and paging.
- [Movie and TV ratings](https://amodb.dev/ratings/): Movie ratings API that returns IMDb, Kinopoisk, Metacritic, Rotten Tomatoes and TMDB scores for a title in one call, with null for any source not known.
- [Rate limits](https://amodb.dev/rate-limits/): amodb limits each key by requests per minute in a sliding 60 second window: 20, 40 or 120 by plan. Read the rate limit headers and handle 429 with Retry-After.
- [Authentication](https://amodb.dev/authentication/): How to send your amodb key: api_key in the query, an Authorization Bearer header, or apikey for OMDb calls. Key format, storage, rotation and the 401 errors.

Updated: 2026-10-18. Source: https://amodb.dev/discord-movie-bot/
