Dashboard Screenshot to Slack, on a Schedule

Post a Grafana or Metabase dashboard screenshot to Slack on a schedule, with a read-only token, a wait for loaded panels and Slack's current upload API.

By Simon Chiu · Updated

Your team looks at the same Grafana dashboard every morning, or it should. A picture of that dashboard in the team's Slack channel at 9:00 gets more eyes than a bookmark. Most dashboards need a login, so a generic screenshot tool gets the sign-in form. Give the renderer a read-only token for the dashboard, wait until the panels have drawn, fail if the login page shows up, and upload the PNG to Slack with the current file upload API. This page has the requests, a script that we ran end to end, and the Grafana details we checked against a real Grafana 12.2 server.

It is one of the use cases where the page is yours and the problem is access, not bot detection.

What we checked in Grafana

We ran grafana/grafana:12.2.0 in Docker with two TestData panels. We created a service account with the Viewer role and loaded the dashboard in headless Chrome. The token went on requests to the Grafana origin only, which matches how network.auth behaves in our renderer. These are the results that shape the request below:

  • A service account token works on page loads, not only on /api. GET /d/ops-overview/ops-overview with Authorization: Bearer glsa_... returned 200. Without the header, Grafana answered 302 to /login. All 59 requests the dashboard made went to the same origin, so a same-origin token covers the whole page.
  • A bad token lands on the login page. That page's visible text starts with "Welcome to Grafana". A content check on that phrase turns a wrong or revoked token into an error, not a picture of a form.
  • A drawn panel body has data-testid="data-testid panel content". Yes, the prefix appears twice in the attribute value. Wait for that selector, not for a fixed delay.
  • &kiosk hides the side menu and the top bar, but the time picker and Refresh button stay. The test ID data-testid dashboard controls covers them, so page.hide removes them.
  • /d-solo/<uid>/<slug>?panelId=1 renders one panel alone. Add &theme=light for a light image.
  • Grafana added timezone=browser to the URL, so the axis labels follow the browser clock. Set browser.timezone to the team's zone.
Snapshot URL pattern Key options
Whole dashboard /d/<uid>/<slug>?orgId=1&kiosk&from=now-24h&to=now viewport 1600x900 at scale: 2, page.hide on the controls
One panel for a status thread /d-solo/<uid>/<slug>?orgId=1&panelId=1&from=now-24h&to=now viewport 1000x500, no hide needed
Light theme for an email digest either URL plus &theme=light same as above
Weekly view from=now-7d&to=now add wait.delay: 1000 if a heavy query draws late

Every row uses the same guards: network.auth with type: "bearer", wait.for_selector on the panel content, and options.fail_if_contains with the login text. Content checks and network options are POST only.

How it works

flowchart LR
  C[cron or CI schedule] --> J[dashboard-to-slack.mjs]
  J -->|POST /v1/screenshot<br/>network.auth bearer| R[RenderScreenshot]
  R -->|Authorization only to the Grafana origin| G[Grafana]
  R -->|PNG, or 422 content_check_failed| J
  J -->|files.getUploadURLExternal| S[Slack API]
  J -->|POST bytes to upload_url| S
  J -->|files.completeUploadExternal<br/>channel_id| S

RenderScreenshot has no scheduler of its own, so the clock is yours: crontab, a scheduled GitHub Actions workflow or a Cloudflare Workers cron trigger. The script runs, makes one capture, and does three Slack calls.

The script

// dashboard-to-slack.mjs: capture a Grafana dashboard with a service account token and post it to Slack.
// Node 22+, no dependencies. Run from cron or a CI schedule:
//   RS_API_KEY=... GRAFANA_TOKEN=glsa_... SLACK_BOT_TOKEN=xoxb-... SLACK_CHANNEL=C0123456789 node dashboard-to-slack.mjs
const API = process.env.RS_BASE_URL ?? 'https://api.renderscreenshot.com';
const SLACK = process.env.SLACK_API ?? 'https://slack.com/api';
const DASHBOARD =
  process.env.DASHBOARD_URL ??
  'https://grafana.example.com/d/ops-overview/ops-overview?orgId=1&kiosk&from=now-24h&to=now';

async function captureDashboard() {
  const res = await fetch(`${API}/v1/screenshot`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.RS_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url: DASHBOARD,
      viewport: { width: 1600, height: 900, scale: 2 },
      network: { auth: { type: 'bearer', token: process.env.GRAFANA_TOKEN } },
      wait: { until: 'networkidle', for_selector: '[data-testid="data-testid panel content"]', timeout: 45 },
      page: { hide: ['[data-testid="data-testid dashboard controls"]'] },
      browser: { timezone: process.env.TEAM_TIMEZONE ?? 'America/New_York' },
      options: { fail_if_contains: ['Welcome to Grafana', 'Dashboard not found'] },
      output: { format: 'png' },
      cache: { ttl: 3600 }, // the shortest TTL: the CDN copy of a private dashboard lives one hour
    }),
    signal: AbortSignal.timeout(90_000),
  });
  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(`capture failed: HTTP ${res.status} ${err.error?.code ?? ''} ${err.error?.message ?? ''}`);
  }
  return Buffer.from(await res.arrayBuffer());
}

