# A Discord bot that makes images: slash commands, embeds and certificates

> A discord.js bot that answers a slash command with a generated image: defer within 3 seconds, render a template, show it in an embed or as a file.

Published 2026-11-07 · https://shareglint.com/en/guides/image-bot-discord

Discord communities post images all the time: quote cards of the best lines in a channel, a giveaway winner announcement, a certificate when someone finishes a course, a "level up" card. A bot can make each of them on demand from a template — a member runs `/quote`, the bot renders a branded card with Shareglint and posts it. This guide builds that bot with discord.js and covers the two Discord rules that trip up image bots: the 3-second response deadline and how images get into a message.

The code was run on 2026-11-07 with discord.js 14.28.1 against stand-ins for Discord's API and ours, which recorded the calls it made. Limits are quoted from the [Discord developer docs](https://docs.discord.com/developers/interactions/receiving-and-responding) as of that date.

## What you need

1. **A Discord application with a bot**, created in the [Discord Developer Portal](https://discord.com/developers/applications), its bot token and application ID, and the bot invited to your server with the `applications.commands` and `bot` scopes.
2. **An API key** for Shareglint and a template. The built-in `quote-og` template takes `quote`, `author`, `role`, `site` and `photo`.
3. **Node.js** and `npm i discord.js`.

## The bot

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

const { DISCORD_TOKEN, DISCORD_APP_ID, IMAGE_API_KEY } = process.env;

const quote = new SlashCommandBuilder()
  .setName('quote')
  .setDescription('Turn a quote into an image')
  .addStringOption((o) => o.setName('text').setDescription('The quote').setRequired(true).setMaxLength(280));

// Register the command (once per deploy is enough; global commands can take a while to appear).
await new REST().setToken(DISCORD_TOKEN)
  .put(Routes.applicationCommands(DISCORD_APP_ID), { body: [quote.toJSON()] });

async function render(text, author) {
  const res = await fetch('https://shareglint.com/v1/images?wait=true', {
    method: 'POST',
    headers: { Authorization: `Bearer ${IMAGE_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      template: 'quote-og',
      format: 'png',
      modifications: { quote: text, author },
    }),
  });
  if (!res.ok) throw new Error(`render failed: ${res.status}`);
  return (await res.json()).url;
}

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

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== 'quote') return;
  await interaction.deferReply(); // must happen within 3 seconds
  try {
    const url = await render(interaction.options.getString('text'), interaction.user.displayName);
    await interaction.editReply({ embeds: [new EmbedBuilder().setImage(url).setColor(0x5865f2)] });
  } catch (err) {
    console.error(err);
    await interaction.editReply('Sorry, I could not make that image.');
  }
});

client.login(DISCORD_TOKEN);
```

Save it as `bot.mjs` and run it with the three environment variables set. In our recorded run, the command registered as `/quote` with a required `text` option capped at 280 characters, and one `/quote` produced exactly: `deferReply`, the render request with `quote` and `author`, and `editReply` with one embed whose image is the returned URL.

Slash commands need only the `Guilds` intent: the bot does not read messages, so it does not need the privileged Message Content intent.

## The 3-second rule

Discord's docs are blunt: "you **must send an initial response within 3 seconds** of receiving the event. If the 3 second deadline is exceeded, the token will be invalidated." A render usually takes about a second, but under load or with a large template it can take longer, and the bot's own network adds to that. So the bot **defers first**: `deferReply()` sends response type 5, `DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE`, and members see "Bot is thinking…". After that, "interaction tokens are valid for **15 minutes**" — plenty of time to render and `editReply`.

For a reply only the member sees (a preview before posting, an error message), defer with `deferReply({ flags: MessageFlags.Ephemeral })`.

## Embed or file?

There are two ways to put the image in the message:

**An embed with the URL**, as above. Discord's docs describe the embed image as "source url of image (only supports http(s) and attachments)"; Discord fetches it and shows a proxied copy. The message stays light, and the image is the file on our storage. One image per embed, up to 10 embeds per message.

**An attachment.** Pass the URL in `files` and discord.js downloads it and uploads the bytes to Discord:

```javascript
await interaction.editReply({ files: [{ attachment: url, name: 'quote.png' }] });
```

To show an uploaded file inside an embed, reference it as `attachment://quote.png` in `setImage`; for that, Discord accepts only `.jpg`, `.jpeg`, `.png`, `.webp` and `.gif`. Attachments are needed for MP4 (an animated render with `"format": "mp4"`), and they keep working even if the original URL later expires.

Upload limits, from Discord's docs: the default is **20 MiB per file** for users, bots, webhooks and interaction responses — raised from 10 MiB on 3 September 2026 — and higher in boosted servers; at most 10 attachments per message, and 25 MiB per request. Interactions carry `attachment_size_limit`, so read that instead of hard-coding a number. Our images are far smaller: as PNG at the default 2× scale, the built-in templates we measured were 0.5–0.7 MB, and the photo-heavy `photo-collage` 2.8 MB.

## Certificates, giveaways and level-up cards

The same handler works for any template; only the command's options and the `modifications` change:

- **`/certificate`** for a course or an event: options for the member and the course, rendered with the `certificate` template (fields include `name`, `course`, `date` and `issuer`). Post it in a channel, or send it privately with `member.send({ files: [...] })` — direct messages fail if the member has them turned off, so catch that. Render a PDF as well (`"format": "pdf"`) when it is meant to be printed ([certificates](https://shareglint.com/en/use-cases/certificates)).
- **Giveaway winners** with `giveaway-winner`, posted by the bot when the draw ends.
- **Milestones**, such as a member's level or a server's 10,000th member, triggered by your bot's own logic rather than a command.

Restrict commands that announce or certify something to moderators: set `setDefaultMemberPermissions` on the command builder, so not every member can issue certificates.

## Keeping it safe and cheap

- **Limit per member.** Every distinct request is a render. Keep a cooldown per user per command; identical values return the identical file without using quota, so repeats are free.
- **Validate options.** `setMaxLength` on string options stops oversized input before it reaches your code; template values are escaped, so text cannot become markup.
- **Keep secrets in the environment.** The API key and bot token never go into messages or embeds. For image links that anyone can open without a key, use [signed image URLs](https://shareglint.com/en/guides/signed-image-urls).
- **Fail visibly.** An `editReply` with an error is better than a "thinking…" message that never resolves.

## Cost

One render per new image. A server making 30 quote cards a day uses about 900 renders a month; a course issuing 200 certificates a month uses 200, plus 200 more if each also gets a PDF. The Free plan's 100 renders carry a watermark; Starter is $9 a month for 2,000 renders ([pricing](https://shareglint.com/en/pricing)).

## What it does not do

- We do not host or run your bot, and we do not hold your Discord token.
- We do not read Discord: member names, roles and levels come from your bot.
- Images come from templates and the values you send; this is not an AI image generator.

The same bot for Telegram — long polling, `sendPhoto` and channel posts — is in the [Telegram image bot guide](https://shareglint.com/en/guides/image-bot-telegram).

## FAQ

### How does a Discord bot post a generated image?

Either put the image's public URL in an embed with EmbedBuilder().setImage(url), or attach the file. An embed image can be any http(s) URL; an attachment uploads the file itself, up to Discord's per-file limit (20 MiB by default since September 2026).

### Why does my slash command say 'The application did not respond'?

Discord needs an initial response within 3 seconds or the interaction token is invalidated. Call interaction.deferReply() first, then render the image and send it with editReply — the token stays valid for 15 minutes.

### Can a Discord bot send certificates to members?

Yes: render the certificate template with the member's name and the course or event, then post it in the channel or send it in a direct message. Render PDF too if members should be able to print it.

### Which discord.js version does this guide use?

discord.js 14.28.1, the current v14 release on 2026-11-07. The classes used — SlashCommandBuilder, EmbedBuilder, REST and Client — are the same throughout v14.
