Menu
Sign up

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 November 7, 2026 · 5 min read

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 as of that date.

What you need

  1. A Discord application with a bot, created in the Discord Developer Portal, 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

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:

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).
  • 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.
  • 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).

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.

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.