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
/screenshotcommand 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
botandapplications.commandsscopes. 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 calleditReply(), 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 deprecateephemeral: truein favor ofMessageFlags.Ephemeral. - Errors reach the user as codes, such as
navigation_failedortimeout. 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.