Discord Screenshot Bot with discord.js

Build a Discord /screenshot slash command that posts website captures as image attachments. discord.js v14 code with cooldowns and error replies, tested.

By Simon Chiu · Updated

A Discord screenshot bot is about 80 lines of discord.js: a /screenshot url: slash command, a deferred reply, one call to the RenderScreenshot API and an image attachment. The browser runs on our side, so the bot itself stays small enough for a free hosting tier. This page has the complete bot, the command registration script and the test we ran against it.

It is part of the screenshot API integrations. For the other chat platform, see Slack.

What you can build

  • A /screenshot command for a support or community server, so members can show a page without uploading their own captures.
  • A release check: post the landing page after each deploy, in dark and light mode.
  • A moderation helper that keeps a record of a reported page as it looked at the time of the report.

Prerequisites

  • Node 20 or later and npm i discord.js@14 (tested with 14.27.0).
  • A Discord application with a bot user. Copy the bot token, the application ID and the ID of your test server.
  • Invite the bot with the bot and applications.commands scopes. No privileged intents are needed.
  • An API key that starts with rs_live_. Get a free key.

Step 1: define the command

Save as command.mjs. Add "type": "module" to package.json.

// command.mjs: the /screenshot slash command definition.
import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js';

export const screenshotCommand = new SlashCommandBuilder()
  .setName('screenshot')
  .setDescription('Capture a web page and post it here')
  .addStringOption((o) => o.setName('url').setDescription('https:// address of the page').setRequired(true))
  .addBooleanOption((o) => o.setName('full_page').setDescription('Capture the whole page, not only the first screen'))
  .addBooleanOption((o) => o.setName('dark').setDescription('Ask the page for its dark color scheme'))
  .addBooleanOption((o) => o.setName('private').setDescription('Only you see the result'))
  // Every capture uses a credit. Start with moderators; widen it later in Server Settings > Integrations.
  .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages);

setDefaultMemberPermissions is the cheapest abuse control you have. Server admins can change who may use the command later without a code change.

Step 2: register it

Save as deploy-commands.mjs and run it once, and again after every change to the command:

// deploy-commands.mjs: register /screenshot in one server (guild commands update at once).
// Usage: DISCORD_TOKEN=... DISCORD_APP_ID=... DISCORD_GUILD_ID=... node deploy-commands.mjs
import { REST, Routes } from 'discord.js';
import { screenshotCommand } from './command.mjs';

const { DISCORD_TOKEN, DISCORD_APP_ID, DISCORD_GUILD_ID } = process.env;
const rest = new REST().setToken(DISCORD_TOKEN);
await rest.put(Routes.applicationGuildCommands(DISCORD_APP_ID, DISCORD_GUILD_ID), {
  body: [screenshotCommand.toJSON()],
});
console.log('Registered /screenshot');

Guild commands are the right choice while you build. When the bot is ready for every server it joins, switch to Routes.applicationCommands(DISCORD_APP_ID).

Step 3: the bot

Save as bot.mjs:

// bot.mjs: answers /screenshot with an image attachment.
// Usage: DISCORD_TOKEN=... RS_API_KEY=rs_live_... node bot.mjs
import { AttachmentBuilder, Client, Events, GatewayIntentBits, MessageFlags } from 'discord.js';

const RS_BASE_URL = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const COOLDOWN_MS = 30_000;
const lastUse = new Map(); // user id -> timestamp