async function slack(method, params) {
  const res = await fetch(`${SLACK}/${method}`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.SLACK_BOT_TOKEN}` },
    body: new URLSearchParams(params), // form-encoded works for both upload methods
  });
  const data = await res.json();
  if (!data.ok) throw new Error(`Slack ${method}: ${data.error}`);
  return data;
}

async function postToSlack(png, title) {
  const filename = `dashboard-${new Date().toISOString().slice(0, 10)}.png`;
  // 1. Ask Slack for an upload URL. 2. Send the bytes there. 3. Share the file in the channel.
  const { upload_url, file_id } = await slack('files.getUploadURLExternal', { filename, length: String(png.length) });
  const up = await fetch(upload_url, { method: 'POST', body: png });
  if (!up.ok) throw new Error(`upload failed: HTTP ${up.status}`);
  await slack('files.completeUploadExternal', {
    files: JSON.stringify([{ id: file_id, title }]),
    channel_id: process.env.SLACK_CHANNEL,
    initial_comment: `${title}: <${DASHBOARD}|open in Grafana>`,
  });
}

try {
  const png = await captureDashboard();
  await postToSlack(png, 'Ops overview, last 24 hours');
  console.log(`posted ${png.length} bytes to ${process.env.SLACK_CHANNEL}`);
} catch (err) {
  console.error(err.message);
  process.exitCode = 1;
}

Tested on 2026-10-18 with Node.js 22.18.0 against a local stub of the RenderScreenshot API and of the three Slack methods. The run posted the file and exited 0. It exited 1 with invalid_auth for a bad Slack token, missing_credentials for a bad API key, and content_check_failed when the stub page carried the Grafana login text. The Grafana facts above came from Grafana 12.2.0 and Puppeteer 24.43.1 with the token added only on same-origin requests. We did not call the production API for this page.

The upload uses the three-step flow because Slack retired the single files.upload method. The bot token needs the files:write scope, and the bot must be in the channel. SLACK_CHANNEL is the channel ID (it starts with C), not the name.

Settings that matter

Use network.auth, not a custom header. Both reach only the target origin. But the request logs store custom network.headers as sent, while network.auth tokens become [FILTERED]. The network options page has the full rules.

Give the service account the Viewer role and access to the one folder you capture. If the token leaks, it can read dashboards and nothing else. Rotate it like any other secret.

Do not post the X-Cache-URL. Slack's image blocks and incoming webhooks need a public image URL, and the CDN link is one. Anyone with that link can open your metrics until X-Cache-Expires. Upload the bytes, and keep cache.ttl at 3600.

Leave cache.available off. The URL says now-24h every day, so the request hash stays the same. With cache reuse on, a second run inside the TTL would post the earlier image.

Identity proxies. If Grafana sits behind Cloudflare Access or a similar gate, the proxy needs its own credential. Cloudflare Access service tokens use the CF-Access-Client-Id and CF-Access-Client-Secret headers, which can go in network.headers. Remember that custom headers are stored in the request logs as sent.

The authenticated pages guide covers cookies and basic auth, if a tool needs those.

Metabase and other tools

The pattern is the same for any dashboard tool. Change the URL, the credential, the wait selector and the login phrase. Metabase can share a dashboard as a public link, or as a signed embed when you turn on static embedding. Both open without a session, so the capture needs no credential at all. We have not run the Metabase variant ourselves, so find the selector for a loaded card in your version with the browser's element inspector before you schedule it. Then add a fail_if_contains entry for that tool's loading or error text.

What it costs

Each snapshot is one render and one credit. A failed render, such as a content check that catches the login page, costs nothing.

Schedule Renders per month Plan that covers it
5 dashboards, every weekday at 9:00 about 110 Free, 150 credits a month
10 dashboards, three times each weekday about 660 Starter, 2,500 credits for $10
5 dashboards, every hour about 3,720 Growth, 12,000 credits for $40

At the hourly rate, each snapshot costs about $0.0108. The pricing page has the current numbers.

Next steps

To watch public pages for changes instead of posting dashboards, read website monitoring with screenshots. To run this script, create a free API key, add a Viewer service account in Grafana, and schedule the job.

Frequently asked questions

Can the renderer reach a Grafana that runs on our private network?
No. Every request the page makes is checked against an SSRF policy that blocks private, loopback and link-local addresses. The dashboard needs a public hostname. Protect it with a token or an identity proxy, not with network isolation.
Why does my old Slack upload code stop working?
Slack retired the single files.upload method. Use files.getUploadURLExternal, send the bytes to the URL it returns, then call files.completeUploadExternal with the channel ID. The bot needs the files:write scope and must be a member of the channel.
Who can see the dashboard image after the capture?
Slack members of the channel, and anyone who holds the X-Cache-URL link until it expires. The script sets cache.ttl to 3600 seconds, the minimum, so that CDN copy lives one hour. Do not post the CDN link; upload the file.
Does Grafana's own image renderer plugin do the same thing?
The plugin renders panels with a headless browser that you run next to Grafana. Use it if you can operate that browser. Use an API when you cannot install plugins, or when one script captures several tools.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.