Quickstart

Every capture is a single HTTP GET that returns raw image bytes. There is no SDK and nothing to install.

Terminal
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:

HTTP
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.

NameTypeDefaultNotes
urlstringrequiredhttp or https only, max 2048 characters.
widthinteger1280Viewport width in CSS pixels. Max 3840.
heightinteger800Viewport height in CSS pixels. Max 4320.
full_pagebooleanfalseCapture the full scrollable page. Height is capped at 4320px.
formatstringpngpng, jpeg or webp.
qualityinteger851–100. Ignored for png, which is lossless.
scalenumber1Device pixel ratio, 0.1–3. Use 2 for retina.
darkbooleanfalseSets prefers-color-scheme: dark for the page.
delayinteger0Milliseconds to wait after load, 0–10000. For late animations.
cookiesstringname=value; name2=value2, set on the target host before the page loads. See pages behind a login.
headerstring, repeatableName: value. Sent only on requests to the target host. In the POST form, headers may be an object instead.
basic_authstringuser:password, offered once when the target origin asks for HTTP basic auth.
timeoutinteger25Seconds to allow for the whole render.

Booleans accept 1, true, yes and on.

Response headers

HeaderMeaning
X-Quota-LimitRenders included in your plan this month.
X-Quota-RemainingRenders left in the current period.
X-Render-Time-MsServer-side render time in milliseconds.
X-Page-StatusHTTP 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.

JSON
{
  "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.

Terminal
curl -G "https://screenshot-api.net/v1/capture" \
  -H "Authorization: Bearer $SCREENSHOT_API_KEY" \
  --data-urlencode "url=https://example.com" \
  -d "selector=.pricing-table"
JSON
{
  "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 }
}
ParameterDefaultNotes
selectorCSS 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.
texttrueSet 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.

Staging against production
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 }'
Has this page changed since last time?
# 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" }'
JSON
{
  "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
}
ParameterDefaultNotes
againstA second URL, rendered now with the same parameters. Give this or baseline, not both.
baselineA name to store under and compare against. Letters, digits, dot, dash, underscore, slash. Up to 100 per account.
update_baselinefalseAfter comparing, store this render as the new baseline. For when the change was expected.
threshold0.1Percent of pixels that must differ for changed to be true. Raise it to ignore a clock or a carousel.
tolerance24Per-channel colour distance, 0–255, below which a pixel counts as unchanged. Anti-aliasing sits under it; a colour change over it.
notifyslack posts the result to your connected channel when something changed or a baseline was created; slack_always posts every time. See Slack delivery.
everything elseAll 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.

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.

Terminal
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.

  1. 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.
  2. 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.
  3. Add "notify": "slack" to any compare, from the API or the MCP tool.
Terminal
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:

Slack
/screenshot https://example.com
/screenshot https://example.com full
/screenshot https://example.com vs https://staging.example.com

The 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.

Create a set
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"
FieldNotes
pages1 to 20. Each has url, optional label and selector.
widthsUp to 3, 320 to 3840. Default [1280]. Height is three quarters of the width unless options.height says otherwise.
schedulehourly, 6h, daily, weekly, or empty for hook and manual runs only.
notifyslack posts when something changed, failed or was newly stored; slack_always posts every run. Needs Slack connected.
threshold, toleranceAs on compare.
optionsfull_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.

Server URL
https://screenshot-api.net/mcp

It 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:

ArgumentDefaultNotes
urlRequired. https:// is assumed.
selectorCrop to one element. The single biggest thing you can do for legibility — one dialog in its own image beats any amount of viewport tuning.
modereadread enlarges text; layout is the desktop view as designed; full is the whole page and its text will be small.
viewport_width1045Narrower means larger text; scale is derived so the result still lands on the budget.
include_texttrueThe page’s rendered text alongside the image.
theme, wait_mslight, 0Dark mode; settle time for pages that load late.
cookies, headers, basic_authFor 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.

macOS (Apple Silicon)
# 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-mcp

Intel 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.

StatuserrorMeaning
400missing_urlNo url parameter.
400invalid_urlMalformed, wrong scheme, or a blocked destination.
400no_elementselector matched nothing on that page.
400invalid_formatformat was not png, jpeg or webp.
401missing_keyNo credentials supplied.
401invalid_keyKey not recognised.
401revoked_keyKey has been revoked.
402quota_reachedMonthly allowance spent. Upgrade or wait for the reset.
429rate_limitedToo many requests per second. Honour Retry-After.
502render_failedThe page did not load or render. Not counted against quota.
503busyRenderers 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.

PlanRenders / monthSnapshot setsRequests / second
Free10011
Starter2,00055
Pro10,0002010
Team25,0005025
Business100,00020050

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:

If you need a screenshot of something on a private network, it has to be reachable from the public internet first.