This Slack screenshot bot is a single Cloudflare Worker. A person types /screenshot https://example.com in any channel, the Worker answers within Slack's 3-second limit, and a few seconds later the capture appears in the channel as an image. For scheduled posts, such as a daily dashboard screenshot, use a cron job instead of a slash command. There is no server to run, no Slack file upload and no bot token to store. It is part of the screenshot API integrations.
What you can build
- "What does the customer see?" Support and sales teams post a page into a thread without anyone opening a browser and cropping a capture by hand.
- A deploy check. After a release, run
/screenshot https://yourapp.com/pricing darkin the release channel, and the team sees the live page in both color schemes. - Incident notes. Record how a status page or a partner site looked at a given minute, inside the incident channel where the timeline already lives.
How it works
sequenceDiagram
participant U as Slack user
participant S as Slack
participant W as Worker
participant R as RenderScreenshot API
U->>S: /screenshot https://example.com
S->>W: POST form (signed)
W-->>S: 200 "Capturing ..." (ephemeral, under 3 s)
W->>R: POST /v1/screenshot, Accept: application/json
R-->>W: { image.url, metadata.title }
W->>S: POST response_url, image block with image.url
S->>U: Capture appears in the channel
The key design choice is Accept: application/json. With that header the API returns JSON instead of image bytes, and image.url is a public CDN link that needs no key (response formats). Slack image blocks load an image from a public URL, so the Worker never touches the binary and never needs the files:write scope. The Discord bot takes the other route and uploads the bytes as an attachment, because Discord embeds work best with files.
Prerequisites
- A Slack workspace where you may install apps.
- A Cloudflare account and Node 20 or later for Wrangler.
- A RenderScreenshot API key that starts with
rs_live_. Get a free key.
Step 1: the Worker
Create a folder with package.json ({"type": "module"}), then save this as src/index.js:
// src/index.js: a Slack /screenshot slash command on Cloudflare Workers. // Secrets: wrangler secret put SLACK_SIGNING_SECRET, wrangler secret put RS_API_KEY const RS_API = 'https://api.renderscreenshot.com'; const RENDER_TIMEOUT_MS = 25_000; // waitUntil work must finish about 30 s after the reply export default { async fetch(request, env, ctx) { if (request.method !== 'POST') return new Response('Not found', { status: 404 }); const body = await request.text(); if (!(await isFromSlack(request, body, env.SLACK_SIGNING_SECRET))) { return new Response('Invalid signature', { status: 401 }); } const form = new URLSearchParams(body); const parsed = parseText(form.get('text') ?? ''); if (parsed.error) return reply(parsed.error); // Slack wants an answer within 3 seconds. Answer now, render in the background. ctx.waitUntil(captureAndPost(parsed, form.get('response_url'), form.get('user_id'), env)); return reply(`Capturing ${parsed.url} ...`); }, }; // "/screenshot https://example.com full dark" -> { url, full, dark } export function parseText(text) { const words = text.trim().split(/\s+/).filter(Boolean); // Slack may wrap links as <https://x.com> or <https://x.com|x.com>. const url = (words.shift() ?? '').replace(/^<([^|>]+)(\|[^>]*)?>$/, '$1'); if (!/^https?:\/\/\S+$/i.test(url)) { return { error: 'Usage: /screenshot https://example.com [full] [dark]' }; } return { url, full: words.includes('full'), dark: words.includes('dark') }; } async function captureAndPost({ url, full, dark }, responseUrl, userId, env) { try { const shot = await capture({ url, full, dark }, env.RS_API_KEY); const title = shot.metadata?.title || url; const posted = await postToSlack(responseUrl, { response_type: 'in_channel', text: `Screenshot of ${url}`, // fallback for notifications blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `<@${userId}> captured <${url}|${escapeMrkdwn(title)}>` } }, { type: 'image', image_url: shot.image.url, alt_text: `Screenshot of ${url}` }, ], }); // If Slack cannot fetch the image, send the link as text instead. if (!posted) await postToSlack(responseUrl, { response_type: 'in_channel', text: `Screenshot of ${url}: ${shot.image.url}` }); } catch (err) { console.error('screenshot failed', { url, code: err.code, message: err.message }); await postToSlack(responseUrl, { response_type: 'ephemeral', text: `Could not capture ${url} (${err.code ?? 'error'}).` }); } } export async function capture({ url, full, dark }, apiKey, apiBase = RS_API) { const res = await fetch(`${apiBase}/v1/screenshot`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', Accept: 'application/json', // JSON with a public CDN link, not the image bytes }, body: JSON.stringify({ url, viewport: { width: 1280, height: 800 }, capture: full ? { mode: 'full_page', scroll: { max_height: 6000 } } : { mode: 'viewport' }, output: { format: 'jpeg', quality: 80 }, block: { ads: true, cookie_banners: true }, ...(dark ? { browser: { dark_mode: true } } : {}), cache: { available: true, ttl: 2592000 }, // reuse for 30 days; the link lives as long }), signal: AbortSignal.timeout(RENDER_TIMEOUT_MS), }).catch((err) => { throw Object.assign(new Error(err.message), { code: err.name === 'TimeoutError' ? 'timeout' : 'network_error' }); }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw Object.assign(new Error(data.error?.message ?? `HTTP ${res.status}`), { code: data.error?.code ?? `http_${res.status}` }); } return data; } async function postToSlack(responseUrl, message) { const res = await fetch(responseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(message), }); return res.ok; } function reply(text) { return Response.json({ response_type: 'ephemeral', text }); } function escapeMrkdwn(s) { return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } // Slack request signing: v0=HMAC-SHA256(secret, "v0:{timestamp}:{raw body}") export async function isFromSlack(request, body, secret) { const ts = request.headers.get('X-Slack-Request-Timestamp'); const sig = request.headers.get('X-Slack-Signature') ?? ''; if (!ts || !secret || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const mac = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`v0:${ts}:${body}`)); const expected = 'v0=' + [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, '0')).join(''); if (expected.length !== sig.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ sig.charCodeAt(i); return diff === 0; }
Notes from building it:
- Verify before parsing. The signature covers the raw body, so the Worker reads it once as text, checks the HMAC, and only then parses the form. Anyone who finds the Worker URL can POST to it. Without this check, every such request would spend one of your credits.
- The 30-second budget. Work in
ctx.waitUntil()may run for about 30 seconds after the Worker has answered. The render timeout is 25 seconds, so a slow page ends with a clear "timeout" message instead of silence. - Two posts at most. A
response_urlaccepts a few posts within 30 minutes, which is plenty for one image message and one fallback.
Step 2: deploy it
Save wrangler.jsonc next to package.json:
{ "name": "slack-screenshot", "main": "src/index.js", "compatibility_date": "2026-10-01" }
npm i -D wrangler@4 npx wrangler secret put RS_API_KEY # paste your rs_live_ key npx wrangler deploy # prints https://slack-screenshot.<you>.workers.dev
Add the Slack signing secret after Step 3, because Slack creates it with the app. The Cloudflare Workers integration covers other ways to call the API from a Worker.
Step 3: create the Slack app
At api.slack.com/apps choose Create New App, then From an app manifest, pick your workspace and paste this (replace the URL with yours):
display_information: name: Screenshot features: bot_user: display_name: Screenshot slash_commands: - command: /screenshot url: https://slack-screenshot.YOUR-SUBDOMAIN.workers.dev/ description: Capture a web page and post it here usage_hint: https://example.com [full] [dark] should_escape: false oauth_config: scopes: bot: - commands settings: org_deploy_enabled: false socket_mode_enabled: false
Install the app to the workspace. Then copy Signing Secret from Basic Information and run npx wrangler secret put SLACK_SIGNING_SECRET. The command works in public and private channels and in DMs, because replies go through response_url and the app does not need to join the channel.
Parameter mapping
| What the user types | API field | Why |
|---|---|---|
https://... (first word) |
url |
Checked for http(s) before a credit is spent |
full |
capture.mode: "full_page", capture.scroll.max_height: 6000 |
A very long page would be unreadable in a message |
dark |
browser.dark_mode: true |
Only pages that support prefers-color-scheme change |
| (always) | output.format: "jpeg", quality: 80 |
Small files load fast in Slack clients |
| (always) | block.ads, block.cookie_banners |
Consent overlays hide the content people asked to see |
| (always) | cache.available: true, cache.ttl: 2592000 |
The same page asked twice in 30 days costs one credit, and the link lives 30 days (caching) |
Errors and retries
The Worker does not retry. A person is waiting in the channel, and a second render would also break the 30-second budget. Failures reach only the person who ran the command, as an ephemeral message with the API error code, for example navigation_failed or timeout. The error codes reference explains each one. If you need retries, use the retryable flag in the error body and the Retry-After header on a 429, as the retry guide shows, and move the work to a Cloudflare Queue so it can outlive the 30 seconds.
Limits and costs
- One credit per fresh render, none for a cache hit. See pricing.
- The rate limit is per account and shared by all your keys. On the Free plan it is 10 requests a minute, so a busy workspace on the same account as your production app competes with it. Use a separate account, or restrict who sees the command.
- Each command is one Worker request with a few seconds of wall time and almost no CPU time, so the Workers Free plan covers a normal workspace. Check Cloudflare's current Workers limits page for your plan.
- The image lives for the cache TTL. After 30 days the link stops working, and Slack may show a broken image in old messages.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
/screenshot failed with the error "dispatch_failed" |
The Worker URL in the manifest is wrong, or the Worker threw | Run npx wrangler tail and send the command again |
Every command gets Invalid signature (401) |
SLACK_SIGNING_SECRET is not set, or it is the client secret |
Copy Signing Secret, not Client Secret, then redeploy |
| "Capturing ..." appears, then nothing | RS_API_KEY is missing, or the render failed silently in waitUntil |
wrangler tail shows the logged error code |
Could not capture ... (missing_credentials) |
The API key secret is empty or mistyped | npx wrangler secret put RS_API_KEY again |
Usage: /screenshot ... for a valid link |
The user left out https:// |
Type the full address; Slack links with labels also work |
Tested on 2026-10-18 with Node 22.18 and Wrangler 4.151.1. We ran the Worker's fetch handler with a mocked fetch and waitUntil: a normal capture, a full-page dark capture, a wrong signature, a timestamp ten minutes old, a non-URL argument, an API error, and a failed Slack post that fell back to a text link. wrangler deploy --dry-run bundled the Worker. The Worker was not connected to a live Slack workspace or to the live API in this test.