Slack Screenshot Bot on Cloudflare Workers

A Slack /screenshot slash command that posts a website capture into the channel. One Cloudflare Worker file, signed requests, no file uploads.

By Simon Chiu · Updated

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 dark in 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// 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_url accepts 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.

Frequently asked questions

Why does Slack show "dispatch_failed" or "operation_timeout" for my slash command?
Slack waits 3 seconds for the HTTP answer to a slash command. A page render takes longer, so the Worker answers at once and posts the image later to the response_url that Slack sends with the command.
Does a Slack screenshot bot need the files:write scope?
Not with this design. The Worker asks the API for JSON, gets a public CDN link to the image, and posts an image block that points to it. The app only needs the commands scope.
How long does the screenshot stay visible in Slack?
The image block points to the RenderScreenshot CDN link, which lives for the cache TTL. The Worker sets 30 days, the maximum. For a permanent record, copy renders to your own bucket with custom storage.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.