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-overviewwithAuthorization: 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. &kioskhides the side menu and the top bar, but the time picker and Refresh button stay. The test IDdata-testid dashboard controlscovers them, sopage.hideremoves them./d-solo/<uid>/<slug>?panelId=1renders one panel alone. Add&theme=lightfor a light image.- Grafana added
timezone=browserto the URL, so the axis labels follow the browser clock. Setbrowser.timezoneto the team's zone.
The request gallery
| 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.