export async function capture({ url, fullPage, dark }) {
  const body = {
    url,
    viewport: { width: 1280, height: 800 },
    output: { format: 'jpeg', quality: 80 }, // JPEG keeps full-page captures well under upload limits
    block: { ads: true, cookie_banners: true },
    cache: { available: true }, // the same request within 24 h costs 0 credits
  };
  if (fullPage) body.capture = { mode: 'full_page', scroll: { max_height: 6000 } };
  if (dark) body.browser = { dark_mode: true };

  const res = await fetch(`${RS_BASE_URL}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(60_000),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    const e = new Error(err.error?.message ?? `HTTP ${res.status}`);
    e.code = err.error?.code ?? `http_${res.status}`;
    throw e;
  }
  return Buffer.from(await res.arrayBuffer());
}

export async function handleScreenshot(interaction) {
  const url = interaction.options.getString('url', true);
  const fullPage = interaction.options.getBoolean('full_page') ?? false;
  const dark = interaction.options.getBoolean('dark') ?? false;
  const hidden = interaction.options.getBoolean('private') ?? false;

  if (!/^https?:\/\/[^\s]+$/i.test(url)) {
    return interaction.reply({ content: 'Send a full address that starts with https://', flags: MessageFlags.Ephemeral });
  }
  const now = Date.now();
  if (now - (lastUse.get(interaction.user.id) ?? 0) < COOLDOWN_MS) {
    return interaction.reply({ content: 'One screenshot every 30 seconds, please.', flags: MessageFlags.Ephemeral });
  }
  lastUse.set(interaction.user.id, now);

  // Discord drops an interaction that gets no answer within 3 seconds. A render takes longer, so defer first.
  await interaction.deferReply(hidden ? { flags: MessageFlags.Ephemeral } : {});
  try {
    const image = await capture({ url, fullPage, dark });
    const file = new AttachmentBuilder(image, { name: 'screenshot.jpg', description: `Screenshot of ${url}` });
    await interaction.editReply({ content: `<${url}>`, files: [file] });
  } catch (err) {
    console.error('screenshot failed', { url, code: err.code, message: err.message });
    await interaction.editReply({ content: `Could not capture that page (${err.code}).` });
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  const client = new Client({ intents: [GatewayIntentBits.Guilds] });
  client.once(Events.ClientReady, (c) => console.log(`Ready as ${c.user.tag}`));
  client.on(Events.InteractionCreate, async (interaction) => {
    if (!interaction.isChatInputCommand() || interaction.commandName !== 'screenshot') return;
    try {
      await handleScreenshot(interaction);
    } catch (err) {
      console.error(err); // a failed reply must not crash the bot
    }
  });
  await client.login(process.env.DISCORD_TOKEN);
}

Design notes from building it:

  • Defer, then edit. The three-second rule is the first thing that breaks a screenshot bot. After deferReply() the bot has 15 minutes to call editReply(), which is far more than the 60-second timeout here.
  • The URL is wrapped in <...>. That stops Discord from adding its own link embed under the screenshot, which would show the page twice.
  • Ephemeral uses flags. Recent discord.js 14 releases deprecate ephemeral: true in favor of MessageFlags.Ephemeral.
  • Errors reach the user as codes, such as navigation_failed or timeout. The error codes reference explains each one.

Parameter mapping

Command option API field Why
url url Validated as http(s) before any credit is spent
full_page: true capture.mode: "full_page", capture.scroll.max_height: 6000 Caps very long pages so the image stays readable in chat
dark: true browser.dark_mode: true Only pages that support prefers-color-scheme change
(always) output.format: "jpeg", quality: 80 Much smaller than PNG for photo-heavy pages
(always) block.ads, block.cookie_banners Cleaner captures; see blocking
(always) cache.available: true Two members asking for the same page cost one credit

Errors and retries

The bot does not retry. In a chat, a person is waiting, and a second attempt after a 60-second timeout would leave them waiting two minutes. If you add a retry, make it one attempt, and only when the error body says "retryable": true (rate limits, 5xx and some render failures). For a 429, wait the Retry-After seconds first. The retry guide has a helper you can drop into capture(). Validation and authentication errors never succeed on a second try.

Limits and costs

  • One credit per fresh capture, 0 for a cache hit. See pricing.
  • On the Free plan a key may send 10 requests per minute. The per-user cooldown protects that budget on busy servers.
  • Discord rejects attachments above the server's upload limit. Long full-page PNGs can pass it, which is why the bot sends JPEG with a height cap.

Troubleshooting

Symptom Cause Fix
"The application did not respond" deferReply() was not reached within 3 seconds Keep validation fast and defer before any network call
The command does not appear It was registered globally, or for another server Run deploy-commands.mjs with the right guild ID
Only admins see the command setDefaultMemberPermissions Allow more roles in Server Settings > Integrations
Could not capture that page (missing_credentials) RS_API_KEY is not set for the bot process Set it in the host's environment, not only in your shell

Tested on 2026-10-15 with Node 22.18 and discord.js 14.27.0. We built the command JSON with SlashCommandBuilder and drove handleScreenshot with mock interactions against a local API stub: a normal capture, a dark full-page capture, an ephemeral reply, the cooldown, an invalid URL and a rejected key. The bot was not connected to a live Discord server in this test.

Frequently asked questions

Why does my Discord bot say "The application did not respond"?
Discord expects an answer to a slash command within 3 seconds. A page render usually takes longer, so call interaction.deferReply() first and send the image later with editReply().
Does a screenshot bot need the Message Content intent?
No. Slash commands deliver their options in the interaction, so the bot only needs the Guilds intent and no privileged intents.
How do I stop members from spending all my credits?
The command is limited to members with Manage Messages by default, each user has a 30-second cooldown, and repeated requests for the same page within 24 hours reuse the cached render for 0 credits.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.