Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: snaplab Captures API
version: 1.0.0
summary: Turn any URL or HTML into a screenshot, a PDF, or a social image.
description: 'snaplab renders web pages with a real headless Chromium and hands you the bytes.
- `POST|GET /api/render` — URL **or** raw HTML → PNG / JPEG / WebP / AVIF / PDF, viewport or full page, retina, dark mode, element clipping.
- `POST|GET /api/render/image` — HTML (or a `{{ template }}` + `data`) → a fixed-size image. The Open Graph card path.
- `POST /api/render/preview` — unauthenticated, rate-limited demo render.
- `POST /api/tools/*` — key-less public tools, 20 renders/day/IP, always answered with a hosted URL.
- `/mcp` — a stateless Streamable-HTTP MCP server exposing `screenshot`, `pdf`, `html_to_image` and `usage`.
## Authentication
Every endpoint except `/api/health`, `/api/render/preview` and `/api/tools/*` needs an API key. Keys look like `sk_…`
and are created in the dashboard or through `POST /api/keys`.
1. `Authorization: Bearer sk_…` — the header form, accepted everywhere.
2. Signed query parameters — `GET /api/render` and `GET /api/render/image` only. See below.
3. The browser session cookie, for the dashboard itself.
### Signed URLs
Putting a raw key in an `` leaks it. Sign the URL instead: append `keyId` (the API key''s id) and
`sig=hex(HMAC_SHA256(signing_secret, canonical))`, optionally with an `expires` Unix timestamp. The canonical string is
every query pair except `sig`, sorted by name then value and joined with `&`. The per-key signing secret and a URL
builder live in the dashboard under Keys.
## Options
Options are camelCase and identical between the JSON body and the query string. In a query string, booleans are
`true`/`1` and `false`/`0`, `hideSelectors` is a comma-separated list, and PDF options take either the dotted form
(`pdf.format=A4`) or the flat alias (`pdfFormat=A4`).
## Caching and quota
Identical options are served from a shared cache for 24h. **Cache hits and failed renders do not count against your
quota** — only fresh, successful renders do. Renders carry `X-Snaplab-Cache`, `X-Snaplab-Duration-Ms`,
`X-Snaplab-Fingerprint` and `X-Snaplab-Quota-Remaining`.
Pass `cache: false` to force a fresh render.
## Errors
Errors are JSON: `{"error":"…","code":"…"}`. Messages are for humans; codes are stable.
| Status | Code | Meaning |
| --- | --- | --- |
| 400 | — | A parameter is missing, malformed, out of range, or the target is a private/blocked address. |
| 401 | `unauthorized` | Missing, unknown or revoked API key; or a bad signature. |
| 402 | `payment_required` | The subscription is past due. |
| 422 | `selector_not_found` | The page rendered but `selector` matched nothing. |
| 429 | `quota_exceeded` | The monthly render allowance is used up. |
| 429 | — | Too many requests per minute for your plan. |
| 502 | `upstream` | The target site was unreachable. |
| 503 | `capacity` / `not_ready` | The render queue is saturated, or the browsers are still starting. Retry shortly. |
| 504 | `timeout` | The page did not finish in time. |
## Plans
| Plan | Renders / month | Price | Rate | Concurrency |
| --- | --- | --- | --- | --- |
| free | 100 | $0 | 30/min | 1 |
| starter | 2,000 | $9 | 120/min | 3 |
| pro | 10,000 | $29 | 300/min | 5 |
| business | 40,000 | $79 | 600/min | 10 |'
termsOfService: https://snaplab.dev/terms
contact:
name: snaplab support
url: https://snaplab.dev/docs
email: snaplab.dev
license:
name: MIT
identifier: MIT
servers:
- url: https://snaplab.dev
description: Production
security:
- bearerAuth: []
tags:
- name: Captures
description: Stored renders, served from public `/i/c/{id}` URLs.
paths:
/api/captures:
get:
tags:
- Captures
operationId: listCaptures
summary: List stored captures
security:
- bearerAuth: []
parameters:
- name: limit
in: query
required: false
schema:
type: integer
maximum: 100
default: 30
description: Page size.
- name: cursor
in: query
required: false
schema:
type: string
description: '`nextCursor` from the previous page (a Unix-seconds timestamp).'
- name: status
in: query
required: false
schema:
type: string
enum:
- pending
- processing
- completed
- failed
description: Filter by status.
- name: q
in: query
required: false
schema:
type: string
description: Substring match on url or title.
responses:
'200':
description: A page of captures, newest first.
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/Capture'
nextCursor:
type:
- string
- 'null'
'401':
description: Not authenticated.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
post:
tags:
- Captures
operationId: createCapture
summary: Render and store a capture
description: Renders synchronously and keeps the result, so it stays available at `/i/c/{id}` and in the dashboard. Pass `webhookUrl` to be called back when the capture finishes instead of polling — see `WebhookPayload`.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CaptureRequest'
responses:
'200':
description: The completed capture row.
content:
application/json:
schema:
$ref: '#/components/schemas/Capture'
'400':
description: Invalid options, a target that resolves to a private/blocked address, or a `webhookUrl` that is not https or resolves to a private address.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'401':
description: Missing or invalid API key / signature.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'402':
description: Subscription past due (`payment_required`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'422':
description: The render can never succeed as written (`selector_not_found`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: Rate limit or monthly quota exceeded (`quota_exceeded`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'502':
description: The target site was unreachable (`upstream`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'503':
description: Queue saturated or browsers not ready. Retry after `Retry-After` seconds.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'504':
description: The page did not finish in time (`timeout`).
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/captures/{id}:
parameters:
- name: id
in: path
required: true
schema:
type: string
get:
tags:
- Captures
operationId: getCapture
summary: Fetch one capture
security:
- bearerAuth: []
responses:
'200':
description: The capture.
content:
application/json:
schema:
$ref: '#/components/schemas/Capture'
'401':
description: Not authenticated.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: No such capture for this account.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
delete:
tags:
- Captures
operationId: deleteCapture
summary: Delete a capture
description: Removes the row and, when nothing else references them, the stored bytes.
security:
- bearerAuth: []
responses:
'200':
description: Deleted.
content:
application/json:
schema:
type: object
properties:
ok:
type: boolean
'401':
description: Not authenticated.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'404':
description: No such capture for this account.
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
WebhookDelivery:
type: object
description: One delivery attempt of a capture callback. Attempts run at 0s, 30s, 5min and 30min; after the fourth the row is `gave_up`.
properties:
id:
type: string
attempt:
type: integer
description: Attempts made so far, 1-4.
status:
type: string
enum:
- pending
- ok
- failed
- gave_up
lastStatusCode:
type:
- integer
- 'null'
description: HTTP status of the last attempt; null if the request never completed.
updatedAt:
type: string
format: date-time
Error:
type: object
required:
- error
properties:
error:
type: string
description: Human-readable message.
code:
type: string
description: Stable machine code, when one applies.
retryable:
type: boolean
issues:
type: array
items:
type: object
additionalProperties: true
description: Zod validation issues, for 400s.
upgradeUrl:
type: string
CaptureWebhook:
type: object
description: Present on `GET /api/captures/{id}` only when a callback was set.
properties:
url:
type: string
format: uri
deliveries:
type: array
items:
$ref: '#/components/schemas/WebhookDelivery'
CaptureRequest:
type: object
description: Every `RenderOptions` field, plus the optional callback. Omitted webhook fields fall back to the account defaults set with `PATCH /api/me`.
allOf:
- $ref: '#/components/schemas/RenderOptions'
properties:
webhookUrl:
type: string
format: uri
maxLength: 2048
description: '`https://` URL POSTed once the capture reaches `completed` or `failed`. Private, loopback, link-local and metadata addresses are refused with a 400.'
example: https://api.example.com/hooks/snaplab
webhookSecret:
type: string
maxLength: 256
description: 'Signs the callback: `x-snaplab-signature: sha256=hex(HMAC_SHA256(secret, timestamp + "." + rawBody))`. Never returned by the API.'
RenderOptions:
type: object
description: Exactly one of `url` or `html`/`template` must be given.
properties:
url:
type: string
maxLength: 2048
format: uri
description: Absolute http(s) URL to capture. Private, loopback, link-local and metadata addresses are refused.
html:
type: string
maxLength: 2000000
description: Raw HTML rendered instead of navigating. Subresources are still SSRF-filtered.
template:
type: string
maxLength: 2000000
description: HTML with `{{ path.to.value }}` placeholders, substituted from `data` and HTML-escaped.
data:
type: object
additionalProperties: true
description: Values for `template` (or for `html`, when no template is given).
css:
type: string
maxLength: 500000
description: Stylesheet injected into `<head>` before load. html/template mode only.
googleFonts:
type: string
maxLength: 300
description: Comma-separated Google Fonts families, e.g. `Inter,Playfair Display` (max 6).
format:
type: string
enum:
- png
- jpeg
- webp
- avif
- pdf
default: png
viewport:
type: string
enum:
- desktop
- laptop
- tablet
- mobile
- wide
default: desktop
description: Preset size. `width`/`height`/`deviceScaleFactor` override individual axes.
width:
type: integer
minimum: 120
maximum: 3840
height:
type: integer
minimum: 120
maximum: 4320
deviceScaleFactor:
type: number
minimum: 0.5
maximum: 3
description: Pixel ratio. 2 = retina.
mobile:
type: boolean
description: 'Sugar for `viewport: "mobile"` (390x844 @3x, touch emulation).'
fullPage:
type: boolean
default: true
description: Capture the whole scrollable document. Ignored when `selector` is set.
selector:
type: string
maxLength: 512
description: Capture just this element. PDFs stay vector/selectable.
quality:
type: integer
minimum: 1
maximum: 100
description: jpeg/webp/avif only.
waitUntil:
type: string
enum:
- auto
- load
- domcontentloaded
- networkidle
default: auto
description: '"auto" waits for load, then for the network to be quiet for 150ms, capped at 400ms. "networkidle" is the full Playwright wait and costs ~0.5s more.'
waitForSelector:
type: string
maxLength: 512
description: Wait (up to 10s) for this selector before capturing.
delayMs:
type: integer
minimum: 0
maximum: 30000
scrollY:
type: integer
minimum: 0
maximum: 50000
description: Scroll down this many pixels first (triggers lazy loading).
hideSelectors:
type: array
maxItems: 20
items:
type: string
maxLength: 512
description: Elements to hide before capturing. Comma-separated in a query string.
darkMode:
oneOf:
- type: boolean
- type: string
enum:
- prefers
- force
description: '`true` (= `"force"`) emulates `prefers-color-scheme: dark` AND turns on Chromium''s auto dark mode, so a page with no dark theme comes back dark; a page that has one is left alone. `"prefers"` is the media query only.'
dismissBanners:
type: boolean
default: true
description: Hide known cookie/consent/chat banners with a curated stylesheet.
blockAds:
type: boolean
default: false
description: Abort requests to known ad, analytics and session-replay hosts.
customCss:
type: string
maxLength: 20000
description: CSS injected after load.
customJs:
type: string
maxLength: 20000
description: JavaScript evaluated after load.
cookies:
type: array
maxItems: 40
items:
type: object
required:
- name
- value
properties:
name:
type: string
maxLength: 256
value:
type: string
maxLength: 4096
domain:
type: string
maxLength: 256
description: Defaults to the target host.
path:
type: string
maxLength: 256
default: /
secure:
type: boolean
httpOnly:
type: boolean
sameSite:
type: string
enum:
- Strict
- Lax
- None
expires:
type: integer
description: Unix seconds. Omit for a session cookie.
additionalProperties: false
description: Per-request cookies. Renders with cookies are never cached or shared.
headers:
type: object
additionalProperties:
type: string
maxLength: 8192
description: Extra request headers.
basicAuth:
type: object
required:
- username
- password
properties:
username:
type: string
password:
type: string
additionalProperties: false
pdf:
type: object
description: PDF-only geometry. Ignored unless `format` is `pdf`.
properties:
format:
type: string
enum:
- Letter
- Legal
- Tabloid
- Ledger
- A0
- A1
- A2
- A3
- A4
- A5
- A6
default: A4
landscape:
type: boolean
default: false
printBackground:
type: boolean
default: true
scale:
type: number
minimum: 0.1
maximum: 2
default: 1
preferCSSPageSize:
type: boolean
default: false
description: Honour the page's own `@page size` rule instead of `format`.
margin:
type: object
description: CSS lengths (`0.4in`, `1cm`, `12mm`…).
properties:
top:
type: string
default: 0.4in
right:
type: string
default: 0.4in
bottom:
type: string
default: 0.4in
left:
type: string
default: 0.4in
additionalProperties: false
headerTemplate:
type: string
maxLength: 20000
description: HTML drawn at the top of every page, inside the top margin. It inherits none of the document's CSS, so set `font-size` explicitly and add your own side padding. Any element carrying the classes `pageNumber`, `totalPages`, `date`, `title` or `url` is filled in by Chromium.
footerTemplate:
type: string
maxLength: 20000
description: 'Same as `headerTemplate`, at the bottom of every page. Also accepts a built-in preset name: `@pageNumbers` (centred “Page N of M”) or `@pageNumbersUrl` (page numbers left, document URL right).'
displayHeaderFooter:
type: boolean
description: Draw the running header and footer. Defaults to `true` as soon as either template is set; pass `false` to suppress both.
pageRanges:
type: string
maxLength: 200
description: 'Sheets to keep, in print-dialog syntax — `1-3`, `1,4,7-9`. Default: every page.'
example: 1-3
media:
type: string
enum:
- screen
- print
default: screen
description: Which stylesheet the paper is printed under. `print` honours the document's own print CSS (`@media print` rules, page breaks, hidden nav); `screen` keeps the on-screen look. Screenshots are always screen media.
tagged:
type: boolean
default: false
description: Emit a tagged (accessible) PDF with structure tags for screen readers.
outline:
type: boolean
default: false
description: Embed a document outline (PDF bookmarks) built from the page's headings.
width:
type: string
maxLength: 40
description: Exact paper width as a CSS length. Replaces `format`; ignored unless `height` is set too.
example: 210mm
height:
type: string
maxLength: 40
description: Exact paper height as a CSS length.
example: 297mm
omitBackground:
type: boolean
default: false
description: 'Drop background colours and images. The same knob as `printBackground: false`, and it wins over it.'
additionalProperties: false
filename:
type: string
maxLength: 120
description: 'Suggested download name, sanitised. Sets `content-disposition: inline; filename="…"` on the returned bytes and on the hosted `/i/c/{id}` file (`?download=1` there makes it an `attachment`). Include the extension you want — it is used verbatim.'
example: INV-2026-014.pdf
cache:
type: boolean
default: true
description: Set false to force a fresh render.
responseType:
type: string
enum:
- binary
- json
default: binary
description: '`binary` streams the bytes; `json` stores the render and answers with a hosted URL.'
additionalProperties: false
Capture:
type: object
properties:
id:
type: string
userId:
type: string
apiKeyId:
type:
- string
- 'null'
url:
type: string
title:
type:
- string
- 'null'
status:
type: string
enum:
- pending
- processing
- completed
- failed
format:
type: string
selector:
type:
- string
- 'null'
fullPage:
type: boolean
width:
type: integer
height:
type: integer
deviceScaleFactor:
type: integer
darkMode:
type: boolean
fileKey:
type:
- string
- 'null'
fileSize:
type:
- integer
- 'null'
fingerprint:
type:
- string
- 'null'
error:
type:
- string
- 'null'
durationMs:
type:
- integer
- 'null'
cacheHit:
type: boolean
createdAt:
type: string
format: date-time
completedAt:
type:
- string
- 'null'
format: date-time
webhook:
$ref: '#/components/schemas/CaptureWebhook'
description: Only on `GET /api/captures/{id}`, and only when a callback was set.
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: sk_<40 chars>
description: 'Send the key as `Authorization: Bearer sk_…`. This is also how the MCP server at `/mcp` authenticates.'
signedKeyId:
type: apiKey
in: query
name: keyId
description: 'Signed-URL auth (GET renders only): the API key''s **id**, not its secret. Pair with `sig`.'
signedSignature:
type: apiKey
in: query
name: sig
description: '`hex(HMAC_SHA256(signingSecret, canonical))`, where canonical is every query pair except `sig`, sorted by name then value and joined with `&`. Add `expires` (Unix seconds) to time-limit the URL.'
externalDocs:
description: snaplab documentation
url: https://snaplab.dev/docs