# About Ssnap (/about) Ssnap is a screenshot and PDF API: one HTTP endpoint that renders any public web page to `jpeg`, `png`, `webp` or `pdf`. This page covers who is behind it and how it actually works, because "trust the API with your production pipeline" is a fair thing to want evidence for. ## How captures are produced [#how-captures-are-produced] Every capture is rendered by **headless Chromium** — a real browser engine, not an HTML- to-image converter. That is why your `@media print` rules, CSS grid, web fonts, `prefers-color-scheme` and client-side charts all behave the way they do in a browser. The request path, in order: 1. The API key is resolved and checked against the per-minute rate limit for that key. 2. The target url is validated as publicly routable — private and internal addresses are refused. See [URL restrictions](/platform/url-restrictions). 3. If `cache_ttl` is set and a matching earlier capture exists, that render is returned without launching a browser. 4. The team's monthly quota is checked and a capture slot is reserved. 5. Chromium renders the page; image formats are post-processed; the result is stored. 6. You get bytes, a signed link, or a webhook. A capture that fails mid-render releases its reserved slot, so broken renders are not billed against your quota. ## Security and data handling [#security-and-data-handling] * **API keys are stored as SHA-256 hashes.** The plaintext key is shown once, at creation, and cannot be recovered afterwards — which is also why a lost key has to be replaced rather than retrieved. See [API keys](/account/api-keys). * **Keys can be scoped in time.** An expiry date is optional at creation and enforced on every request; expired keys fail with `api_key_expired`. * **Captures are team-scoped.** The cache will never serve another team's render, because team ownership is part of the lookup, not just the cache key. * **Outbound fetches are constrained.** Both `url` and `callback_url` go through the same public-host checks, so the API cannot be used to reach private infrastructure. * **Webhook deliveries are signed**, so your endpoint can reject anything that did not come from Ssnap. See [Verify webhook signatures](/guides/verify-webhooks). * **Stored captures expire.** Files are kept for a bounded retention window and then removed by a scheduled job — details in [Storage and retention](/platform/retention). * **Signed links are short-lived.** A `response=url` link is valid for 24 hours and carries no credential beyond its own signature. ## Platform health [#platform-health] [ssnap.cc/status](https://ssnap.cc/status) is checked live on request rather than served from a cached dashboard, and it probes the things that actually break: database, cache, the browser engine (by rendering a real test page), the async queue depth and the recent capture success rate. There is a lightweight `GET /up` endpoint for uptime monitors. More detail in [Status and support](/platform/status). ## About these docs [#about-these-docs] * Written and maintained by the team that builds the API, not generated from a schema. * Every parameter range, default and error code on this site reflects the behaviour of the deployed service. Where behaviour is surprising, it is documented as such rather than smoothed over. * Each page carries a last-updated date, taken from the commit that changed it. * Every page is available as Markdown for machine consumption: append `.md`, use the copy button, or see [llms.txt](/llms.txt) and [llms-full.txt](/llms-full.txt). * Found something wrong or out of date? Mail it to [info@ssnap.cc](mailto:info@ssnap.cc) and it gets fixed. ## Contact [#contact] | Channel | Where | | ------------ | -------------------------------------------- | | Email | [info@ssnap.cc](mailto:info@ssnap.cc) | | Contact form | [ssnap.cc/contact](https://ssnap.cc/contact) | | In-app | The feedback control in your dashboard | | Status | [ssnap.cc/status](https://ssnap.cc/status) | When reporting a failed capture, include the target url, the full parameter set, the response status and the `code` from the body. That is normally enough to reproduce it without a back-and-forth. # API authentication (/authentication) Every request to `/api/v1/*` must carry a valid API key. Keys are created per team in the dashboard under **API keys**. ## Sending the key [#sending-the-key] Two equivalent forms are accepted. ```bash title="Bearer token (preferred)" curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer prod_1a2b3c..." \ --data-urlencode "url=https://example.com" ``` ```bash title="api_key parameter" curl -G https://ssnap.cc/api/v1/screenshot \ --data-urlencode "api_key=prod_1a2b3c..." \ --data-urlencode "url=https://example.com" ``` The bearer header takes precedence when both are present. Prefer it: query strings end up in browser history, proxy logs and server access logs. ## Key format [#key-format] Keys are a short environment prefix followed by 64 hexadecimal characters: | Environment | Prefix | Example | | ----------- | ------- | --------------------- | | Production | `prod_` | `prod_9f3c…` (64 hex) | | Staging | `stg_` | `stg_9f3c…` | | Local | `dev_` | `dev_9f3c…` | Only the SHA-256 hash of a key is stored. The plaintext value is displayed exactly once, at creation. ## What a key carries [#what-a-key-carries] A key is bound to the team that was active when it was created. That binding determines: * the **monthly screenshot quota** it draws from, * the **per-minute rate limit** applied to it, * the **team** that owns every screenshot it captures. Keys are throttled individually: two keys on the same team each get the plan's full per-minute allowance, but they share the one monthly quota. ## Authentication failures [#authentication-failures] | Status | Code | Meaning | | ------ | ------------------ | ------------------------------------------- | | 401 | `missing_api_key` | No bearer token and no `api_key` parameter. | | 401 | `invalid_api_key` | The key does not match any active record. | | 403 | `api_key_inactive` | The key exists but has been deactivated. | | 403 | `api_key_expired` | The key is past its `expires_at` date. | ```json title="401 response" { "error": "Missing API key", "code": "missing_api_key" } ``` Never ship a key in client-side code. A leaked key can spend your whole monthly quota. Proxy captures through your own backend, and rotate keys from the dashboard if one escapes. See [API keys](/account/api-keys) for creating, expiring and revoking keys. ## Next [#next] # Ssnap Screenshot API (/) Ssnap is a screenshot API. You send one HTTP request with a target url, and you get back a rendered image or PDF, either as raw bytes, as a signed link, or delivered to your own webhook. No browser to install, no Chromium in your container. ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --output example.jpg ``` ## What you get [#what-you-get] * **One endpoint.** `GET` or `POST` on [`/api/v1/screenshot`](/api/screenshot), with the same parameters and responses on both verbs. * **Four output formats.** `jpeg`, `png`, `webp` and [`pdf`](/guides/pdf-export). * **131 device presets** plus explicit viewport control, dark-mode emulation, [full-page capture and CSS-selector element clipping](/guides/full-page-and-elements). * **Post-capture styling.** [Background colour, borders and text watermarks](/guides/image-styling), applied server-side. * **Sync or async.** Get the bytes back in the response, or pass a `callback_url` and receive a [signed webhook](/api/async-callbacks) when the render is done. * **Result caching.** Repeat captures inside a [`cache_ttl`](/api/caching) window are served from the previous render and do not consume quota. ## Start from your language [#start-from-your-language] ## Start from the problem [#start-from-the-problem] ## How a capture works [#how-a-capture-works] 1. Your key is resolved and throttled against your plan's [per-minute allowance](/api/rate-limits-and-quotas). 2. The target url is validated: it must be a publicly routable `http(s)` address. See [URL restrictions](/platform/url-restrictions). 3. If `cache_ttl` is set and a matching earlier capture exists, that render is returned. 4. Otherwise your team's monthly quota is checked and a capture slot is reserved. 5. Headless Chromium renders the page; the result is post-processed and [stored](/platform/retention). 6. You receive [the bytes, a signed url, or a webhook](/api/responses). A failed capture releases its reserved slot, so broken renders are never billed against your quota. Every failure mode is listed in [API error codes](/api/errors). ## Support [#support] Email [info@ssnap.cc](mailto:info@ssnap.cc). Live platform status is at [ssnap.cc/status](https://ssnap.cc/status), and [About Ssnap](/about) covers how the service is built and how these docs are maintained. # Quickstart (/quickstart) ## Create an account [#create-an-account] Sign up at [ssnap.cc](https://ssnap.cc) with an email and password, or with Google. A personal team is created for you automatically, because every API key, screenshot and subscription belongs to a team, not to an individual user. ## Subscribe to a plan [#subscribe-to-a-plan] Open **Subscriptions** in your dashboard and pick a plan. Captures require an active subscription: without one, the API answers `402` with code `subscription_required`. See [Plans and billing](/account/plans-and-billing) for allowances and prices. ## Create an API key [#create-an-api-key] Go to **API keys**, give the key a name, and optionally an expiry date. The key is shown once, at creation. Ssnap stores only a SHA-256 hash of it, so a lost key cannot be recovered. Delete it and create a new one. ## Capture a page [#capture-a-page] ```bash title="Binary response" curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "format=png" \ --data-urlencode "full_page=true" \ --output example.png ``` By default the response body *is* the image. Ask for a link instead with `response=url`: ```bash title="Signed url response" curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "response=url" ``` ```json title="Response" { "url": "https://ssnap.cc/screenshots/0193.../file?expires=...&signature=...", "cached": false } ``` The returned link is signed and valid for 24 hours. ## Go async for slow pages [#go-async-for-slow-pages] Pass a `callback_url` and the request returns immediately with `202`; the finished capture is POSTed to your endpoint. ```bash curl -X POST https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com","callback_url":"https://your-app.com/hooks/ssnap"}' ``` See [Async callbacks](/api/async-callbacks) for the payload and signature scheme. ## Next steps [#next-steps] # Account security (/account/account-security) ## Sign-in options [#sign-in-options] * **Email and password**, with email verification on registration. * **Google OAuth**: sign in without a password. An OAuth account is linked to the email address it carries. * **Password reset** by email from the sign-in page. ## Two-factor authentication [#two-factor-authentication] Enable 2FA under **Settings → Security**. Setup gives you a QR code for any TOTP app (1Password, Authy, Google Authenticator) plus recovery codes. Store the recovery codes somewhere other than the device holding your authenticator. They are the only way back into an account whose second factor is lost. Confirming or disabling 2FA requires your password. ## Password changes [#password-changes] Change your password under **Settings → Security**. The page itself is password-confirmed, and password updates are throttled to 6 attempts per minute. ## Profile [#profile] **Settings → Profile** holds your name, email address and avatar. Changing an email address triggers re-verification. ## Deleting your account [#deleting-your-account] Account deletion is available from the profile settings page and requires a verified email. It is permanent. Deleting your user account is not the same as deleting a team. Review team ownership first, using [Teams](/account/teams), so a team you own is not left without an owner. ## API key hygiene [#api-key-hygiene] Account security ends where key handling begins: a key is a bearer credential with no second factor. Keep keys server-side, give them expiry dates, rotate them, and delete any key you cannot account for. See [API keys](/account/api-keys). ## Next [#next] # API keys (/account/api-keys) API keys live under **API keys** in your dashboard, at `ssnap.cc//api-keys`. ## Creating a key [#creating-a-key] Give the key a name (up to 255 characters) and, optionally, an expiry date. Names are for you: use them to identify which system a key belongs to, so revoking one later is not guesswork. The key value is displayed **once**, right after creation. Only a SHA-256 hash is stored, so it cannot be shown again. Copy it into your secret store immediately. New keys are active on creation and belong to the team that was current when you made them. ## What a key is scoped to [#what-a-key-is-scoped-to] | Scope | Effect | | ------ | ---------------------------------------------------------------------------------- | | Team | Every capture is owned by the key's team and drawn from that team's monthly quota. | | Plan | The key's per-minute rate limit comes from the team's plan. | | Expiry | After `expires_at`, requests fail with `403` `api_key_expired`. | Keys are throttled individually, so several keys on one team each get the full per-minute allowance while sharing one monthly quota. See [Rate limits and quotas](/api/rate-limits-and-quotas). ## Expiry [#expiry] An expiry date is optional; without one the key works until deleted. Expiry is a useful default for keys handed to contractors, CI pipelines or short-lived experiments. ## Revoking [#revoking] Delete a key from the dashboard and it stops working immediately. Deletion is restricted to the team the key belongs to. Screenshots captured with a deleted key are **not** removed; they stay in your history until [retention](/platform/retention) prunes them. ## Rotation [#rotation] 1. Create a new key with a name marking the rotation (`billing-service-2026-09`). 2. Deploy it. 3. Confirm traffic on the new key. 4. Delete the old one. Since keys are throttled independently, a rotation does not halve your throughput while both are live. ## If a key leaks [#if-a-key-leaks] Delete it first, then investigate. A leaked key can spend your whole monthly allowance and every capture it makes is attributed to your team. Never embed a key in a browser, mobile app or public repository. Put captures behind your own backend endpoint, and keep the key server-side. ## Quick generate [#quick-generate] The **Screenshots** page has a quick-generate form for trying parameters without writing code. It uses one of your active keys and consumes quota exactly like an API call, so test renders count against your monthly allowance. ## Next [#next] # Plans and billing (/account/plans-and-billing) Subscriptions are per team and handled through Stripe. Without an active subscription, captures fail with `402` and code `subscription_required`. ## Plans [#plans] | Plan | Basic | Pro | Enterprise | | ------------------- | -------------------- | -------------------- | -------------------- | | Monthly | $10 | $29 | $149 | | Yearly | $100 | $290 | $1,490 | | Screenshots / month | 5,000 | 50,000 | 150,000 | | Requests / minute | 60 | 300 | 1,000 | | Formats | JPEG, PNG, WebP, PDF | JPEG, PNG, WebP, PDF | JPEG, PNG, WebP, PDF | | Support | Standard | Priority | 24/7 | Yearly billing works out at roughly ten months for twelve. ## Subscribing [#subscribing] Open **Subscriptions** in your dashboard, choose a plan and interval, and complete Stripe Checkout. You are returned to the dashboard with the result. Only a team owner may manage billing. ## Changing plan [#changing-plan] Swapping plan from the same page: * **Upgrades are invoiced immediately**, so the higher allowance applies at once. * **Downgrades prorate a credit** toward your next billing cycle. Your new rate limit and monthly allowance follow the new plan as soon as the subscription updates. ## Billing period and quota reset [#billing-period-and-quota-reset] The screenshot counter resets on your **subscription anniversary**, not on the first of the calendar month: subscribe on the 12th and the period rolls over on the 12th. Current usage and period end are shown on the dashboard. Cache hits and failed captures are not counted. See [Rate limits and quotas](/api/rate-limits-and-quotas). ## The 80% warning [#the-80-warning] When usage crosses 80% of the monthly allowance, the team owner gets one email for that period. It is a heads-up to upgrade or to raise `cache_ttl` before captures start failing with `quota_exceeded`. ## Invoices and payment methods [#invoices-and-payment-methods] The **Billing** action opens the Stripe customer portal, where you can update the card, change the billing address and download past invoices. Paid invoices are also recorded against the team as transactions. ## Cancelling [#cancelling] Cancel from the Stripe portal. Captures keep working while the subscription is still active; once it lapses, the API returns `402 subscription_required`. Stored screenshots remain until [retention](/platform/retention) prunes them. ## Next [#next] # Teams (/account/teams) Teams are the unit of ownership in Ssnap. API keys, screenshots, subscriptions and quota all belong to a team, never to an individual. Your account gets a personal team on sign-up, and the team slug appears in dashboard urls: `ssnap.cc//dashboard`. ## Roles [#roles] | Role | Level | Can do | | ---------- | ----- | --------------------------------------------------------------------------------------- | | **Owner** | 3 | Everything: update and delete the team, manage members and invitations, manage billing. | | **Admin** | 2 | Update the team, create and cancel invitations. | | **Member** | 1 | Use the team's API keys and see its screenshots. | Only an owner can add, update or remove members, delete the team, or manage billing. Admins handle day-to-day invitations. Owner cannot be assigned through the invitation flow; it belongs to whoever created the team. ## Inviting people [#inviting-people] Owners and admins invite by email address and role. The invitee receives an email with an accept link. * Invitations expire after **3 days**. * Expired invitations are cleaned up automatically each day. * A pending invitation can be cancelled from the team settings page. * Pending invitations addressed to you appear on your dashboard. ## Switching teams [#switching-teams] If you belong to several teams, switch the current team from the dashboard. The current team decides: * which quota and rate limit a new API key inherits, * which team owns screenshots captured from the dashboard, * which subscription the billing pages act on. ## Leaving and deleting [#leaving-and-deleting] * **Leave** removes your membership. If it was your current team, you are moved to another team you belong to. * **Delete** removes the team, its memberships and its pending invitations. Members whose current team it was are moved to their personal team. Both actions are restricted by policy: the last owner cannot simply walk away from a team that would be left without one. ## Teams and the API [#teams-and-the-api] An API key created while team A is current stays bound to team A even if you later switch to team B. To capture against another team's quota, switch teams and create a key there. ## Next [#next] # Async callbacks (/api/async-callbacks) Slow pages, batch jobs and anything you do not want to block on should use the async mode: pass `callback_url` and the API returns `202` immediately, then POSTs the result to your endpoint when the render finishes. ```bash curl -X POST https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/heavy-report", "format": "pdf", "network_idle": true, "callback_url": "https://your-app.com/hooks/ssnap" }' ``` ```json title="Immediate response (202)" { "status": "queued", "message": "Screenshot queued; the result will be delivered to the callback URL." } ``` `202` only means the job was accepted. Subscription, quota and url checks run when the job executes, so failures arrive at your webhook, never as the HTTP response. ## Callback requirements [#callback-requirements] `callback_url` passes the same public-host checks as `url`: `http`/`https` only, no loopback, private, link-local or reserved targets. See [URL restrictions](/platform/url-restrictions). The host is re-checked a second time at delivery, immediately before the POST, because a job may run long after it was validated and DNS can be repointed in between. ## Payloads [#payloads] Delivered as `POST` with `Content-Type: application/json`. ```json title="Success" { "status": "success", "url": "https://ssnap.cc/screenshots/0193ab…/file?expires=…&signature=…", "cached": false } ``` ```json title="Failure" { "status": "error", "error": "Monthly screenshot limit reached", "code": "quota_exceeded" } ``` The `url` is signed and valid for 24 hours, so download the file promptly if you need to keep it. `code` values are the same set listed in [API error codes](/api/errors). ## Signature headers [#signature-headers] | Header | Value | | ------------------- | -------------------------------------------------------------------------------------- | | `X-Ssnap-Timestamp` | Unix timestamp of the delivery attempt. | | `X-Ssnap-Signature` | `sha256=` + HMAC-SHA256 of `"."`, keyed with your webhook secret. | Verify against the **raw** request body, before any JSON parsing, and reject deliveries whose timestamp is far from now to blunt replays. Worked examples in [Verify webhook signatures](/guides/verify-webhooks). ## Retries and delivery guarantees [#retries-and-delivery-guarantees] * The capture job itself retries up to **3 times**, backing off 10s then 30s, with a 120-second timeout per attempt. * The webhook POST is retried up to **3 times**, 500 ms apart, with a 10-second timeout. * A delivery that still fails is logged and dropped. It does **not** re-run the capture, because a retry would consume a second quota slot for an image that already exists. Design your handler to be idempotent and to return `2xx` quickly; do the heavy work after acknowledging. ## Async vs sync [#async-vs-sync] | Aspect | Sync | Async | | ----------------------- | --------------------------------- | -------------------------------- | | Response | Bytes or signed url | `202` acknowledgement | | Good for | Interactive requests, small pages | Heavy pages, PDFs, batch jobs | | Failure reaches you as | HTTP status + `code` | Webhook with `"status": "error"` | | Needs a public endpoint | No | Yes | ## Next [#next] # Screenshot caching (/api/caching) Ssnap can serve a previously rendered capture instead of launching the browser again. Caching is **opt-in per request**: without `cache_ttl`, every call renders fresh. ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "cache_ttl=3600" \ --data-urlencode "response=url" ``` `cache_ttl` is measured in seconds, from `0` (disabled) up to `2592000` (30 days). ## What counts as identical [#what-counts-as-identical] A capture is reusable when it belongs to **your team**, succeeded, was created within `cache_ttl` seconds, still has its stored file on disk, and matches the cache key: > `sha256(url + device_id + output parameters)` The output parameters in that key are `theme`, `format`, `quality`, `device_id`, `width`, `height`, `full_page`, `network_idle`, `delay`, `selector`, `background`, `border_width`, `border_color`, `watermark_text`, `watermark_size`, `watermark_color`, `watermark_x`, `watermark_y`. Delivery options (`cache_ttl`, `response`, `callback_url`) are **not** part of the key. Asking for `response=url` can therefore hit a capture originally taken as binary bytes. ## Quota effect [#quota-effect] A cache hit does not consume a monthly screenshot slot. It is the cheapest way to keep a high-traffic thumbnail or OG image endpoint inside your plan. Rate limiting is unaffected: cache hits still count against your per-minute allowance, because they still cost a request. ## Detecting a hit [#detecting-a-hit] | Response mode | Where to look | | -------------- | ---------------------------------- | | Binary | `X-Screenshot-Cached: true` header | | `response=url` | `"cached": true` in the body | | Webhook | `"cached": true` in the payload | ## When a hit will not happen [#when-a-hit-will-not-happen] * `cache_ttl` is `0` or absent. * Any output-affecting parameter changed, including a `delay` or `quality` tweak. * The earlier capture failed, or was captured by a different team. * The stored file was pruned by [retention](/platform/retention), or is otherwise gone. A missing file is skipped rather than served, and a fresh render happens instead. ## Practical pattern [#practical-pattern] Pick a TTL matching how fast the target page changes: | Target | Suggested `cache_ttl` | | ----------------------------- | --------------------- | | Marketing page / OG image | `86400` (1 day) | | Dashboard or docs page | `3600` (1 hour) | | Live data, pricing tables | `300` (5 minutes) | | Anything that must be current | omit it | ## Next [#next] # Device emulation (/api/devices) Ssnap ships **131 device presets** mirroring Puppeteer's device list: phones, tablets and their landscape variants, each with a user agent and a screen size. Captures emulate the selected device. ## Choosing a device [#choosing-a-device] Pass `device_id`: ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "device_id=42" ``` The ids are assigned when the preset catalogue is imported. The current list, with names and dimensions, is shown in the device picker on the **Screenshots** page of your dashboard. Use it to find the id you want. Omitting `device_id` uses the server's configured default preset. An unknown id is rejected with `422` and code `invalid_device`. ## Preset families [#preset-families] | Family | Examples | | ------------- | --------------------------------------------------------------------------------- | | iPhone | iPhone 4 through iPhone 15 Pro Max, each with a landscape variant | | iPad | iPad, iPad Mini, iPad Pro, iPad Pro 11, gen 6 / gen 7 | | Galaxy | Galaxy S III, S5, S8, S9+, Note II, Note 3, Tab S4 | | Pixel / Nexus | Pixel 2, Pixel 2 XL, Nexus 4/5/5X/6/6P/7/10 | | Other | Kindle Fire HDX, Nokia Lumia, Microsoft Lumia, BlackBerry, JioPhone 2, LG Optimus | Each preset carries its own `type` (`mobile` or `tablet`), user agent and screen size. ## Overriding the viewport [#overriding-the-viewport] `width` and `height` are applied **after** the preset, so they override its dimensions: ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "width=1920" \ --data-urlencode "height=1080" ``` Pass **both** `width` and `height`. A lone dimension is ignored and the preset's size is used instead. Both accept 1–5000. The device's user agent still applies after a viewport override, so you get the preset UA plus custom dimensions. To capture a desktop-sized page, set an explicit `width`/`height`. ## Full page and devices [#full-page-and-devices] `full_page=true` captures the entire scroll height at the selected width, so the device preset controls the width and the page controls the height. See [Full page and element captures](/guides/full-page-and-elements). ## Rendering environment [#rendering-environment] Every capture runs in headless Chromium with: * locale `en-GB`, * a 30-second render timeout, * dialogs auto-dismissed, so `alert()` or cookie `confirm()` popups cannot hang a render, * ad, analytics and Google Fonts requests blocked (`googlesyndication.com`, `google-analytics.com`, `fonts.googleapis.com`) to cut render time and noise. Because Google Fonts is blocked at request level, a page relying solely on it renders in fallback fonts. Self-hosted fonts are unaffected. ## Next [#next] # API error codes (/api/errors) Failures are JSON. The `error` string is for humans and may be reworded; branch your code on `code`, which is stable. ```json { "error": "Rate limit exceeded", "code": "rate_limit_exceeded" } ``` Error bodies are JSON whatever `format` you asked for. Check the status code before writing a response body to disk, or you will save an error object as `.png`. ## All error codes [#all-error-codes] | Status | `code` | Cause | Retry? | | ------ | ------------------------------------------------- | --------------------------------------------------------- | ------------------------ | | 401 | [`missing_api_key`](#missing_api_key) | No bearer token and no `api_key` parameter. | No | | 401 | [`invalid_api_key`](#invalid_api_key) | No key matches the value sent. | No | | 403 | [`api_key_inactive`](#api_key_inactive) | The key has been deactivated. | No | | 403 | [`api_key_expired`](#api_key_expired) | The key is past its `expires_at`. | No | | 402 | [`subscription_required`](#subscription_required) | The owning team has no active subscription. | No | | 429 | [`rate_limit_exceeded`](#rate_limit_exceeded) | Too many requests this minute for this key. | Yes, after `Retry-After` | | 429 | [`quota_exceeded`](#quota_exceeded) | The team's monthly allowance is used up. | No | | 422 | [`invalid_device`](#invalid_device) | `device_id` does not match a known preset. | No | | 422 | [`invalid_url`](#invalid_url) | The target is not publicly reachable, or did not respond. | No | | 500 | [`capture_failed`](#capture_failed) | The render or post-processing failed. | Yes, with backoff | | 422 | [validation errors](#validation-errors) | A parameter failed validation. | No | ## `missing_api_key` [#missing_api_key] **401.** The request carried neither an `Authorization: Bearer` header nor an `api_key` parameter. The usual cause is a proxy or client library stripping unknown headers. If you cannot control the headers, the `api_key` query parameter is equivalent — see [API authentication](/authentication). ## `invalid_api_key` [#invalid_api_key] **401.** A key was sent, but no key matches it. Check for truncation first: keys are long, and copy-paste out of a terminal wraps them. Then check you are not sending a key that has been deleted — Ssnap stores only a SHA-256 hash, so a deleted key is unrecoverable and a new one has to be created. ## `api_key_inactive` [#api_key_inactive] **403.** The key exists and matches, but has been deactivated in the dashboard. Reactivate it under **API keys**, or create a replacement. Deactivation is reversible; deletion is not. ## `api_key_expired` [#api_key_expired] **403.** The key is past the `expires_at` date set when it was created. Expiry is checked on every request, so this appears the instant the date passes, mid-run if necessary. Create a new key. If your keys keep expiring unexpectedly, rotate on a schedule that is shorter than the expiry, not equal to it. ## `subscription_required` [#subscription_required] **402.** The team that owns the key has no active subscription — either it never had one, or a payment failed. Captures are gated on an active subscription, so this affects every key in the team at once. Resolve it in the billing portal; see [Plans and billing](/account/plans-and-billing). ## `rate_limit_exceeded` [#rate_limit_exceeded] **429.** Too many requests in the last rolling 60 seconds for this **API key**. This one is temporary. `Retry-After` gives the seconds until the window resets, usually under a minute. Every response also carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`, so you can throttle before you get here rather than after. Cache hits still count against this limit — they cost a request even though they cost no quota. Details in [Rate limits and quotas](/api/rate-limits-and-quotas). ## `quota_exceeded` [#quota_exceeded] **429.** The **team's** monthly screenshot allowance is used up. Same status code as the rate limit, opposite handling: this does not clear until the billing period rolls over, so retrying is pure waste. Branch on `code`, not on the status. Three ways out, in order of effort: upgrade the plan, use `cache_ttl` so repeat captures stop consuming slots ([Screenshot caching](/api/caching)), or wait for the period to roll over. ## `invalid_device` [#invalid_device] **422.** `device_id` does not match any of the 131 presets. Preset ids are integers and the list is fixed — see [Device emulation](/api/devices). If you only need a specific viewport rather than a specific user agent, drop `device_id` and pass `width` **and** `height` instead. A lone `width` or `height` is ignored. ## `invalid_url` [#invalid_url] **422.** The target is not a publicly routable `http`/`https` address, resolves to a private or internal address, or did not respond. The same check applies to `callback_url`, so a webhook endpoint on `localhost` or a private VPC address is refused at request time. Local development therefore needs a tunnel. Full rules in [URL restrictions](/platform/url-restrictions). This does **not** consume quota — the reserved slot is released. ## `capture_failed` [#capture_failed] **500.** The render or the post-processing failed. The common causes, in rough order of frequency: * **A `selector` that matched nothing.** There is no fallback to the viewport; the render fails. Verify the selector against the live page, and remember it is applied after rendering, so an element created later by JavaScript needs `network_idle` or a `delay`. * **A timeout.** The overall render budget is 30 seconds, and a long `delay` eats into it — a 25-second delay on a slow page will time out here. * **The page itself crashed the renderer**, usually a very large full-page capture. Worth retrying once or twice with backoff; transient render failures happen. If it persists for a url that loads fine in your own browser, check [status](https://ssnap.cc/status) and send the full parameter set to support. Failed captures release their reserved slot, so this does not consume quota. ## Validation errors [#validation-errors] **422**, but a different shape. Parameter validation runs before any browser work, and returns Laravel's standard body: ```json title="422" { "message": "The selector field must not be greater than 255 characters.", "errors": { "selector": ["The selector field must not be greater than 255 characters."] } } ``` The `errors` map is keyed by parameter name. There is no `code` field here — the presence of `errors` is how you tell the two kinds of `422` apart: ```js const body = await response.json(); const reason = body.code ?? Object.keys(body.errors ?? {}).join(', '); ``` Ranges and types for every parameter are in [Capture parameters](/api/parameters). ## Two `429`s, two meanings [#two-429s-two-meanings] Worth restating because it is the single most common integration bug: * `rate_limit_exceeded` is temporary. Wait `Retry-After` and retry. * `quota_exceeded` lasts until the billing period rolls over. Retrying will not help, and the retries themselves burn your rate limit. ## Quota is not charged for failures [#quota-is-not-charged-for-failures] A capture that fails mid-render releases its reserved slot, so `capture_failed` and `invalid_url` do not consume quota. Cache hits do not consume quota either. ## Async errors [#async-errors] When `callback_url` is set, the HTTP call returns `202` and any error is delivered to your webhook instead of in the response: ```json { "status": "error", "error": "Invalid url", "code": "invalid_url" } ``` The `code` values are identical to the table above. This includes the quota and subscription checks, which run when the job executes, not when it is queued — so a request that returned `202` can still end in `quota_exceeded`. See [Async callbacks](/api/async-callbacks). ## Retry guidance [#retry-guidance] | Code | Retry? | | -------------------------------------------------------------------------- | ------------------------------------------------------------------- | | `capture_failed` | Yes, once or twice, with backoff. Transient render failures happen. | | `rate_limit_exceeded` | Yes, after `Retry-After`. | | `quota_exceeded` | No, not until the billing period rolls over. | | `invalid_url`, `invalid_device`, validation | No, fix the request. | | `missing_api_key`, `invalid_api_key`, `api_key_*`, `subscription_required` | No, fix credentials or billing. | # Capture parameters (/api/parameters) All parameters work identically on `GET` (query string) and `POST` (JSON body). Only `url` is required. ## Target [#target] | Parameter | Type | Default | Notes | | --------- | ------------ | ------- | --------------------------------------------------------------------------------------------------------------------- | | `url` | string (uri) | none | **Required.** Must be a publicly routable `http`/`https` address. See [URL restrictions](/platform/url-restrictions). | ## Output [#output] | Parameter | Type | Default | Notes | | ---------- | ---------------------------------- | -------- | --------------------------------------------------------------- | | `format` | `jpeg` \| `png` \| `webp` \| `pdf` | `jpeg` | `pdf` skips image post-processing and ignores `selector`. | | `quality` | integer 1–100 | `80` | Applies to `jpeg` and `webp`. `png` is lossless and ignores it. | | `response` | `binary` \| `url` | `binary` | `url` returns a signed link valid 24 hours. | ## Viewport and device [#viewport-and-device] | Parameter | Type | Default | Notes | | ----------- | ----------------- | --------------------- | --------------------------------------------------------- | | `device_id` | integer | server default preset | A device preset id. See [Device emulation](/api/devices). | | `width` | integer 1–5000 | from preset | Applied only together with `height`. | | `height` | integer 1–5000 | from preset | Applied only together with `width`. | | `theme` | `light` \| `dark` | `light` | Emulates `prefers-color-scheme` for the page. | `width` and `height` are applied **after** the device preset, so an explicit viewport overrides the preset's dimensions. Pass both; a lone `width` or `height` is ignored. The device's user agent still applies. ## Capture behaviour [#capture-behaviour] | Parameter | Type | Default | Notes | | -------------- | --------------- | ------- | ------------------------------------------------------------------------------ | | `full_page` | boolean | `false` | Captures the whole scroll height instead of the viewport. | | `selector` | string ≤255 | none | CSS selector to clip to. Takes precedence over `full_page`; ignored for `pdf`. | | `network_idle` | boolean | `false` | Waits for network activity to settle before capturing. | | `delay` | integer 0–30000 | `0` | Extra milliseconds to wait after load. | `selector` and `full_page` are mutually exclusive: when both are given on an image format, the selector wins and the page is clipped to that element. ## Image styling [#image-styling] Applied after the render, to image formats only. See [Backgrounds, borders and watermarks](/guides/image-styling). | Parameter | Type | Default | Notes | | ----------------- | -------------- | -------- | ----------------------------------------------------- | | `background` | hex colour | none | Six hex digits, `#` optional (`#ffffff` or `ffffff`). | | `border_width` | integer 1–200 | none | Border expands the image rather than cropping it. | | `border_color` | hex colour | `000000` | Only used when `border_width` is set. | | `watermark_text` | string ≤255 | none | Enables the watermark. | | `watermark_size` | integer 6–200 | `24` | Font size in points. | | `watermark_color` | hex colour | `ffffff` | | | `watermark_x` | integer 0–5000 | `20` | Offset from the left edge, in pixels. | | `watermark_y` | integer 0–5000 | `20` | Offset from the top edge, in pixels. | ## Delivery [#delivery] | Parameter | Type | Default | Notes | | -------------- | ----------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `cache_ttl` | integer 0–2592000 | `0` | Seconds an identical earlier capture may be reused. A cache hit does not consume quota. | | `callback_url` | string (uri) | none | When set, the capture is queued and the result POSTed here; the request returns `202`. Subject to the same public-host checks as `url`. | ## Which parameters affect the cache key [#which-parameters-affect-the-cache-key] The cache key is a hash of the target url, the device, and these output-affecting parameters: `theme`, `format`, `quality`, `device_id`, `width`, `height`, `full_page`, `network_idle`, `delay`, `selector`, `background`, `border_width`, `border_color`, `watermark_text`, `watermark_size`, `watermark_color`, `watermark_x`, `watermark_y`. `cache_ttl`, `response` and `callback_url` are delivery options and do **not** change the key. See [Screenshot caching](/api/caching). ## Validation [#validation] Parameters are validated before any browser work happens. A violation returns `422` with Laravel's standard validation error body: ```json { "message": "The url field is required.", "errors": { "url": ["The url field is required."] } } ``` ## Next [#next] # Rate limits and quotas (/api/rate-limits-and-quotas) Two independent limits apply to every capture. | Limit | Scope | Window | Exceeded response | | ---------------- | --------------- | ---------------------- | --------------------------- | | Rate limit | Per **API key** | Rolling 60 seconds | `429` `rate_limit_exceeded` | | Screenshot quota | Per **team** | Monthly billing period | `429` `quota_exceeded` | ## Rate limits [#rate-limits] Each key is throttled at its plan's per-minute allowance. Two keys on the same team each get the full allowance: throttling is per key, quota is per team. | Plan | Requests / minute | | ---------- | ----------------- | | Basic | 60 | | Pro | 300 | | Enterprise | 1,000 | Every response carries the current state: ```http X-RateLimit-Limit: 300 X-RateLimit-Remaining: 288 X-RateLimit-Reset: 1758196800 Retry-After: 37 ``` | Header | Meaning | | ----------------------- | -------------------------------------- | | `X-RateLimit-Limit` | Your plan's per-minute allowance. | | `X-RateLimit-Remaining` | Requests left in the current window. | | `X-RateLimit-Reset` | Unix timestamp when the window resets. | | `Retry-After` | Seconds until then. | Cache hits and failed captures still count here, because they are requests. ## Monthly quota [#monthly-quota] | Plan | Screenshots / month | | ---------- | ------------------- | | Basic | 5,000 | | Pro | 50,000 | | Enterprise | 150,000 | The counter covers **all captures by the team**, from every API key and from the dashboard's quick-generate form. What is *not* counted: * **Cache hits.** A capture served under `cache_ttl` never reserves a slot. * **Failed captures.** A render that errors releases its reserved slot. The quota is enforced under a lock as the capture slot is reserved, so parallel requests cannot overshoot the limit. ## When the period resets [#when-the-period-resets] The billing period is derived from your subscription's start date, not from the calendar month: a subscription started on the 12th resets on the 12th of each month. Current usage and period end are shown on your dashboard. ## The 80% warning [#the-80-warning] When a capture pushes your team past **80%** of its monthly allowance, the team owner is notified by email, once per billing period, so a busy month does not turn into a mailbox full of warnings. ## Staying inside the limits [#staying-inside-the-limits] The highest-leverage change is [caching](/api/caching). A `cache_ttl` on repeat targets removes both quota consumption and render latency. * Cache aggressively for pages that change slowly. * Read `X-RateLimit-Remaining` and pace your workers instead of retrying into a wall. * Honour `Retry-After` with exponential backoff on `rate_limit_exceeded`. * Use [async callbacks](/api/async-callbacks) for batch work so slow renders do not pin your own request threads. * Treat `quota_exceeded` as terminal for the period: alert rather than retry. ## Next [#next] # API response formats (/api/responses) The capture endpoint answers in one of three shapes, chosen by your parameters. ## Binary (default) [#binary-default] With no `response` or `callback_url`, the body is the rendered file itself. ```http HTTP/1.1 200 OK Content-Type: image/jpeg Content-Disposition: inline; filename="media_9f3c….jpg" Cache-Control: private, max-age=0, no-store X-Screenshot-Cached: false X-RateLimit-Limit: 300 X-RateLimit-Remaining: 299 ``` | Header | Meaning | | --------------------- | ------------------------------------------------------------- | | `Content-Type` | `image/jpeg`, `image/png`, `image/webp` or `application/pdf`. | | `Content-Disposition` | `inline`, with the generated filename. | | `X-Screenshot-Cached` | `true` when served from an earlier identical capture. | Check the status code before writing the body to disk. Error responses are JSON, so a blind `--output` will happily save an error object with an image file extension. ## Signed url (`response=url`) [#signed-url-responseurl] ```json { "url": "https://ssnap.cc/screenshots/0193ab…/file?expires=1758283200&signature=8f2c…", "cached": true } ``` The link is signed and expires **24 hours** after the response. It needs no API key, so it can be handed to a browser or embedded in an email, but treat it as a secret while it lives. Fetch and re-host the file if you need it for longer; stored captures are also subject to [retention](/platform/retention). An invalid or expired signature returns `403`; a capture whose file has been pruned returns `404`. ## Queued (`callback_url` set) [#queued-callback_url-set] ```http HTTP/1.1 202 Accepted ``` ```json { "status": "queued", "message": "Screenshot queued; the result will be delivered to the callback URL." } ``` Nothing is rendered yet at this point: quota and subscription checks happen when the job runs, so a queued request can still end in an error, delivered as a webhook, not as an HTTP response. See [Async callbacks](/api/async-callbacks). ## Errors [#errors] Every failure is JSON with a human-readable `error` and a stable `code`: ```json { "error": "Monthly screenshot limit reached", "code": "quota_exceeded" } ``` Validation failures use Laravel's standard shape instead, with a `message` and a per-field `errors` map. Both are covered in [API error codes](/api/errors). ## Next [#next] # Screenshot API endpoint (/api/screenshot) ```http GET https://ssnap.cc/api/v1/screenshot POST https://ssnap.cc/api/v1/screenshot ``` Both verbs hit the same action and accept the same parameters. `GET` keeps simple integrations to a single url; `POST` keeps long parameters (CSS selectors, watermark text) out of url length limits and access logs. * `GET` takes parameters in the query string. * `POST` takes parameters as a JSON body (`Content-Type: application/json`) or as form fields. Authentication is required on every call. See [Authentication](/authentication). ## Minimal request [#minimal-request] ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --output shot.jpg ``` `url` is the only required parameter. Everything else falls back to a default: `jpeg`, quality `80`, light theme, the configured default device preset, viewport-sized (not full page), no delay. ## Full request [#full-request] ```bash curl -X POST https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/pricing", "format": "png", "theme": "dark", "width": 1440, "height": 900, "full_page": true, "network_idle": true, "delay": 500, "border_width": 12, "border_color": "#111111", "watermark_text": "© Example", "watermark_size": 24, "watermark_color": "#ffffff", "watermark_x": 30, "watermark_y": 30, "cache_ttl": 3600, "response": "url" }' ``` ## Response modes [#response-modes] | Mode | Trigger | Status | Body | | ---------- | ------------------ | ------ | -------------------------------------- | | Binary | default | `200` | The image or PDF bytes | | Signed url | `response=url` | `200` | `{ "url": …, "cached": … }` | | Queued | `callback_url` set | `202` | `{ "status": "queued", "message": … }` | `callback_url` wins over `response`: when it is present the capture is always queued and delivered to your webhook. Details in [API response formats](/api/responses) and [Async callbacks](/api/async-callbacks). ## Status codes [#status-codes] | Status | When | | ------ | ------------------------------------------------------ | | `200` | Capture succeeded (fresh or cached). | | `202` | Capture queued for webhook delivery. | | `401` | Missing or invalid API key. | | `402` | The team has no active subscription. | | `403` | The key is inactive or expired. | | `422` | Validation failed, or the url or device was rejected. | | `429` | Per-minute rate limit hit, or monthly quota exhausted. | | `500` | The render itself failed. | Every non-`2xx` body carries a stable `code`. See [API error codes](/api/errors). ## Rate limit headers [#rate-limit-headers] Responses carry the current throttle state: ```http X-RateLimit-Limit: 300 X-RateLimit-Remaining: 297 X-RateLimit-Reset: 1758196800 Retry-After: 41 ``` See [Rate limits and quotas](/api/rate-limits-and-quotas). ## Next [#next] # Code examples (/guides/code-examples) Every example captures `https://example.com` and saves the result. Keep your key in an environment variable, never in source control. Each snippet here is the short version. The three most-used languages have a full guide covering streaming, batching, queued renders and the failure modes worth handling: ## cURL [#curl] ```bash title="Save the bytes" curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "format=png" \ --data-urlencode "full_page=true" \ --output example.png ``` ```bash title="Get a signed link" curl -X POST https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://example.com","response":"url","cache_ttl":3600}' ``` ## Node.js [#nodejs] ```js title="capture.mjs" import { writeFile } from 'node:fs/promises'; const response = await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', format: 'png', full_page: true, cache_ttl: 3600, }), }); if (!response.ok) { // Errors are always JSON, whatever the requested output format. const { error, code } = await response.json(); throw new Error(`ssnap ${response.status} ${code}: ${error}`); } console.log('cached:', response.headers.get('x-screenshot-cached')); await writeFile('example.png', Buffer.from(await response.arrayBuffer())); ``` ```js title="Signed url instead of bytes" const response = await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', response: 'url' }), }); const { url, cached } = await response.json(); ``` ## PHP [#php] ```php title="capture.php" post('https://ssnap.cc/api/v1/screenshot', [ 'url' => 'https://example.com', 'format' => 'png', 'full_page' => true, 'cache_ttl' => 3600, ]); if ($response->failed()) { throw new RuntimeException($response->json('code') . ': ' . $response->json('error')); } file_put_contents('example.png', $response->body()); ``` ```php title="Without a framework" true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('SSNAP_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'url' => 'https://example.com', 'format' => 'png', ]), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status !== 200) { throw new RuntimeException($body); } file_put_contents('example.png', $body); ``` ## Python [#python] ```python title="capture.py" import os import requests response = requests.post( "https://ssnap.cc/api/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"}, json={ "url": "https://example.com", "format": "png", "full_page": True, "cache_ttl": 3600, }, timeout=60, ) if response.status_code != 200: payload = response.json() raise RuntimeError(f"ssnap {payload.get('code')}: {payload.get('error')}") with open("example.png", "wb") as file: file.write(response.content) ``` ## Go [#go] ```go title="capture.go" package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "url": "https://example.com", "format": "png", "full_page": true, }) req, _ := http.NewRequest("POST", "https://ssnap.cc/api/v1/screenshot", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("SSNAP_API_KEY")) req.Header.Set("Content-Type", "application/json") res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { panic(fmt.Sprintf("ssnap returned %d", res.StatusCode)) } file, _ := os.Create("example.png") defer file.Close() file.ReadFrom(res.Body) } ``` ## Ruby [#ruby] ```ruby title="capture.rb" require "net/http" require "json" uri = URI("https://ssnap.cc/api/v1/screenshot") request = Net::HTTP::Post.new(uri) request["Authorization"] = "Bearer #{ENV.fetch('SSNAP_API_KEY')}" request["Content-Type"] = "application/json" request.body = { url: "https://example.com", format: "png", full_page: true }.to_json response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) end raise response.body unless response.code == "200" File.binwrite("example.png", response.body) ``` ## Handling rate limits [#handling-rate-limits] ```js title="Back off on 429" async function capture(params, attempt = 0) { const response = await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(params), }); if (response.status === 429) { const { code } = await response.clone().json(); // A monthly quota does not reset within a retry window, so do not loop on it. if (code === 'quota_exceeded' || attempt >= 3) return response; const wait = Number(response.headers.get('retry-after') ?? 5); await new Promise((resolve) => setTimeout(resolve, wait * 1000)); return capture(params, attempt + 1); } return response; } ``` See [API error codes](/api/errors) for which codes are worth retrying, and [Rate limits and quotas](/api/rate-limits-and-quotas) for the two limits this guards against. # Full page and element captures (/guides/full-page-and-elements) Three capture modes, chosen by two parameters. | Mode | Parameters | Result | | ------------------ | ---------------- | --------------------------------------------------------- | | Viewport (default) | none | Exactly the visible area of the chosen device or viewport | | Full page | `full_page=true` | The whole scroll height at the chosen width | | Element | `selector=` | Only the matched element, cropped tight | ## Viewport [#viewport] The default. The image matches the device preset's screen size, or your `width`/`height` override. ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "width=1280" \ --data-urlencode "height=720" \ --output viewport.jpg ``` ## Full page [#full-page] ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "full_page=true" \ --data-urlencode "network_idle=true" \ --output full.jpg ``` Width comes from the viewport; height is the document's full scroll height. Lazy-loaded pages are the usual disappointment here: images below the fold may never load because nothing scrolls. Pair `full_page` with `network_idle=true` and a `delay` of a few hundred milliseconds. ## A single element [#a-single-element] ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com/pricing" \ --data-urlencode "selector=#pricing-table" \ --output element.png ``` `selector` accepts any CSS selector up to 255 characters, and the first match is used. Good targets: `#id`, `.card`, `main > section:first-of-type`, `[data-testid="chart"]`. ### Rules that bite [#rules-that-bite] * **`selector` beats `full_page`.** If both are set on an image format, the element wins. * **`selector` is ignored for `pdf`.** PDFs always render the document. * **A selector that matches nothing fails the render.** You get `500` with `capture_failed`. * An element hidden behind a cookie banner or an overlay is captured as it appears on screen, overlay included. ## Timing controls [#timing-controls] Both apply to every mode. | Parameter | Effect | | ------------------- | ------------------------------------------------------------------------------------------------ | | `network_idle=true` | Waits for network activity to settle (tolerating up to two in-flight requests) before capturing. | | `delay=` | Waits this many milliseconds after load, 0–30000. | Use `network_idle` for data-driven pages, `delay` for animations and transitions. They combine: idle first, then the delay. ```bash --data-urlencode "network_idle=true" \ --data-urlencode "delay=1000" ``` The overall render timeout is 30 seconds. A long `delay` eats into it, so a 25-second delay on a slow page will time out and return `capture_failed`. ## Dark mode [#dark-mode] `theme=dark` emulates `prefers-color-scheme: dark`, so pages with a media-query dark theme render dark. Pages that switch themes with a stored user preference or a toggle class will not follow it. ```bash --data-urlencode "theme=dark" ``` ## Next [#next] # Backgrounds, borders and watermarks (/guides/image-styling) Three post-capture manipulations are applied server-side, to image formats only. PDFs are returned untouched, and a request with none of these parameters skips post-processing entirely. Colours are six hex digits, with an optional leading `#`: `#1a1a1a` and `1a1a1a` are the same value. Three-digit shorthand and named colours are rejected with `422`. ## Background [#background] Fills transparent areas. Useful for PNG or WebP captures of pages with a transparent body. ```bash --data-urlencode "background=#ffffff" ``` ## Border [#border] ```bash --data-urlencode "border_width=16" \ --data-urlencode "border_color=#111111" ``` | Parameter | Range | Default | | -------------- | ---------- | -------- | | `border_width` | 1–200 px | none | | `border_color` | hex colour | `000000` | The border **expands** the image rather than cropping into it: a 1280×720 capture with `border_width=16` comes back 1312×752. `border_color` alone does nothing; the width is what enables the border. ## Watermark [#watermark] ```bash --data-urlencode "watermark_text=© Example Ltd" \ --data-urlencode "watermark_size=28" \ --data-urlencode "watermark_color=#ffffff" \ --data-urlencode "watermark_x=40" \ --data-urlencode "watermark_y=40" ``` | Parameter | Range | Default | | ----------------- | ---------- | -------- | | `watermark_text` | ≤255 chars | none | | `watermark_size` | 6–200 | `24` | | `watermark_color` | hex colour | `ffffff` | | `watermark_x` | 0–5000 | `20` | | `watermark_y` | 0–5000 | `20` | `watermark_x` and `watermark_y` are pixel offsets from the **top-left** corner of the image. To place text near the bottom of a known-height capture, set an explicit `height` and compute the offset yourself, because coordinates are absolute rather than anchored. Position watermarks with care on `full_page` captures: the image height depends on the page's scroll height, so a fixed `watermark_y` may land anywhere. Watermark viewport-sized or `selector` captures when placement matters. ## Combining them [#combining-them] Order of application is background, then border, then watermark, so a watermark can sit on top of the border area. ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "format=png" \ --data-urlencode "width=1200" \ --data-urlencode "height=630" \ --data-urlencode "background=#0b0b0f" \ --data-urlencode "border_width=12" \ --data-urlencode "border_color=#2b2b35" \ --data-urlencode "watermark_text=example.com" \ --data-urlencode "watermark_size=22" \ --data-urlencode "watermark_color=#8f8fa3" \ --output og-image.png ``` ## Styling and the cache [#styling-and-the-cache] All styling parameters are part of the [cache key](/api/caching). Changing a watermark colour produces a fresh render and consumes a quota slot, so settle on your styling before warming a cache. ## Next [#next] # HTML to PDF API (/guides/pdf-export) Set `format=pdf` and the capture returns `application/pdf` bytes. ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://example.com/invoice/123" \ --data-urlencode "format=pdf" \ --data-urlencode "network_idle=true" \ --output invoice.pdf ``` ## What changes in PDF mode [#what-changes-in-pdf-mode] | Parameter | In PDF mode | | --------------------------------------- | --------------------------------------------------------------------------------------------- | | `selector` | Ignored; the document is always rendered whole. | | `full_page` | Not applicable; PDF output paginates the document. | | `quality` | Ignored; PDF is not a lossy raster format. | | `background`, `border_*`, `watermark_*` | Ignored; image post-processing is skipped for PDFs. | | `theme` | Applies. `dark` emulates `prefers-color-scheme: dark`. | | `width` / `height`, `device_id` | Apply; they set the rendering viewport, which drives responsive layout and CSS media queries. | | `delay`, `network_idle` | Apply. | Watermarks and borders are image operations. If you need a watermark on a PDF, put it in the page's own CSS (`position: fixed` works well with print styles) rather than in the API call. ## Print stylesheets [#print-stylesheets] The page is rendered by Chromium, so its `@media print` rules apply. That is the place to control page breaks, hide navigation, and set margins: ```css @media print { nav, .cookie-banner { display: none; } .invoice-section { break-inside: avoid; } } ``` ## Async for heavy documents [#async-for-heavy-documents] Long reports are the classic case for [async callbacks](/api/async-callbacks): the render runs in the background with a 120-second job timeout, and your request returns at once. ```bash curl -X POST https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/reports/2026-q1", "format": "pdf", "network_idle": true, "delay": 1500, "callback_url": "https://your-app.com/hooks/ssnap" }' ``` ## Caching PDFs [#caching-pdfs] `cache_ttl` works the same way for PDFs as for images, and a cache hit still skips the quota charge. Since `format` is part of the cache key, a PDF and a PNG of the same page are cached separately. See [Screenshot caching](/api/caching). ## Next [#next] # Node.js screenshot API (/guides/screenshot-api-nodejs) Taking a screenshot from Node.js without a screenshot API means shipping Puppeteer, a Chromium binary and a few hundred megabytes of container image, then keeping that browser alive and patched. Ssnap replaces that with one `fetch` call. Everything below runs on built-in Node 18+ APIs. No SDK, no dependencies. ## The minimal call [#the-minimal-call] ```js title="capture.mjs" import { writeFile } from 'node:fs/promises'; const response = await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', format: 'png' }), }); await writeFile('example.png', Buffer.from(await response.arrayBuffer())); ``` That works, and it is also the version that will quietly write a JSON error object into a file named `.png`. Read the next section before shipping it. ## Check the status first [#check-the-status-first] The body of a successful capture *is* the image. The body of a failed one is JSON, whatever `format` you asked for. Nothing in the bytes tells you which you got, so branch on the status code: ```js title="capture.mjs" if (!response.ok) { const { error, code } = await response.json(); throw new Error(`ssnap ${response.status} ${code}: ${error}`); } ``` `code` is the stable identifier; `error` is prose and may be reworded. Full list in [API error codes](/api/errors). ## Streaming to disk [#streaming-to-disk] `arrayBuffer()` holds the whole render in memory. For full-page captures of long documents, pipe instead: ```js title="stream.mjs" import { createWriteStream } from 'node:fs'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; const response = await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', full_page: true, format: 'png' }), }); if (!response.ok) throw new Error(await response.text()); await pipeline(Readable.fromWeb(response.body), createWriteStream('full.png')); ``` ## Getting a link instead of bytes [#getting-a-link-instead-of-bytes] Pass `response: 'url'` and you get a signed link back, valid for 24 hours, that needs no API key. Useful when the file is going straight into an email or an `` and you do not want it passing through your own server: ```js const response = await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', response: 'url', cache_ttl: 3600 }), }); const { url, cached } = await response.json(); ``` The link expires. If you need the image for longer than a day, fetch it and re-host it — and remember stored captures are pruned on the schedule in [Storage and retention](/platform/retention). ## Don't block a request on a render [#dont-block-a-request-on-a-render] A synchronous capture holds your Node process for as long as Chromium takes, and the render timeout is 30 seconds. Inside an HTTP handler that is an easy way to exhaust your own connection pool. Two ways out. Either move the call into a job queue, or let Ssnap queue it for you with `callback_url`: the API answers `202` at once and POSTs the finished capture to your endpoint. ```js title="Queue it" await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com/report', format: 'pdf', network_idle: true, callback_url: 'https://your-app.com/hooks/ssnap', }), }); ``` The webhook is signed. Verify it before trusting the payload — see [Verify webhook signatures](/guides/verify-webhooks). ## Retrying without making it worse [#retrying-without-making-it-worse] Both throttles answer `429`, and they need opposite handling: the per-minute rate limit clears in under a minute, the monthly quota does not clear until your billing period rolls over. Looping on `quota_exceeded` just burns your rate limit too. ```js title="backoff.mjs" export async function capture(params, attempt = 0) { const response = await fetch('https://ssnap.cc/api/v1/screenshot', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SSNAP_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(params), }); if (response.status === 429) { const { code } = await response.clone().json(); if (code === 'quota_exceeded' || attempt >= 3) return response; const wait = Number(response.headers.get('retry-after') ?? 5); await new Promise((resolve) => setTimeout(resolve, wait * 1000)); return capture(params, attempt + 1); } return response; } ``` `X-RateLimit-Remaining` on every response tells you how close you are before you get there. See [Rate limits and quotas](/api/rate-limits-and-quotas). ## Things that catch people out [#things-that-catch-people-out] * **Lazy-loaded images come back blank on `full_page`.** Nothing scrolls the page, so intersection observers never fire. Pair `full_page: true` with `network_idle: true` and a `delay` of 300–1000 ms. * **A `selector` that matches nothing fails the whole render** with `500` and `capture_failed`, rather than falling back to the viewport. * **`width` alone is ignored.** The viewport override only applies when both `width` and `height` are present. * **Cache hits are free, repeats are not.** Identical parameters plus a `cache_ttl` skip the quota charge entirely; without `cache_ttl` every call renders and bills. See [Screenshot caching](/api/caching). * **Never put the key in client-side JavaScript.** A browser-visible bearer token is a public token. Call from your server, or hand out signed urls. ## Next [#next] # PHP screenshot API (/guides/screenshot-api-php) PHP has no way to drive a headless browser in-process, so the usual answer is shelling out to `wkhtmltoimage` or running a Node sidecar. Both mean binaries on your servers. An HTTP call does not. Examples use Laravel's `Http` client first, then plain cURL for everything else. ## Laravel [#laravel] ```php title="app/Services/Ssnap.php" use Illuminate\Support\Facades\Http; $response = Http::withToken(config('services.ssnap.key')) ->timeout(60) ->post('https://ssnap.cc/api/v1/screenshot', [ 'url' => 'https://example.com', 'format' => 'png', 'full_page' => true, 'cache_ttl' => 3600, ]); if ($response->failed()) { throw new RuntimeException($response->json('code').': '.$response->json('error')); } Storage::disk('local')->put('example.png', $response->body()); ``` Set the timeout explicitly. The default in Guzzle is short enough that a slow page plus a `delay` will abort client-side while the render is still going — and a capture aborted at your end has still been taken and still counts against quota. Put the key in `config/services.php`, never inline: ```php title="config/services.php" 'ssnap' => [ 'key' => env('SSNAP_API_KEY'), ], ``` ## Without a framework [#without-a-framework] ```php title="capture.php" true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer '.getenv('SSNAP_API_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'url' => 'https://example.com', 'format' => 'png', ]), ]); $body = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($status !== 200) { // On failure the body is JSON, not an image, whatever `format` you asked for. $error = json_decode($body, true); throw new RuntimeException($error['code'].': '.$error['error']); } file_put_contents('example.png', $body); ``` ## Streaming straight to a file [#streaming-straight-to-a-file] For full-page captures, write as the bytes arrive instead of holding the whole render in a PHP string: ```php title="stream.php" 'https://example.com', 'format' => 'png', 'full_page' => 'true', 'network_idle' => 'true', ])); curl_setopt_array($ch, [ CURLOPT_HTTPHEADER => ['Authorization: Bearer '.getenv('SSNAP_API_KEY')], CURLOPT_FILE => $file, CURLOPT_TIMEOUT => 60, ]); curl_exec($ch); curl_close($ch); fclose($file); ``` Note the `GET` form: every parameter works identically as a query string or a JSON body. ## Queue the render, don't hold the request [#queue-the-render-dont-hold-the-request] A synchronous capture inside a controller blocks a PHP-FPM worker for the whole render. Under any real traffic that is how you run out of workers. Either dispatch your own job, or hand the queueing to Ssnap with `callback_url` — the API answers `202` immediately and POSTs the finished capture to your endpoint, with a 120-second job budget instead of the synchronous 30-second render timeout. ```php title="Queue it" Http::withToken(config('services.ssnap.key')) ->post('https://ssnap.cc/api/v1/screenshot', [ 'url' => route('invoices.print', $invoice), 'format' => 'pdf', 'network_idle' => true, 'callback_url' => route('webhooks.ssnap'), ]); ``` ## Receiving the webhook in Laravel [#receiving-the-webhook-in-laravel] The callback endpoint is public by definition, so verify the signature before you trust the payload. The scheme is in [Verify webhook signatures](/guides/verify-webhooks). ```php title="routes/web.php" Route::post('/hooks/ssnap', function (Request $request) { // Signature verification first — see the webhook guide for the exact header // and hashing scheme, and use hash_equals rather than ===. if ($request->input('status') === 'error') { report(new RuntimeException($request->input('code'))); return response()->noContent(); } ProcessCapture::dispatch($request->input('url')); return response()->noContent(); })->withoutMiddleware(VerifyCsrfToken::class); ``` CSRF must be excluded: the POST comes from Ssnap, not from a session. ## Invoices and reports [#invoices-and-reports] PDF is the common PHP case, and it works the same way — `format=pdf`, with the caveat that image post-processing (`background`, `border_*`, `watermark_*`) and `selector` are skipped for PDFs. Put a watermark in the page's own print CSS instead. Full detail in [HTML to PDF API](/guides/pdf-export) and [Invoice and report PDFs](/use-cases/invoice-pdfs). Rendering your own authenticated pages needs care: Ssnap fetches the url as an anonymous public visitor, so a page behind session auth renders as your login screen. Use a signed, time-limited url — Laravel's `URL::temporarySignedRoute()` is a direct fit. ## Things that catch people out [#things-that-catch-people-out] * **`Http::failed()` covers 4xx and 5xx, but a `202` is a success.** With `callback_url` set, `$response->body()` is a queue acknowledgement, not an image. * **A `selector` matching nothing fails the render** with `capture_failed`. * **`width` alone is ignored** — the viewport override needs both `width` and `height`. * **Signed urls expire after 24 hours.** Store the file if you need it longer; stored captures are themselves pruned per [Storage and retention](/platform/retention). * **Cache hits skip the quota charge.** For a thumbnail endpoint hit by many users, a `cache_ttl` is the difference between one capture and thousands. ## Next [#next] # Python screenshot API (/guides/screenshot-api-python) The Python alternative to running Selenium or Playwright in your own container: one HTTP call, no browser to install, no driver to keep in step with Chrome. Examples use `requests`; the `httpx` equivalents are at the bottom. ## The minimal call [#the-minimal-call] ```python title="capture.py" import os import requests response = requests.post( "https://ssnap.cc/api/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"}, json={"url": "https://example.com", "format": "png"}, timeout=60, ) response.raise_for_status() with open("example.png", "wb") as file: file.write(response.content) ``` Set a `timeout`. Without one, `requests` waits forever, and a capture can legitimately take up to the 30-second render timeout — longer if the page is slow and you added a `delay`. ## Errors are JSON even when you asked for PNG [#errors-are-json-even-when-you-asked-for-png] A failed capture returns a JSON body with the same `Content-Type` habits as any other error, so `response.content` on a failure is an error object, not an image. Branch on the status before writing anything: ```python title="capture.py" if response.status_code != 200: payload = response.json() raise RuntimeError(f"ssnap {payload.get('code')}: {payload.get('error')}") ``` `code` is stable and safe to compare against; `error` is human prose. The full table is in [API error codes](/api/errors). ## Streaming large captures [#streaming-large-captures] A full-page render of a long document can be tens of megabytes. Stream it rather than buffering: ```python title="stream.py" with requests.post( "https://ssnap.cc/api/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"}, json={"url": "https://example.com", "full_page": True, "network_idle": True}, stream=True, timeout=60, ) as response: response.raise_for_status() with open("full.jpg", "wb") as file: for chunk in response.iter_content(chunk_size=64 * 1024): file.write(chunk) ``` ## Capturing a batch [#capturing-a-batch] The per-minute rate limit applies per API key, so a naive `for` loop over a thousand urls will hit `429` partway through and lose whatever it had not written yet. Two habits make batches survivable: ```python title="batch.py" import time def capture(params, attempt=0): response = requests.post( "https://ssnap.cc/api/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"}, json=params, timeout=60, ) if response.status_code == 429: code = response.json().get("code") # The monthly quota does not reset inside a retry window. Only the # per-minute rate limit is worth waiting out. if code == "quota_exceeded" or attempt >= 3: return response time.sleep(int(response.headers.get("retry-after", 5))) return capture(params, attempt + 1) return response for target in urls: result = capture({"url": target, "format": "png", "cache_ttl": 86400}) ``` `cache_ttl` in a batch is not just speed: a cache hit does not consume a monthly capture slot, so re-running a failed batch costs nothing for the urls that already succeeded. See [Screenshot caching](/api/caching). ## Async with httpx [#async-with-httpx] For genuinely concurrent work, cap the concurrency yourself — your key's per-minute allowance is the real ceiling, not your event loop. ```python title="async_capture.py" import asyncio import os import httpx LIMIT = asyncio.Semaphore(5) async def capture(client: httpx.AsyncClient, url: str) -> bytes: async with LIMIT: response = await client.post( "https://ssnap.cc/api/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"}, json={"url": url, "format": "png", "cache_ttl": 3600}, timeout=60.0, ) if response.status_code != 200: raise RuntimeError(response.json().get("code", "unknown")) return response.content async def main(urls: list[str]) -> list[bytes]: async with httpx.AsyncClient() as client: return await asyncio.gather(*(capture(client, url) for url in urls)) ``` Check `X-RateLimit-Remaining` on the responses to tune that semaphore against your actual plan — see [Rate limits and quotas](/api/rate-limits-and-quotas). ## Links instead of bytes [#links-instead-of-bytes] ```python response = requests.post( "https://ssnap.cc/api/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"}, json={"url": "https://example.com", "response": "url", "cache_ttl": 3600}, timeout=30, ) payload = response.json() print(payload["url"], payload["cached"]) ``` The signed link lasts 24 hours and carries no credentials, so it can go straight into a template. Beyond that window, re-host the file yourself. ## Long jobs belong in a queue [#long-jobs-belong-in-a-queue] Rendering a heavy report inside a Django or Flask request ties up a worker for the whole render. Pass a `callback_url` instead: the API returns `202` immediately and POSTs the result to your endpoint when it is done, with a 120-second job timeout rather than 30. ```python requests.post( "https://ssnap.cc/api/v1/screenshot", headers={"Authorization": f"Bearer {os.environ['SSNAP_API_KEY']}"}, json={ "url": "https://example.com/reports/q1", "format": "pdf", "network_idle": True, "callback_url": "https://your-app.com/hooks/ssnap", }, timeout=30, ) ``` Verify the signature on that webhook before acting on it — your endpoint is public. See [Verify webhook signatures](/guides/verify-webhooks). ## Things that catch people out [#things-that-catch-people-out] * **`full_page` plus lazy loading gives you blank boxes.** Add `network_idle: True` and a small `delay`; nothing scrolls the page for you. * **A non-matching `selector` fails the render** with `capture_failed`, it does not fall back to the viewport. * **`width` without `height` is ignored.** Pass both or neither. * **`quality` does nothing for PNG.** It is lossy-format only, so `jpeg` and `webp`. * **Keep the key server-side.** Anything embedded in a notebook you share, or in client code, is a leaked key — rotate it from the dashboard if that happens. ## Next [#next] # Verify webhook signatures (/guides/verify-webhooks) Async deliveries are signed. Verify before acting: your callback endpoint is public, so anyone who learns its address can post to it. ## The scheme [#the-scheme] | Header | Value | | ------------------- | ----------------------------------------------------------- | | `X-Ssnap-Timestamp` | Unix timestamp of the delivery attempt | | `X-Ssnap-Signature` | `sha256=` + `HMAC-SHA256(".", secret)` | The signed string is the timestamp, a literal dot, then the **raw request body** exactly as received. Re-serialising parsed JSON will change the bytes and break the signature. Signature headers are present only when a webhook secret is configured for the platform. Treat a missing signature as a failed verification rather than a pass. ## Node.js / Express [#nodejs--express] ```js title="webhook.js" import crypto from 'node:crypto'; import express from 'express'; const app = express(); // Capture the raw body; parsed JSON cannot be verified. app.post( '/hooks/ssnap', express.raw({ type: 'application/json' }), (req, res) => { const timestamp = req.get('X-Ssnap-Timestamp'); const signature = req.get('X-Ssnap-Signature'); if (!timestamp || !signature) return res.sendStatus(400); // Reject deliveries older than five minutes to blunt replays. if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) { return res.sendStatus(400); } const expected = 'sha256=' + crypto .createHmac('sha256', process.env.SSNAP_WEBHOOK_SECRET) .update(`${timestamp}.${req.body.toString('utf8')}`) .digest('hex'); const valid = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); if (!valid) return res.sendStatus(401); const payload = JSON.parse(req.body.toString('utf8')); // Acknowledge first, work afterwards: deliveries time out after 10 seconds. res.sendStatus(200); void handle(payload); }, ); ``` ## PHP / Laravel [#php--laravel] ```php title="Controller" public function handle(Request $request): Response { $timestamp = $request->header('X-Ssnap-Timestamp'); $signature = $request->header('X-Ssnap-Signature'); abort_if($timestamp === null || $signature === null, 400); abort_if(abs(time() - (int) $timestamp) > 300, 400); $expected = 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $request->getContent(), config('services.ssnap.webhook_secret'), ); abort_unless(hash_equals($expected, $signature), 401); $payload = $request->json()->all(); if ($payload['status'] === 'success') { ProcessScreenshot::dispatch($payload['url']); } return response()->noContent(); } ``` ## Python / Flask [#python--flask] ```python title="app.py" import hashlib, hmac, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["SSNAP_WEBHOOK_SECRET"].encode() @app.post("/hooks/ssnap") def ssnap_webhook(): timestamp = request.headers.get("X-Ssnap-Timestamp") signature = request.headers.get("X-Ssnap-Signature") if not timestamp or not signature: abort(400) if abs(time.time() - int(timestamp)) > 300: abort(400) expected = "sha256=" + hmac.new( SECRET, f"{timestamp}.".encode() + request.get_data(), hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, signature): abort(401) payload = request.get_json() return "", 204 ``` ## Handler checklist [#handler-checklist] * **Compare in constant time.** Use `timingSafeEqual`, `hash_equals` or `compare_digest`, never `==`. * **Verify against raw bytes**, before parsing. * **Reject stale timestamps** (five minutes is a reasonable window). * **Return `2xx` fast.** Delivery times out after 10 seconds and is retried up to three times, 500 ms apart. * **Be idempotent.** Retries mean the same payload can arrive more than once. * **Download promptly.** The `url` in a success payload is signed for 24 hours. * **Handle failures.** A payload may be `{"status": "error", "error": …, "code": …}`, and the [error codes](/api/errors) are the same as the synchronous ones. ## Next [#next] # Storage and retention (/platform/retention) Every successful capture is stored so it can be re-served, cached and listed in your dashboard history. ## Retention window [#retention-window] Screenshots and their files are kept for **30 days** by default, then removed by a daily prune job. Deletion removes the stored file along with its record, so nothing is left orphaned on disk. Treat Ssnap storage as a cache, not as an archive. If a capture matters to you, download it and store it yourself. The retention window applies to your dashboard history too: the **Screenshots** page offers 1-, 7- and 30-day windows. ## How stored files are served [#how-stored-files-are-served] Captures live on a private disk. They are reachable only through signed, time-limited links: | Where | Validity | | ---------------------- | -------- | | API `response=url` | 24 hours | | Async callback payload | 24 hours | | Dashboard history | 1 hour | A link with a bad or expired signature returns `403`; a capture whose file has been pruned returns `404`. Signed links carry no API key, so they are safe to hand to a browser. For the same reason, anyone holding the link can fetch the file while it is valid. Treat them as short-lived secrets. ## Interaction with caching [#interaction-with-caching] A cache hit requires the earlier capture's file to still exist. Once retention prunes it, the next request re-renders and consumes a quota slot, even inside a long `cache_ttl`. Practically: a `cache_ttl` beyond the retention window cannot keep paying off. ## What is kept per capture [#what-is-kept-per-capture] | Stored | Not stored | | -------------------------------------------- | ------------------------------------------- | | Target url, capture parameters, device | Page content beyond the rendered file | | The rendered file | Your API key (only its SHA-256 hash exists) | | Owning team, key and user, status, timestamp | | ## Deleting captures [#deleting-captures] Deleting an API key does not delete the screenshots it made; history stays until retention prunes it. For an earlier deletion of specific captures, contact [info@ssnap.cc](mailto:info@ssnap.cc). ## Next [#next] # Status and support (/platform/status) ## Status page [#status-page] [ssnap.cc/status](https://ssnap.cc/status) reports live health, checked on request rather than served from a cached dashboard. That distinction matters: the page tells you what is true now, not what a background job last recorded. | Check | What it proves | | -------------- | ---------------------------------------------------------------- | | Database | Connection and a live query succeed. | | Cache | A value can be written and read back. | | Browser engine | A real test page renders, not merely that a binary is installed. | | Queue | The async capture backlog is within normal depth. | | Capture | Recent success rate across real captures. | Each check reports `operational`, `degraded` or `down`, rolling up to an overall state. The browser render check is cached for a minute, since the probe itself costs about a second. ### Reading a degraded state [#reading-a-degraded-state] The checks map onto failure modes you can see from the outside: * **Browser engine degraded** → expect `capture_failed` on synchronous captures. Retrying with backoff is reasonable. * **Queue degraded** → synchronous captures are fine; `callback_url` deliveries arrive late. Do not re-queue, or you will pay for the capture twice. * **Capture success rate degraded** → renders are completing but failing more often than usual. Check your own target url first; a single broken page skews nothing, but a site that started blocking datacentre traffic looks identical from your side. ## Uptime monitoring [#uptime-monitoring] There is a lightweight `GET /up` endpoint for external monitors. It is cheap, so a one-minute interval is fine. Do not point a monitor at the capture endpoint itself. Every probe would be a real capture: it consumes a monthly slot, counts against your per-minute rate limit, and costs a browser launch. If you want to monitor captures specifically, do it once every few minutes against a static page, with a long `cache_ttl` so most probes are free cache hits — see [Screenshot caching](/api/caching). ## Getting help [#getting-help] | Channel | Where | | ------------ | -------------------------------------------- | | Email | [info@ssnap.cc](mailto:info@ssnap.cc) | | Contact form | [ssnap.cc/contact](https://ssnap.cc/contact) | | In-app | The feedback control in your dashboard | ## Reporting a failed capture [#reporting-a-failed-capture] Include these five things and the problem is usually reproducible without a back-and-forth: 1. The **target url**, exactly as sent. 2. The **full parameter set**, including the ones you think are irrelevant — `delay` and `network_idle` frequently are not. 3. The **HTTP status** and the **`code`** from the body. See [API error codes](/api/errors). 4. Roughly **when** it happened, with a timezone. 5. Whether it is **consistent or intermittent**, and whether the url renders in your own browser. ### Worth checking before you write [#worth-checking-before-you-write] A large share of reported failures resolve to one of these: * A `selector` that no longer matches, which fails the render rather than falling back. * A target that requires a login, or has started refusing datacentre IP ranges — the capture is what an anonymous visitor sees. * A private or internal url, refused by [URL restrictions](/platform/url-restrictions). * `quota_exceeded` mistaken for `rate_limit_exceeded`, since both answer `429`. * A stale signed url: they expire 24 hours after the response, and the stored file is itself subject to [Storage and retention](/platform/retention). ## Security reports [#security-reports] Security issues go to [info@ssnap.cc](mailto:info@ssnap.cc) directly rather than through the in-app form. Include reproduction steps and give us a chance to fix it before publishing. # URL restrictions (/platform/url-restrictions) Ssnap fetches urls on your behalf, so it refuses anything that is not publicly routable. The same checks apply to `url` and to `callback_url`. Rejections return `422` with code `invalid_url`. ## Rules [#rules] | Rule | Rejected examples | | ------------------------------------------ | ------------------------------------------------------------------- | | `http` or `https` only | `file:///etc/passwd`, `ftp://…`, `data:…` | | No loopback or reserved hostnames | `localhost`, `app.localhost` | | No internal-network suffixes | `*.local`, `*.internal`, `*.intranet`, `*.home.arpa` | | No cloud metadata endpoints | `metadata.google.internal`, `metadata.goog` | | No private or reserved IPs | `127.0.0.1`, `10.0.0.5`, `192.168.1.10`, `169.254.169.254`, `[::1]` | | No hostnames that *resolve* to private IPs | a public name pointed at `10.x.x.x` | The hostname is resolved and **every** A and AAAA record is checked; one private address among them rejects the url. A hostname that cannot be resolved is rejected too. ## Redirects [#redirects] The target is probed before the browser is started, and each redirect hop is re-checked, up to 5 hops. A public url that redirects to an internal address is refused mid-chain. The probe has a 3-second timeout and the target must answer with a status below 400. This means a url that 404s, times out, or is behind a login wall returns `422` `invalid_url` rather than a screenshot of an error page. ## Checked twice for async captures [#checked-twice-for-async-captures] For [async callbacks](/api/async-callbacks), both the capture target and the callback host are re-validated when the job runs, not only when the request was accepted. A job may run minutes after validation, and DNS can be repointed in between. ## Capturing private or authenticated pages [#capturing-private-or-authenticated-pages] Ssnap cannot reach anything that is not publicly resolvable, and there is no parameter for credentials, cookies or headers. Options: * Expose a **public, unguessable preview url** (a signed token in the path) for the page you want captured. * Render the content to a **temporary public page** and capture that. * Allow the capture through a **public staging host** rather than an internal one. An unguessable url is not an access control. Give it a short lifetime, and do not put anything behind it that would be harmful if the link were shared. ## What happens during the render [#what-happens-during-the-render] Beyond the host checks, the render itself: * dismisses JavaScript dialogs, so `alert()` cannot hang a capture, * blocks `googlesyndication.com`, `google-analytics.com` and `fonts.googleapis.com`, * times out after 30 seconds. ## Next [#next] # Invoice and report PDFs (/use-cases/invoice-pdfs) Generating an invoice with a PDF library means laying out a document in code: absolute coordinates, manual page breaks, a font subsystem. Rendering the invoice as an HTML page and capturing it means you keep CSS, and the same template can serve the web view and the download. ## The basic render [#the-basic-render] ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://your-app.com/invoices/1042/print?signature=..." \ --data-urlencode "format=pdf" \ --data-urlencode "network_idle=true" \ --output invoice-1042.pdf ``` `format=pdf` returns `application/pdf` bytes. The document paginates itself, so `full_page` does not apply and `selector` is ignored — a PDF is always the whole document. ## Authentication is the first problem [#authentication-is-the-first-problem] Ssnap fetches the url as an anonymous public visitor. It carries none of your cookies, so a page behind session auth renders as your login screen, and a private url is rejected outright: the target must be publicly routable. See [URL restrictions](/platform/url-restrictions). The workable pattern is a signed, time-limited, public route that renders one document: ```php title="Laravel" URL::temporarySignedRoute('invoices.print', now()->addMinutes(5), ['invoice' => $invoice]); ``` Same idea anywhere else: a token in the url, short expiry, single document, no session. Keep the window tight — the url is a bearer credential for that invoice while it lives. ## Print CSS does the layout [#print-css-does-the-layout] The page is rendered by Chromium, so `@media print` rules apply. That is where pagination actually gets controlled: ```css @media print { nav, .cookie-banner, .download-button { display: none; } .invoice-line-items { break-inside: auto; } .invoice-total, .signature-block { break-inside: avoid; } thead { display: table-header-group; } tfoot { display: table-footer-group; } } ``` `display: table-header-group` is the one worth remembering: it repeats the table header on every page of a long line-item list, which is otherwise the most common complaint about an HTML-rendered invoice. ## Image styling does not apply [#image-styling-does-not-apply] `background`, `border_width`, `border_color` and every `watermark_*` parameter are image post-processing. They are skipped entirely for PDFs, as is `quality` — PDF is not a lossy raster format. A "PAID" or "DRAFT" watermark therefore belongs in the page's own CSS: ```css @media print { .watermark { position: fixed; inset: 0; display: grid; place-content: center; font-size: 8rem; color: rgb(0 0 0 / 8%); transform: rotate(-30deg); pointer-events: none; } } ``` `position: fixed` repeats it on every printed page, which is usually what you want. ## Viewport still matters [#viewport-still-matters] `width`/`height` and `device_id` apply in PDF mode, because they set the rendering viewport, which drives your responsive breakpoints. A template that collapses to a single-column mobile layout at 390px will produce a single-column PDF. Pin a desktop viewport for documents: ```bash --data-urlencode "width=1240" \ --data-urlencode "height=1754" ``` `theme` applies too — `theme=dark` emulates `prefers-color-scheme: dark`, which is almost never what you want for something that will be printed on paper. Leave it at `light`. ## Long reports: go async [#long-reports-go-async] A multi-hundred-page report will not finish inside the 30-second synchronous render timeout. Pass a `callback_url`: the request returns `202` at once, and the finished PDF is POSTed to your endpoint, with a 120-second job budget. ```bash curl -X POST https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/reports/2026-q1?signature=...", "format": "pdf", "network_idle": true, "delay": 1500, "callback_url": "https://your-app.com/hooks/ssnap" }' ``` Two cautions on the async path. The signed url on your side must still be valid when the job actually runs, so give it more headroom than you would for a synchronous call. And the webhook is public — verify its signature before writing the file anywhere. See [Async callbacks](/api/async-callbacks) and [Verify webhook signatures](/guides/verify-webhooks). ## Charts and fonts [#charts-and-fonts] Anything drawn client-side needs to have finished drawing. `network_idle=true` covers the data fetch; `delay` covers the render after it. For a report full of charts, 1000–2000 ms is a realistic starting point — then measure, because the delay counts against the render timeout. ## Caching and storage [#caching-and-storage] `cache_ttl` works for PDFs exactly as for images, and `format` is part of the cache key, so a PDF and a PNG of the same page cache separately. For an invoice this matters less than you would think: the document is usually generated once and then belongs in your own storage. Do not rely on Ssnap as the system of record — captures are pruned per [Storage and retention](/platform/retention), and a signed url expires after 24 hours. ## Checklist [#checklist] * Public, signed, short-lived route — not a session-authenticated page. * `format=pdf`; expect `selector`, `full_page`, `quality` and all image styling to be ignored. * Watermark and page breaks in `@media print`, not in API parameters. * Desktop `width`/`height` so the template does not render its mobile layout. * `network_idle=true`, plus a `delay` if anything is drawn client-side. * Async with `callback_url` for anything long, with signature verification on the receiver. * Store the bytes yourself. ## Next [#next] # Open Graph images (/use-cases/og-images) Social preview images are a layout problem pretending to be an image problem. Drawing them with a canvas library means re-implementing text wrapping, web fonts and ellipsis by hand. Rendering an HTML template you already know how to style skips all of it. The shape is always the same: a route that renders the card as a web page, and a capture of that route at 1200×630. ## The template route [#the-template-route] Build a normal page on your own site that takes the dynamic parts as query parameters: ``` https://your-app.com/og?title=Ship+it&author=Ada ``` Style it at exactly the output size, and make it self-contained — no cookie banner, no navigation, no analytics: ```css body { width: 1200px; height: 630px; margin: 0; display: grid; place-content: center; } ``` ## The capture [#the-capture] ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://your-app.com/og?title=Ship+it" \ --data-urlencode "width=1200" \ --data-urlencode "height=630" \ --data-urlencode "format=png" \ --data-urlencode "cache_ttl=2592000" \ --data-urlencode "response=url" ``` Four things matter here. **Both `width` and `height`.** A lone `width` is ignored — the viewport override only applies when both are present. See [Capture parameters](/api/parameters). **No `full_page`.** The template is already exactly 1200×630. `full_page` would follow the document's scroll height instead, and any stray margin would change the aspect ratio. **`format=png`.** Crisp text on flat colour, and `quality` does nothing for PNG anyway. Use `webp` if your consumers accept it and the card is photographic. **A long `cache_ttl`.** This is the part that decides whether the feature is affordable. ## Caching is the whole trick [#caching-is-the-whole-trick] An OG image endpoint is hit by every crawler, every chat unfurl and every share preview — the same card, over and over. Without `cache_ttl` each of those is a fresh browser launch and a fresh capture against your monthly allowance. A cache hit does not consume a capture slot at all. Because the cache key is a hash of the url plus the output-affecting parameters, and your title travels in the url, each distinct card renders once and is then free: ``` cache_ttl=2592000 # 30 days, the maximum ``` Check `X-Screenshot-Cached` (binary) or `"cached"` (with `response=url`) to confirm you are actually hitting it. Full rules in [Screenshot caching](/api/caching). Rate limiting still applies to cache hits — they cost a request even though they cost no quota. An OG endpoint fronted directly by a crawler stampede should sit behind your own CDN cache as well. ## Serving the result [#serving-the-result] Two workable patterns. **Generate at publish time.** When a post is saved, capture it, store the bytes on your own storage, and put that permanent url in the `og:image` tag. Predictable, no runtime dependency, and crawlers never wait on a render. **Generate on demand with a signed url.** Capture with `response=url` and hand back the signed link. It is valid for 24 hours and needs no API key, which is fine for a preview and wrong for a permanent tag — crawlers re-fetch `og:image` long after that. If you go this route, cache the bytes yourself on first use. Whichever you pick, do not call the API from the browser. The key would be public. ## Fonts and timing [#fonts-and-timing] Web fonts are the usual cause of a card that renders in a fallback face. The capture happens when the page is ready, not when every font file has painted: ```bash --data-urlencode "network_idle=true" \ --data-urlencode "delay=300" ``` `network_idle` waits for network activity to settle; `delay` adds a fixed pause after that. Together they cover fonts and any entry animation. Better still, inline the font as a data url in the template so there is no request to wait for. ## Checklist [#checklist] * Template route renders standalone at 1200×630, no chrome. * `width` **and** `height` both set; no `full_page`. * `cache_ttl` at or near the 30-day maximum. * `network_idle=true` plus a small `delay` if the card uses web fonts. * Bytes stored on your own storage before they go into a permanent `og:image` tag. * API key server-side only. ## Next [#next] # Visual regression testing (/use-cases/visual-regression) Visual diffing only works if two captures of an unchanged page are byte-comparable. Most of the effort is not in taking the screenshot — it is in removing everything that changes between two runs. ## A baseline capture [#a-baseline-capture] ```bash curl -G https://ssnap.cc/api/v1/screenshot \ -H "Authorization: Bearer $SSNAP_API_KEY" \ --data-urlencode "url=https://staging.example.com/pricing" \ --data-urlencode "width=1280" \ --data-urlencode "height=800" \ --data-urlencode "format=png" \ --data-urlencode "network_idle=true" \ --data-urlencode "delay=500" \ --output baseline/pricing-1280.png ``` Every one of those parameters is doing determinism work. ## Pin the viewport [#pin-the-viewport] Use an explicit `width` and `height` rather than a device preset, and pass both — a lone `width` is ignored. A pinned viewport means a CSS breakpoint change shows up as a diff instead of silently reflowing the whole image. Run the same page at two or three widths and treat each as its own baseline: ``` 1280×800 desktop 768×1024 tablet 390×844 mobile ``` Device presets are useful when you want a specific user agent as well — see [Device emulation and viewports](/api/devices). ## Use PNG [#use-png] `png` is lossless. JPEG and WebP are lossy, and their encoders can produce different bytes for identical input, which is a diff your tool will happily report. `quality` is ignored for PNG, so there is nothing to pin. ## Kill the non-determinism in the page [#kill-the-non-determinism-in-the-page] This is the part no capture parameter can solve for you. Anything that differs between two runs will diff: * Clocks, relative timestamps, "3 minutes ago". * Randomised ordering, A/B buckets, rotating testimonials. * Live counters, stock levels, exchange rates. * Animations mid-flight, carousels, skeleton loaders. * Third-party embeds and ads. Standard fixes: a `?visual-test=1` flag your app reads to freeze the clock and disable animations, seeded fixture data, and stubbed third-party content in the environment you capture. ```css @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; } } ``` ## Wait properly, not longer [#wait-properly-not-longer] ``` network_idle=true delay=500 ``` `network_idle` waits for network activity to settle, then `delay` adds a fixed pause. The pause is what covers CSS transitions and font swaps, which produce no network traffic at all. Do not reach for a huge `delay` as insurance. The overall render timeout is 30 seconds and a long delay eats into it — a 25-second delay on a slow page times out with `capture_failed`. ## Full page or element [#full-page-or-element] `full_page=true` captures the whole scroll height, which is the honest test but also the most fragile: one extra row anywhere shifts everything below it, so a one-line change diffs the entire image. Scoping to a component with `selector` gives far more useful failures: ```bash --data-urlencode "selector=#pricing-table" ``` The selector is capped at 255 characters and the first match is used. Note that a selector matching nothing fails the render with `capture_failed` rather than falling back — in a test suite that is a feature, since a vanished element should fail. More in [Full page and element screenshots](/guides/full-page-and-elements). ## Turn caching off [#turn-caching-off] `cache_ttl` is exactly wrong here. A cache hit returns an earlier render, so a genuine regression would be served the old, passing image. Leave `cache_ttl` unset in test runs — without it, every call renders fresh. The reverse is worth knowing too: because the cache key includes every output-affecting parameter, changing a single one of them in your test config invalidates all your baselines. Keep the capture parameters in version control next to the baselines. ## Running the suite in CI [#running-the-suite-in-ci] The per-minute rate limit is per key, and a suite is a burst by nature. Two habits: ``` - one API key per environment, so CI cannot throttle production traffic - bounded concurrency, tuned against X-RateLimit-Remaining ``` Back off on `rate_limit_exceeded` using `Retry-After`, but never loop on `quota_exceeded` — that one lasts until the billing period rolls over. See [Rate limits and quotas](/api/rate-limits-and-quotas) and [API error codes](/api/errors). For a large suite, `callback_url` turns the run into a fan-out: fire every capture, let them queue, collect the webhooks. The job budget is 120 seconds per capture rather than the synchronous 30-second render timeout. ## Storing baselines [#storing-baselines] Do not treat a signed url as a baseline. It expires after 24 hours, and stored captures are pruned on their own schedule — see [Storage and retention](/platform/retention). Commit the bytes, or push them to your own bucket, and diff against those. ## Checklist [#checklist] * Explicit `width` **and** `height`, one baseline per breakpoint. * `format=png`. * `network_idle=true` plus a modest `delay`. * No `cache_ttl`. * Animations, clocks and random data frozen in the page itself. * Capture parameters version-controlled with the baselines. * A separate API key for CI. ## Next [#next]