How to Send Scheduled Website Screenshots to Slack
Schedule website captures and deliver them to Slack with a no-code workflow or a Playwright script that uploads the screenshot as a file.
To send scheduled website screenshots to Slack, connect three steps: a schedule trigger, a page capture, and Slack delivery. For a no-code setup, Zapier documents a daily Schedule by Zapier → HTML to Image website screenshot → Slack channel message workflow. For a developer-controlled setup, schedule a Playwright script, capture the page, and upload the image through Slack’s external file-upload API.
This guide covers both approaches, including runnable Python and JavaScript examples, Slack file upload, webhook alternatives, scheduling, troubleshooting, and an API option when you do not want to manage a browser. The browser and Slack flow below follows the documented [Playwright screenshot API](https://playwright.dev/docs/screenshots), [Slack upload methods](https://api.slack.com/methods/files.getUploadURLExternal), [Slack completion method](https://api.slack.com/methods/files.completeUploadExternal), and [Zapier template](https://zapier.com/apps/slack/integrations/html-to-image/). Recheck current service requirements and plan limits before deployment.
1. Choose how to schedule and capture
| Approach | What it does | Best fit |
|---|---|---|
| Zapier | Connects a schedule trigger, HTML to Image capture, and Slack channel message in a workflow. | You want to configure an existing no-code integration. |
| Playwright + Slack file upload | Your scheduled script controls browser capture and uploads the resulting image into a Slack channel. | You need custom browser behavior or direct control over the file. |
| Playwright + incoming webhook | Posts a message to Slack, optionally with an image URL. | A message linking to an already accessible image is sufficient. |
Scheduling, capture, and delivery are separate jobs. A schedule starts the run; a browser or screenshot service produces the image; Slack receives a message or file. Decide first whether Slack must contain the image as an uploaded file or whether a message with an image link will do. Use Slack’s file-upload flow for a native shared file.
No-code route with Zapier
- Start with Zapier’s documented daily workflow for Schedule by Zapier, HTML to Image, and a Slack channel message.
- Choose the desired schedule. Zapier’s integration page lists hourly, weekly, and custom-frequency schedule triggers as well.
- Set the page URL in the HTML to Image action and configure available capture settings there.
- Map the screenshot action’s output into the Slack step. Confirm whether it provides a file, an image URL, or another output, and that the Slack action accepts that output as intended.
- Choose the destination channel, test the workflow, and confirm the channel receives the expected screenshot or link.
- Publish the workflow and check connected-app requirements and current plan limits.
The documented Zapier template is specifically a daily website screenshot sent to a Slack channel. The available capture options and the exact way the image is mapped into a Slack message depend on the connected actions and current service behavior.
2. Build a scheduled screenshot job with Playwright
The following Python example is a complete script for a Linux host with Python, Playwright, and Chromium installed. It captures a full-page PNG, asks Slack for an upload URL, sends the image bytes, and finalizes the upload into a channel. Create a Slack app with the files:write scope, install it in the workspace, and make the bot a member of the destination channel. Set SLACK_BOT_TOKEN and SLACK_CHANNEL_ID as environment variables.
import os
import requests
from playwright.sync_api import sync_playwright
PAGE_URL = os.environ.get("PAGE_URL", "https://example.com")
TOKEN = os.environ["SLACK_BOT_TOKEN"]
CHANNEL_ID = os.environ["SLACK_CHANNEL_ID"]
OUTPUT = "website.png"
# Capture a full-page PNG. Use a stable viewport and wait condition for
# repeatable captures; adjust the wait for the site being captured.
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 900}, device_scale_factor=1)
page.goto(PAGE_URL, wait_until="networkidle", timeout=60000)
page.screenshot(path=OUTPUT, full_page=True)
browser.close()
size = os.path.getsize(OUTPUT)
headers = {"Authorization": f"Bearer {TOKEN}"}
# 1. Ask Slack for a temporary upload URL and file ID.
r = requests.post(
"https://slack.com/api/files.getUploadURLExternal",
headers=headers,
data={"filename": os.path.basename(OUTPUT), "length": size},
timeout=30,
)
r.raise_for_status()
result = r.json()
if not result.get("ok"):
raise RuntimeError(f"Slack upload URL request failed: {result}")
upload_url = result["upload_url"]
file_id = result["file_id"]
# 2. Send the binary image to the returned URL.
with open(OUTPUT, "rb") as image_file:
upload = requests.post(
upload_url,
data=image_file,
headers={"Content-Type": "image/png"},
timeout=120,
)
upload.raise_for_status()
# 3. Finalize and share the upload in the chosen channel.
r = requests.post(
"https://slack.com/api/files.completeUploadExternal",
headers=headers,
data={
"files": [{"id": file_id, "title": f"Screenshot of {PAGE_URL}"}],
"channel_id": CHANNEL_ID,
"initial_comment": f"Scheduled screenshot: {PAGE_URL}",
},
timeout=60,
)
r.raise_for_status()
result = r.json()
if not result.get("ok"):
raise RuntimeError(f"Slack upload finalization failed: {result}")
print(f"Shared {OUTPUT} to Slack channel {CHANNEL_ID}")
Install the Python dependencies and browser binary, then run the script once manually before adding a schedule:
python -m pip install playwright requests
python -m playwright install chromium
export PAGE_URL="https://example.com"
export SLACK_BOT_TOKEN="xoxb-your-token"
export SLACK_CHANNEL_ID="C0123456789"
python scheduled_screenshot.py
For production, keep secrets in the scheduler’s secret store or protected environment, not in source control. The script raises an error for Slack API failures so a scheduler or wrapper can detect a failed run. Add structured logging and alerting appropriate to your environment.
Runnable Node.js version
This version uses Playwright and Node’s built-in fetch. Use Node.js 18 or newer for global fetch. Install Playwright and its Chromium browser, then set the same three environment variables.
import { chromium } from 'playwright';
import { readFile, stat } from 'node:fs/promises';
import { basename } from 'node:path';
const pageUrl = process.env.PAGE_URL ?? 'https://example.com';
const token = process.env.SLACK_BOT_TOKEN;
const channelId = process.env.SLACK_CHANNEL_ID;
if (!token || !channelId) throw new Error('Set SLACK_BOT_TOKEN and SLACK_CHANNEL_ID');
const filename = 'website.png';
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
await page.goto(pageUrl, { waitUntil: 'networkidle', timeout: 60000 });
await page.screenshot({ path: filename, fullPage: true });
} finally {
await browser.close();
}
const fileBytes = await readFile(filename);
const headers = { Authorization: `Bearer ${token}` };
// 1. Get the temporary upload URL and file ID.
let response = await fetch('https://slack.com/api/files.getUploadURLExternal', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ filename: basename(filename), length: String((await stat(filename)).size) }),
});
if (!response.ok) throw new Error(`Slack upload URL HTTP ${response.status}`);
let result = await response.json();
if (!result.ok) throw new Error(`Slack upload URL error: ${JSON.stringify(result)}`);
const { upload_url: uploadUrl, file_id: fileId } = result;
// 2. Upload the image bytes to the temporary URL.
response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': 'image/png' },
body: fileBytes,
});
if (!response.ok) throw new Error(`Image upload HTTP ${response.status}`);
// 3. Complete the upload and share it to the destination channel.
response = await fetch('https://slack.com/api/files.completeUploadExternal', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
files: JSON.stringify([{ id: fileId, title: `Screenshot of ${pageUrl}` }]),
channel_id: channelId,
initial_comment: `Scheduled screenshot: ${pageUrl}`,
}),
});
if (!response.ok) throw new Error(`Slack completion HTTP ${response.status}`);
result = await response.json();
if (!result.ok) throw new Error(`Slack completion error: ${JSON.stringify(result)}`);
console.log(`Shared ${filename} to Slack channel ${channelId}`);
npm install playwright
npx playwright install chromium
PAGE_URL="https://example.com" SLACK_BOT_TOKEN="xoxb-your-token" SLACK_CHANNEL_ID="C0123456789" node scheduled_screenshot.mjs
Choose the Playwright capture mode
Playwright screenshots can be saved to a file, returned as bytes, captured for the full scrollable page, or limited to a specific element. Use the mode that matches the Slack message’s purpose.
| Need | Python example | Notes |
|---|---|---|
| Visible viewport | page.screenshot(path="view.png") |
Captures the current viewport only. |
| Entire page | page.screenshot(path="full.png", full_page=True) |
Captures the full scrollable page; very long pages can produce large files. |
| One element | page.locator("main article").screenshot(path="article.png") |
Useful for a chart, report, or page region; the selector must match an attached element. |
| Image bytes | image_bytes = page.screenshot(full_page=True) |
Pass bytes to an upload request or store them without writing a local file. |
Other useful controls include type="jpeg" or type="png", quality=80 for JPEG, scale="css" to keep output dimensions closer to CSS pixels, and animations="disabled" to reduce moving content. Full-page capture does not guarantee that every lazy-loaded image has been requested; scroll the page before capture if the site only loads content as it enters the viewport.
Schedule the script
For a Linux server using cron, create a wrapper that loads secrets securely and invokes the script. This example runs every weekday at 08:30 in the server’s configured timezone:
30 8 * * 1-5 /usr/bin/python3 /opt/screenshot-job/scheduled_screenshot.py >> /var/log/screenshot-job.log 2>&1
Set the host timezone deliberately or use a scheduler that lets you specify the timezone. For daylight-saving changes, confirm whether the chosen scheduler follows local wall-clock time or UTC. Cloud schedulers can also trigger a container or function, but the capture runtime still needs a compatible browser and its required system libraries.
3. Use a Slack incoming webhook when a link is enough
An incoming webhook posts JSON to the channel associated with its URL. It can send text and Block Kit content, including a reference to an image URL. It does not upload local screenshot bytes as a Slack file. If the screenshot is stored somewhere Slack members can access, a webhook message can link to it or display it through an image block.
Create a Slack app, enable incoming webhooks, authorize the target channel, and store the generated webhook URL as a secret. Example with cURL:
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"Scheduled website screenshot: https://example.com/screenshot.png"}' \
"$SLACK_WEBHOOK_URL"
Slack documents webhook URLs as secrets and says leaked URLs may be revoked. A webhook is tied to its configured channel, and webhook posts cannot be deleted through the webhook. Slack directs developers who need more complex chat flows, including deletion, to chat.postMessage. For a private channel, the user installing the webhook must first be a member.
4. Or skip the browser setup
[ScreenshotNeo](https://screenshotneo.com) is a website screenshot API and MCP server. Its API can return a screenshot directly, so a scheduled job can request the image and pass the bytes to the same Slack file-upload sequence above. See the [ScreenshotNeo API docs](https://screenshotneo.com/docs/) for parameters and response details.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo HTTP ${res.status}`);
const image = Buffer.from(await res.arrayBuffer());
For the API response, check the status and response headers before forwarding bytes to Slack; use the X-Page-Verdict and X-Billed headers to determine the page outcome and billing status. The API accepts cookie banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before capture; each cleanup step can be turned off. Bot checks, CAPTCHAs, blank pages, timeouts, failed loads, and cache hits cost nothing. An MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients. The Free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots. Every feature is on every plan.
Sign up for ScreenshotNeo’s free plan to get 1,000 screenshots a month with no card.
5. Reliability, performance, and cost
- Wait for the right page state.
networkidlecan be a poor fit for pages with persistent network activity. If navigation times out, choose a meaningful selector or a short fixed delay appropriate to the page, then capture only when the required content is visible. - Control image size. Full-page screenshots of long pages can be large. Capture a viewport or element when that meets the need, or reduce output size and quality where the chosen format permits.
- Keep output repeatable. Fix the viewport, device scale, locale, timezone, and browser version when comparing captures. Fonts, animation, time-sensitive content, and environment differences can change pixels. Playwright’s visual comparison guidance notes that rendering can vary by environment.
- Handle failures at each step. Treat browser launch, navigation, image upload, and Slack finalization as distinct failure points. Record which step failed, retain enough context to debug, and alert an operator after the retry policy is exhausted.
- Use bounded retries. Retry transient network or rate-limit failures with backoff; do not blindly repeat a successful upload finalization, which may create duplicate channel posts. Keep the generated file ID associated with its upload attempt.
- Estimate cost from your actual stack. The researched sources provide no comparable price or benchmark for Zapier, hosting, browser runtime, or Slack usage. Check each service’s current plan and usage terms. A self-hosted Playwright job still consumes compute and requires maintenance.
Slack’s external upload flow has three required stages: request the upload URL and file ID, POST the file bytes to that URL, then call files.completeUploadExternal to finalize and share. The API requires the files:write scope. Include a channel_id to share the file into that channel; without one, Slack says the uploaded file remains private. Slack says an upload that is not finalized is discarded.
6. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Slack says missing_scope or denies the API call |
The app token lacks files:write, or the app’s scopes changed after installation. |
Add the documented scope, reinstall or reauthorize the app if needed, then use the updated token. |
| Upload completes but the channel does not show a file | The upload was not finalized, or no destination channel was supplied. | Call files.completeUploadExternal with the matching file ID and channel_id; verify the response has ok: true. |
| File remains private | Completion omitted the destination channel. | Supply the channel ID in the completion call and ensure the app can access the channel. |
| Slack rejects the uploaded bytes | The declared length or filename is wrong, or the bytes were not sent to the temporary upload URL correctly. | Use the exact file byte length, a filename, and the returned upload URL; check the byte-upload HTTP status. |
| Playwright navigation times out | The page is slow, keeps connections open, or the selected wait condition never occurs. | Use a suitable load condition, wait for a page-specific selector, or set a deliberate timeout; inspect whether the page loaded before retrying. |
| Screenshot is blank or missing content | Capture ran before client-rendered content appeared, the selector matched nothing, or lazy content was never loaded. | Wait for the content selector, verify the locator, and scroll through lazy-loaded sections before full-page capture. |
| Webhook returns an error or stops working | The URL may be invalid, revoked, or attached to a different channel. | Check the response and app configuration. If the URL was exposed, replace it and update the secret store. |
| Webhook cannot remove a posted message | Incoming webhooks do not provide deletion. | Use the Slack Web API method intended for the required message lifecycle. |
| Scheduled job works manually but not under cron | Cron uses a different environment, working directory, PATH, timezone, or secret configuration. | Use absolute paths, set the required environment explicitly, log stdout and stderr, and verify the host timezone. |
7. Frequently asked questions
Can Slack post a screenshot on a schedule by itself?
The schedule must come from an automation service or scheduled job. Slack receives the result; the documented Zapier workflow combines a schedule trigger, screenshot action, and Slack step.
Does an incoming webhook upload a screenshot file?
No. It posts a JSON message. To put the image in Slack as a shared file, use the external upload and completion methods.
Can I send a full-page screenshot?
Yes. Playwright supports full-page capture. Very long pages may create large files, and lazy-loaded content may need to be brought into view before capture.
Is ScreenshotsCloud a recurring scheduler?
The Slack Marketplace listing documents a /screenshot URL command and channel delivery. It does not describe recurring scheduling, so treat it as command-driven capture unless its current documentation says otherwise.


