Quickstart
Every capture is a single HTTP GET that returns raw image bytes. There is no SDK and nothing to install.
curl -G "https://screenshot-api.net/v1/screenshot" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
--data-urlencode "url=https://example.com" \
-o shot.png
Authentication
Pass your key as a bearer token:
Authorization: Bearer sk_live_...A ?key= query parameter is also accepted so a capture can be used directly as an <img src>.
The query form exposes your key to anyone who can read the page source or your server logs. Use it only for throwaway keys, never a production one.
GET /v1/screenshot
Renders a page and returns the image. The response body is the image itself, with Content-Type set to match the requested format — not a JSON wrapper containing a URL.
Parameters
Only url is required. Values outside the accepted range are clamped to the nearest valid value rather than rejected, so a request never fails over a parameter we can safely bound.
| Name | Type | Default | Notes |
|---|---|---|---|
url | string | required | http or https only, max 2048 characters. |
width | integer | 1280 | Viewport width in CSS pixels. Max 3840. |
height | integer | 800 | Viewport height in CSS pixels. Max 4320. |
full_page | boolean | false | Capture the full scrollable page. Height is capped at 4320px. |
format | string | png | png, jpeg or webp. |
quality | integer | 85 | 1–100. Ignored for png, which is lossless. |
scale | number | 1 | Device pixel ratio, 0.1–3. Use 2 for retina. |
dark | boolean | false | Sets prefers-color-scheme: dark for the page. |
delay | integer | 0 | Milliseconds to wait after load, 0–10000. For late animations. |
cookies | string | — | name=value; name2=value2, set on the target host before the page loads. See pages behind a login. |
header | string, repeatable | — | Name: value. Sent only on requests to the target host. In the POST form, headers may be an object instead. |
basic_auth | string | — | user:password, offered once when the target origin asks for HTTP basic auth. |
timeout | integer | 25 | Seconds to allow for the whole render. |
Booleans accept 1, true, yes and on.
Response headers
| Header | Meaning |
|---|---|
X-Quota-Limit | Renders included in your plan this month. |
X-Quota-Remaining | Renders left in the current period. |
X-Render-Time-Ms | Server-side render time in milliseconds. |
X-Page-Status | HTTP status of the final document, after redirects. A 401 or 403 means the image is a login or error page, not the content. |
GET /v1/account
Returns your plan and current usage. Useful for a dashboard or an alert before you run out.
{
"email": "you@company.com",
"plan": { "id": "pro", "name": "Pro", "quota": 10000 },
"usage": { "period": "2026-09", "used": 412, "remaining": 9588 }
}GET /v1/capture
The same render, returned as JSON with the page’s own text beside the image.
Takes every parameter /v1/screenshot takes, plus selector
and text.
This exists for feeding screenshots to a model. An image is the right medium for
layout and state and the wrong one for exact characters: it gets resized before it
is read, and that is where rn becomes m and
0 becomes O. Sending both removes the guesswork.
curl -G "https://screenshot-api.net/v1/capture" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
--data-urlencode "url=https://example.com" \
-d "selector=.pricing-table"{
"image": "iVBORw0KGgo...", // base64, matches mime_type
"mime_type": "image/png",
"width": 1568, "height": 1175,
"title": "Example Domain",
"url": "https://example.com/", // after redirects
"text": "Example Domain\n\nThis domain is for use in...",
"status": 200, // HTTP status of the final document
"render_ms": 345,
"quota": { "limit": 10000, "remaining": 9588 }
}| Parameter | Default | Notes |
|---|---|---|
selector | — | CSS selector to crop to. The element is scrolled into view first, so it works off-screen. Overrides full_page. Returns 400 no_element if nothing matches. |
text | true | Set false to omit the page text. Text is innerText, so it is what a reader sees rather than what the markup contains, capped at 40,000 characters. |
Sizing it for a model. Vision models resize an image so its long
edge is around 1568px before reading it, so capturing wider is not more detail —
it is detail that gets discarded. Aim at that number and spend the rest on
scale: width=1045&scale=1.5 lands on it with text
rendered half again as large. A selector beats both.
POST /v1/compare
Render a page and say what changed against another render: a second URL captured now, or a baseline stored earlier under a name. You get the percentage of pixels that changed, the changed regions as boxes, and a diff image with changes tinted and the rest faded. Every capture parameter applies to both sides, so the two images line up. Each render costs one quota unit; the comparison is free.
curl -X POST "https://screenshot-api.net/v1/compare" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://staging.example.com/pricing",
"against": "https://example.com/pricing",
"width": 1280, "full_page": true }'# first call stores the render under the name and says so; every later call diffs against it
curl -X POST "https://screenshot-api.net/v1/compare" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/pricing", "baseline": "pricing" }'{
"changed": true,
"percent": 2.34, // of pixels, on the union canvas
"regions": [ { "x": 96, "y": 48, "w": 48, "h": 32 } ], // largest first, image px
"width": 1280, "height": 2140, "size_changed": false,
"before": { "url": "...", "status": 200, "captured_at": "2026-09-01T10:00:00Z" },
"after": { "url": "...", "status": 200 },
"baseline": { "name": "pricing", "created": false, "updated": false },
"diff_image": "iVBORw0KGgo...", // PNG, base64
"render_ms": 812
}| Parameter | Default | Notes |
|---|---|---|
against | — | A second URL, rendered now with the same parameters. Give this or baseline, not both. |
baseline | — | A name to store under and compare against. Letters, digits, dot, dash, underscore, slash. Up to 100 per account. |
update_baseline | false | After comparing, store this render as the new baseline. For when the change was expected. |
threshold | 0.1 | Percent of pixels that must differ for changed to be true. Raise it to ignore a clock or a carousel. |
tolerance | 24 | Per-channel colour distance, 0–255, below which a pixel counts as unchanged. Anti-aliasing sits under it; a colour change over it. |
notify | — | slack posts the result to your connected channel when something changed or a baseline was created; slack_always posts every time. See Slack delivery. |
| everything else | All capture parameters and credentials apply to both sides. Format is always PNG. |
GET /v1/baselines lists an account’s baselines and
DELETE /v1/baselines/{name} removes one. Baseline pixels are stored
until replaced or deleted and go with the account when it is deleted.
Check the status first. A login page and a dashboard both render, and a diff between them is large and meaningless. Both sides carry their HTTP status; treat anything at 400 or above as “not the page” before reading the numbers.
Pages behind a login
A staging site behind basic auth, a preview deploy with a bypass header, an app that wants your session cookie: all three work, and each credential is scoped to the target and nowhere else.
- Cookies are set on the target host before the page loads. Copy
the session cookie from your browser’s devtools (Application → Cookies)
and pass it as
cookies=session=.... The browser scopes it by domain. - Headers are attached only to requests bound for the target host.
A bearer token for
app.example.comis never sent to the page’s CDN, analytics or any other origin it loads, and it does not follow a redirect to another host.Host,Cookieand hop-by-hop headers are refused. - Basic auth is answered once, only when the target origin asks for it. Wrong credentials render the server’s 401 page rather than failing, so you can see what happened.
Use the POST form for anything with a credential in it. A query string is written to access logs on the way; a JSON body is not.
curl -X POST "https://screenshot-api.net/v1/capture" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://preview-abc.vercel.app/dashboard",
"headers": { "x-vercel-protection-bypass": "..." },
"cookies": "session=...",
"basic_auth": "staging:hunter2"
}'Every response carries the document’s HTTP status, as status in
JSON and X-Page-Status on the image endpoint. Check it: a login page and
a dashboard both render perfectly well, and only the status tells them apart.
Pages you are logged into on your own machine. If the page only exists in your browser session, your CRM, an admin panel, a cart, a hosted service is the wrong tool and we will not pretend otherwise. Claude in Chrome or Playwright MCP with a persistent profile screenshot what you are already looking at, with no credential leaving your machine. Use us for what has to happen without a person and a laptop: schedules, deploys, CI, cloud agents, a hundred URLs at once.
Slack delivery
A compare result can land in a Slack channel with the diff image, the percentage changed, the regions and both pages’ status. No app to install on Slack’s side: it uses an incoming webhook.
- In Slack, open the channel, then Integrations → Add an app → Incoming Webhooks, and add it to the channel. Copy the webhook URL it gives you.
- On the dashboard, under Integrations, paste the URL and click Connect. We post a test message first, and only save the URL if Slack accepted it. The URL is stored as a credential and never shown again.
- Add
"notify": "slack"to any compare, from the API or the MCP tool.
curl -X POST "https://screenshot-api.net/v1/compare" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/pricing", "baseline": "pricing", "notify": "slack" }'The Slack app
Where the app is installed on this server, the dashboard shows Add to Slack
instead: pick a channel, and delivery is set up without copying anything. It also adds a
/screenshot command to every channel in the workspace:
/screenshot https://example.com
/screenshot https://example.com full
/screenshot https://example.com vs https://staging.example.comThe image is posted to the channel and the render counts against the account that installed the app. Every command request is verified against Slack’s signing secret before it is read.
The message’s image is served from a URL of the form /a/<id>.png:
a 128-bit random id, no login, valid for seven days, then deleted. That is what lets
Slack render it inline. The response carries delivered, and
delivery_error when Slack did not take the message; the comparison
itself still succeeds.
Deploy checks and schedules
A snapshot set is a named list of pages and widths. A run renders every page at every width, compares each with its baseline from the previous run, stores the diff images, and posts one summary to Slack. Runs start from a deploy hook, a schedule, the dashboard, or the API. The first run stores baselines; every run after that reports what moved and makes the new render the baseline, so each check is against the last known state.
curl -X POST "https://screenshot-api.net/v1/sets" \
-H "Authorization: Bearer $SCREENSHOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "marketing-site",
"pages": [ { "url": "https://example.com/", "label": "Home" },
{ "url": "https://example.com/pricing" } ],
"widths": [1280, 390],
"schedule": "daily",
"notify": "slack",
"options": { "full_page": "true" }
}'The response carries hook_url. Anything that can make a POST can trigger
a run with it; the body is ignored and the token in the URL is the credential, so
treat it like one.
# last step of the deploy job; the secret holds the hook URL
- name: Visual check
run: curl -fsS -X POST "$SCREENSHOT_HOOK"
env:
SCREENSHOT_HOOK: ${{ secrets.SCREENSHOT_HOOK }}
# Site settings → Build & deploy → Deploy notifications → Add notification
# → Outgoing webhook, event "Deploy succeeded", URL: the hook URL
# Account → Notifications → Destinations → Webhooks → add the hook URL,
# then a notification for "Pages deployment succeeded" to that destination
# Vercel has no per-project outgoing webhook; call it from the pipeline
# that deploys, or from a GitHub Action on the deployment_status event.
curl -fsS -X POST "$SCREENSHOT_HOOK"
| Field | Notes |
|---|---|
pages | 1 to 20. Each has url, optional label and selector. |
widths | Up to 3, 320 to 3840. Default [1280]. Height is three quarters of the width unless options.height says otherwise. |
schedule | hourly, 6h, daily, weekly, or empty for hook and manual runs only. |
notify | slack posts when something changed, failed or was newly stored; slack_always posts every run. Needs Slack connected. |
threshold, tolerance | As on compare. |
options | full_page, dark, delay, height, scale, and the credentials cookies, headers, basic_auth. Credentials are stored for the set and never returned. |
A run costs one render per page per width, so a set of 10 pages at 2 widths on an hourly schedule spends 14,400 renders a month; size the plan to the schedule.
Endpoints: GET /v1/sets, GET /v1/sets/{id} with its recent runs,
PUT /v1/sets/{id}, DELETE /v1/sets/{id},
POST /v1/sets/{id}/run to run now, POST /v1/sets/{id}/reset to
forget the baselines after a redesign, GET /v1/sets/{id}/runs and
GET /v1/runs/{id}. Free plans keep one set; Starter five, Pro twenty, Team
fifty, Business two hundred.
Bursts are one run. Ten deploy notifications in a minute queue one check, not ten. A run that hits the monthly quota stops and reports which pages it could not check.
MCP server
A hosted Model Context Protocol server, so an assistant — Claude, Claude Code, Cursor, ChatGPT, anything that speaks MCP — can take its own screenshots instead of asking you for one. There is nothing to download: give the client one URL, sign in once in the browser, and it is connected.
https://screenshot-api.net/mcpIt exists because a screenshot handed to a model is usually unreadable for a
reason nobody expects: the model does not see the image you sent, it sees one
resized so the long edge is around 1568px. Capturing wider is not more detail, it
is detail that gets discarded. The server aims at that number, spends what is left
on scale, and returns the page’s text beside the image so exact
characters never have to be guessed from pixels.
Connect
claude mcp add --transport http screenshot-api https://screenshot-api.net/mcp
# then, inside Claude Code: /mcp, pick screenshot-api, Authenticate
# claude.ai: Settings → Connectors → Add custom connector
Name: screenshot-api
URL: https://screenshot-api.net/mcp
# leave the client id and secret empty; the connector registers itself
// ~/.cursor/mcp.json
{
"mcpServers": {
"screenshot-api": { "url": "https://screenshot-api.net/mcp" }
}
}
# ChatGPT: Settings → Connectors → Create (developer mode)
Name: screenshot-api
URL: https://screenshot-api.net/mcp
Auth: OAuth
Whichever client, the first use opens a browser tab on this site. Sign in if you are not already, click Allow, and the tab hands control back. The connection then appears on your dashboard under the client’s name, next to your API keys, and can be disconnected there at any moment. Captures it makes count against your plan like any other.
What the consent grants. The same access as an API key: taking screenshots on your account and reading its plan and usage. It cannot create or revoke keys, change your plan or sign in as you. It is an ordinary API key with a label, which is why revoking it works the same way and takes effect immediately.
A client that would rather not use OAuth — a script, a self-hosted agent
— can send an API key instead, as Authorization: Bearer sk_live_...
on the same URL. The endpoint speaks Streamable HTTP: POST one
JSON-RPC message, get one JSON response.
Using it
Then just ask: “screenshot our pricing page and check the tiers line
up”, or “compare the preview deploy against production and
tell me what moved”. Two tools: screenshot, and
compare, which takes the same arguments plus against or
baseline and returns the diff image with the changed regions as
numbers. The screenshot arguments:
| Argument | Default | Notes |
|---|---|---|
url | — | Required. https:// is assumed. |
selector | — | Crop to one element. The single biggest thing you can do for legibility — one dialog in its own image beats any amount of viewport tuning. |
mode | read | read enlarges text; layout is the desktop view as designed; full is the whole page and its text will be small. |
viewport_width | 1045 | Narrower means larger text; scale is derived so the result still lands on the budget. |
include_text | true | The page’s rendered text alongside the image. |
theme, wait_ms | light, 0 | Dark mode; settle time for pages that load late. |
cookies, headers, basic_auth | — | For pages behind a login; same scoping as the API. The result text names the HTTP status, so a model knows a 401 page from a dashboard. |
Running it locally instead
The same server ships as a single static binary that speaks MCP over stdio and calls the API with a key of your own. Use it where a hosted connection is not an option: an air-gapped editor, a CI job, a client that cannot open a browser.
# download, verify, install
curl -fLO "https://screenshot-api.net/downloads/screenshot-api-mcp_0.3.0_darwin_arm64"
curl -fsSL "https://screenshot-api.net/downloads/SHA256SUMS" | shasum -a 256 -c --ignore-missing
chmod +x screenshot-api-mcp_0.3.0_darwin_arm64
xattr -d com.apple.quarantine screenshot-api-mcp_0.3.0_darwin_arm64 # unsigned download
sudo mv screenshot-api-mcp_0.3.0_darwin_arm64 /usr/local/bin/screenshot-api-mcp
# your key, in a file rather than an environment variable
printf '%s' "sk_live_..." > ~/.screenshot-api-key && chmod 600 ~/.screenshot-api-key
# register with Claude Code, then restart it
claude mcp add screenshot-api --scope user \
-e SCREENSHOT_API_KEY_FILE="$HOME/.screenshot-api-key" \
-- /usr/local/bin/screenshot-api-mcpIntel Macs, Linux and Windows builds are in
the same directory: swap
darwin_arm64 for darwin_amd64, linux_amd64,
linux_arm64 or windows_amd64.exe. The key goes in a file,
not an environment variable: an env var is written into the client’s config
and is readable by anything the agent runs. screenshot-api-mcp --check
renders one page and exits, which tells a bad key from a bad config.
Errors
Errors are JSON with a stable machine-readable error field. Branch on that, not on the prose in message.
| Status | error | Meaning |
|---|---|---|
| 400 | missing_url | No url parameter. |
| 400 | invalid_url | Malformed, wrong scheme, or a blocked destination. |
| 400 | no_element | selector matched nothing on that page. |
| 400 | invalid_format | format was not png, jpeg or webp. |
| 401 | missing_key | No credentials supplied. |
| 401 | invalid_key | Key not recognised. |
| 401 | revoked_key | Key has been revoked. |
| 402 | quota_reached | Monthly allowance spent. Upgrade or wait for the reset. |
| 429 | rate_limited | Too many requests per second. Honour Retry-After. |
| 502 | render_failed | The page did not load or render. Not counted against quota. |
| 503 | busy | Renderers saturated. Retry after a short pause. |
Failed renders are refunded. A 502 or 503 releases the reserved unit, so you are never billed for our failures.
Rate limits and quota
Two independent limits apply. Requests per second is a burst control; renders per month is your plan allowance.
| Plan | Renders / month | Snapshot sets | Requests / second |
|---|---|---|---|
| Free | 100 | 1 | 1 |
| Starter | 2,000 | 5 | 5 |
| Pro | 10,000 | 20 | 10 |
| Team | 25,000 | 50 | 25 |
| Business | 100,000 | 200 | 50 |
Quota resets at the start of each calendar month, UTC.
URL restrictions
Some destinations are refused with invalid_url. This is not configurable, and it protects both this service and yours:
- Schemes other than
httpandhttps. - Private and reserved address space: loopback, RFC1918, link-local, carrier-grade NAT and cloud metadata endpoints.
- Hostnames that resolve to any of the above, including names that return a mix of public and private addresses.
- URLs containing embedded credentials.
- Ports other than 80, 443, 8080 and 8443.
If you need a screenshot of something on a private network, it has to be reachable from the public internet first.