Docs
Everything runs in your user’s browser. There’s no headless renderer on our side, which is why the capture matches what they were actually looking at.
Install
Two ways in. Use the package if you have a bundler; use the script tag if you don’t, or if you’d rather not add a dependency to ship one button.
npm i @screen2api/sdkThe script build is 31 KB gzipped (27 KB brotli), has no dependencies, and exposes a single global — window.screen2api — carrying the same API as the package. Pinning an exact version and supplying the integrity hash means a compromised CDN still can’t change what runs on your page; every released version has one.
There is also a floating channel at /sdk/v0/screen2api.min.js that picks up patches on its own. Be aware that while we’re on 0.x, semver permits breaking changes in a minor release — so pin the exact version in production until 1.0.
Create one client and keep it around. It caches your project config and any assets it has already inlined, so the second capture on a page is much faster than the first.
import { createClient } from '@screen2api/sdk'
export const s2a = createClient({
publishableKey: 'pk_live_…',
})The package ships both module formats, so require resolves to the CommonJS build and import to the ESM one. Either is fine — but note this is a browser SDK. Requiring it from a bundler that outputs CommonJS works; calling capture() in Node does not, and throws a Screen2ApiError with code unsupported rather than failing somewhere confusing.
The publishable key is meant to be in your client bundle. What protects your project is the origin allowlist in Settings, not the key being secret.
Keys and origins
There are two kinds of key and they are not interchangeable. Getting this wrong is the most common way to end up either broken or exposed.
| Option | Type | Default | Description |
|---|---|---|---|
pk_live_… / pk_test_… | publishable | browser | Ships in your frontend. Used by the SDK to create captures. Anyone can read it out of your bundle, which is fine — a live publishable key only works from an origin you have listed. |
sk_live_… / sk_test_… | secret | server | Full access to your project over the REST API: read any capture, mint fresh URLs, use the file API. Never put one in a browser. Stored here only as a SHA-256 hash and shown once — if you lose it, rotate it. |
The origin allowlist
A pk_live_ key is refused unless the request’s Origin is on the project’s list. Add every origin you serve from, including the scheme and any port:
https://app.example.com
https://example.com
http://localhost:3000pk_test_ keys skip the check entirely, which is what makes them convenient locally and unsuitable for production. Test captures count against a smaller quota and are the ones to use in CI.
If captures work on your machine and fail once deployed, this is almost always why — look for forbidden_origin in the network response.
Environments
Live and test keys address the same project but separate data, so a test run cannot pollute the captures your team is looking at. Webhooks fire for both; the payload carries the project id so you can route them apart if you want to.
Choosing what to capture
Pass target as a CSS selector, an element, or a function returning one. Leave it out and you get the whole page.
await s2a.capture() // the page
await s2a.capture({ target: '#invoice' }) // one element
await s2a.capture({ target: '.receipt' }) // first match
await s2a.capture({ target: () => ref.current })area decides how much of it you get:
element— the target’s whole box, even the part scrolled past the bottom of the window. This is the default when you name a target.viewport— only what’s on screen right now, stopping at the fold. Ask for this by name when you want a bug report to show exactly what the user was looking at.document— the full scrollable page, top to bottom. The default when you don’t name a target.
Scroll positions inside the target are kept. If a panel is scrolled halfway, the capture shows it halfway.
Formats
Ask for several at once. The expensive work — resolving styles, inlining fonts and images, rasterizing — happens once, then the result is encoded N times.
await s2a.capture({
target: '#invoice',
formats: ['png', 'pdf', 'svg'],
scale: 2, // defaults to the device's own pixel ratio, capped at 3
quality: 0.92, // jpeg and webp only
})svg and html keep the text selectable rather than flattening it to pixels, which is handy for archiving anything someone might need to copy a number out of later. pdf writes a single page sized to the capture by default.
CSV export
csv is the odd one out: it reads the DOM instead of the bitmap. Point it at a table and you get the data, not a picture of the data.
await s2a.capture({
target: '#line-items',
formats: ['csv'], // no rasterizing happens at all
})
// Or both, from one call
await s2a.capture({
target: '#line-items',
formats: ['png', 'csv'],
})Anything with a tabular shape works:
<table>— includingcolspanandrowspan, which are expanded so every row lines up- ARIA tables —
role="table","grid"or"treegrid", which is what most virtualized data grids render <dl>becomes two columns;<ul>and<ol>become one- Anything else, if you mark it up with
data-csv-rowanddata-csv-cell
Point it at something that isn’t any of those and the call rejects with a Screen2ApiError naming the element, rather than handing you an empty file to debug later.
What lands in the cells
The same rule as the rest of the engine: whatever the user can see. Rows hidden with display:none are skipped, the value someone typed into an input beats the value in the HTML, and rendered text beats source text. When the display text isn’t what you want to export, override it per cell:
<td data-csv-value="2026-07-28T09:12:00Z">3 days ago</td>
<td data-csv-redact>4111 1111 1111 1111</td>redact and exclude apply here too — redacted cells come through as [redacted], excluded ones are dropped entirely.
Options
await s2a.capture({
target: '#line-items',
formats: ['csv'],
csv: {
delimiter: ';', // for locales where Excel expects it
omitHeader: true,
preserveLineBreaks: false,
bom: true, // Excel needs this to read accents correctly
sanitize: true, // neutralise spreadsheet formulas — see below
},
})Leave sanitize on. A cell beginning =, +, - or @ is executed as a formula by Excel, Sheets and LibreOffice. Since these values come off a live page — often typed by one of your users — an export is a direct route to =HYPERLINK(...) credential phishing. We prefix a single quote so the spreadsheet displays the text and runs nothing.
One consequence worth knowing: annotations never appear in a CSV, because there is nowhere in a spreadsheet to put an arrow. If a capture asks for png and csv together, the PNG carries the markup and the CSV carries the data.
The editor
Turn it on and the user gets a markup pass before anything is sent. Eleven tools, each on a number key:
- Arrow, line, box, ellipse, freehand, highlighter, text — the usual. Hold Shift to snap an arrow to 45° or keep a box square.
- Numbered steps — auto-incrementing badges for “do this, then this”. Delete one and the rest renumber, because a sequence with a gap in it defeats the point.
- Spotlight — dims everything outside a region. Where an arrow says “look here”, this says “and ignore all of that”.
- Redact — pixelates irreversibly, before the image is encoded.
- Crop, and a caption field.
Pick select to move a shape, or to select one and remove it with Delete. ⌘Z / ⇧⌘Z undo and redo, and the copy button puts the annotated image straight on the clipboard.
const s2a = createClient({
publishableKey: 'pk_live_…',
editor: { enabled: true, confirmLabel: 'Send to support' },
})If they close it without confirming, capture() resolves to null — a cancel isn’t an error, so you don’t need a try/catch around it.
const result = await s2a.capture({ target: '#report' })
if (!result) return // user backed outYou can also flip the editor on from the dashboard without redeploying, since the SDK reads project settings at runtime.
The Save a copy menu in the header downloads the result without uploading it — PNG, JPEG, WebP, PDF, SVG, HTML and CSV. Two of those depend on what’s happened to the capture: SVG and HTML are the vector document, and the reason to want them is that the text stays selectable, so they switch off the moment anything is drawn or cropped rather than handing you an .svg with a flat bitmap inside it. CSV is unaffected either way — it came from the DOM, not the pixels.
Receiving files
Add an endpoint under Webhooks. When a capture finishes we POST it the file URLs along with whatever you passed as params.
await s2a.capture({
target: '#invoice',
params: { invoiceId: 'inv_1042', userId: 'usr_77' },
}){
"event": "capture.completed",
"capture": {
"id": "cap_9f3c…",
"caption": "The VAT line looks wrong",
"params": { "invoiceId": "inv_1042", "userId": "usr_77" },
"files": [
{ "format": "png", "url": "https://…", "bytes": 481203,
"expires_at": "2026-08-03T09:12:44.000Z" }
]
}
}Check the screen2api-signature header against the raw body before you trust any of it — there is code for that below, and every attempt is listed on the Webhooks page.
Failures are yours to retry
We deliver once. If your endpoint doesn’t answer 2xx, the delivery is marked failed and nothing happens automatically — you resend it from the Webhooks page once you’ve fixed the cause.
That is deliberate. A capture webhook usually creates something at your end — a ticket, a message, a row — and a handler that does the work and then fails on the way out turns every automatic retry into a duplicate. The failures that actually happen are also the ones retrying cannot fix: a wrong URL, a signature check that rejects us, an endpoint returning 401. Those would generate days of noise and still need a person.
So the delivery is kept with its status code and response body until the files expire, and Resend is one click. The attempt counter keeps climbing across manual sends, so you can see how many times it has been tried.
If you’d rather pull than be pushed, keep the capture id and fetch it later with a secret key — that mints fresh URLs, which matters because the ones in the webhook expire with the capture.
curl https://api.screen2api.com/api/v1/captures/cap_9f3c… \
-H "Authorization: Bearer sk_live_…"Turning delivery off
Not every export is meant for your server. A “download this table as CSV” button is finished the moment the file reaches the person who clicked it, and delivering it anyway means your endpoint receives traffic you then have to filter back out. Pass webhook: false:
// This one is for the user. Nothing is delivered.
await s2a.capture({ target: '#report-table', formats: ['csv'], webhook: false })
// This one is a bug report. It goes to your endpoint as usual.
await s2a.capture({ target: 'body', params: { kind: 'bug' } })The capture is still uploaded and still appears in your dashboard — you have opted out of the delivery, not the record. To keep it off our servers entirely, use download(), which never touches the network.
It works per element in markup too, which is usually where you want it:
<table data-screen2api data-s2a-webhook="false">…</table>Set it for every tagged element with s2a.autoBind({ webhook: false }), or for the whole client with createClient({ capture: { webhook: false } }). The per-element attribute wins, so you can switch it back on for the one element that reports bugs.
Redaction
Anything matching redact is blurred before the image is encoded, so the original pixels never leave the browser. exclude removes the element from the capture entirely.
await s2a.capture({
target: '#account',
redact: ['[data-pii]', '.card-number'],
exclude: ['.support-widget', 'nav'],
})Set the same selectors project-wide in Settings if you’d rather not rely on every call site remembering. Elements carrying data-s2a-ignore are always skipped.
The file API
Everything above starts from the screen. The file API starts from a file your user already has — a contract, a scan, a photo of a receipt — and takes the same path out: annotate or redact it, export it, have it delivered to your webhook with your params attached.
It is server-to-server, so it takes a secret key. Nothing here needs the browser SDK.
curl -X POST https://api.screen2api.com/api/v1/files \
-H "Authorization: Bearer sk_live_…" \
-F file=@contract.pdfThe response tells you what that specific file can become, rather than a general list — so a picker built from targets can never offer a format that will fail halfway through:
{
"id": "8f14e45f…",
"name": "contract.pdf",
"source_kind": "pdf",
"page_count": 12,
"sha256": "3b8f…",
"expires_at": "2026-07-31T09:14:22Z",
"targets": [
{ "format": "pdf", "label": "PDF" },
{ "format": "png", "label": "PNG" },
{ "format": "gif", "label": "GIF", "animated": true,
"note": "12 pages become 12 frames" },
{ "format": "svg", "label": "SVG", "vector": false,
"note": "Embeds a rendered image rather than true vector paths" }
]
}A file can arrive four ways and they all end up in the same place: a multipart upload, a blob posted as multipart, {"base64": "…"}, or {"url": "https://…"} which we fetch server-side. You never handle bytes.
Redaction removes the content
This is the part worth reading twice. Drawing a black box over text with a PDF library leaves the text completely intact underneath — recoverable with copy-paste, pdftotext, or any PDF tool. Most software that offers “redaction” does exactly that.
We rebuild every page carrying a redaction from pixels, so the text is gone because the page is no longer text. Then we read the output back and confirm it before handing it over; if the content somehow survived, the export fails rather than delivering a document that only looks redacted. Pages without a redaction keep their text layer and stay searchable.
curl -X POST https://api.screen2api.com/api/v1/files/8f14e45f…/export \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6c1f…" \
-d '{
"format": "pdf",
"annotations": [
{ "kind": "redact", "page": 1, "x": 0.08, "y": 0.13, "w": 0.6, "h": 0.06 }
],
"params": { "matterId": "m_4471" }
}'{
"file_id": "8f14e45f…",
"format": "pdf",
"url": "https://…/contract.pdf",
"expires_at": "2026-07-31T09:14:22Z",
"sha256_in": "3b8f…",
"sha256_out": "d41d…",
"redaction_verified": true,
"redacted_regions": 1
}redaction_verified is null when nothing was redacted, and true only when we have re-extracted the text and confirmed the content is absent. The two hashes are there so you can prove which input produced which output.
Coordinates are normalised — 0 to 1, origin at the page’s top-left — so a mark means the same thing at 72 DPI and at 300. Besides redact there is highlight, rect, ellipse, line, arrow, ink and text; those are drawn as vector graphics and cost the page nothing.
Idempotency-Key is honoured — a retried export is replayed, not re-rendered or re-delivered.
Page operations
Rotate, flip, delete and reorder, applied before annotations so the page you marked up is the page that gets exported. All of them keep the text layer.
curl -X PATCH https://api.screen2api.com/api/v1/files/8f14e45f… \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"page_ops": [{"page": 2, "rotate": 90}, {"page": 5, "deleted": true}]}'The export webhook
Same envelope as a capture, same signature scheme. Subscribe an endpoint to file.exported in Webhooks — an endpoint created before the file API existed only listens for captures, so it has to be ticked once.
{
"event": "file.exported",
"file": {
"id": "8f14e45f…",
"name": "contract.pdf",
"format": "pdf",
"url": "https://…/contract.pdf",
"expires_at": "2026-07-31T09:14:22Z",
"sha256_in": "3b8f…",
"sha256_out": "d41d…",
"redaction_verified": true,
"redacted_regions": 1,
"params": { "matterId": "m_4471" }
}
}What it takes, and what it doesn’t
PDF, images (PNG, JPG, WEBP, GIF, TIFF, BMP, AVIF, HEIC), SVG, and text (TXT, MD, CSV). That is an allowlist, not a blocklist: if a file isn’t something a person can look at and mark up, it is refused with a message naming what we do take, rather than failing somewhere further down.
Office documents need exporting to PDF first. Audio, video and archives are a different product. Files cap at 25 MB, and both the file and everything exported from it are deleted after 24 hours — so store the bytes your side if you need to keep them.
There is also an editor at Files, which drives these same endpoints. Anything it can do, curl can do.
Other entry points
captureAll() gives you one capture per matching element — a whole list of report cards in a single call.
const { results, errors } = await s2a.captureAll({
target: '.report-card',
formats: ['png'],
})It runs sequentially on purpose: each capture rasterizes a full subtree, and firing twenty at once makes the page stutter for the user who is still looking at it. One element failing doesn’t abandon the rest — check errors. Each result carries index and total in its params, so your webhook can tell them apart. The editor is skipped here; annotating N images back to back isn’t a flow anyone wants.
preload() warms the font and asset caches so the first capture isn’t the slow one. On a font-heavy page, downloading and base64-ing every @font-face is most of the wall clock — do it while the user isn’t waiting.
// on idle, or when they hover your export button
requestIdleCallback(() => s2a.preload())result.copy() puts the capture on the system clipboard. It resolves false rather than throwing when the browser refuses — it needs a secure context and a recent user gesture — so you can fall back to a download.
const result = await s2a.capture({ target: '#chart' })
if (result && !(await result.copy())) {
// clipboard blocked; offer the file instead
}And the lower-level pieces, if you want to build your own pipeline: toCanvas(), toBlob(format), renderToCanvas(), elementToCsv() and canvasToPdf() are all exported.
React
import { Screen2ApiProvider, useCaptureTarget } from '@screen2api/sdk/react'
function Report() {
const { ref, capture, capturing } = useCaptureTarget({
formats: ['png', 'pdf'],
})
return (
<>
<div ref={ref}>…</div>
<button onClick={() => capture()} disabled={capturing}>
{capturing ? 'Exporting…' : 'Export'}
</button>
</>
)
}Wrap your app in <Screen2ApiProvider publishableKey="…">. There is also a plain useCapture() if you’d rather pass a selector, and a <CaptureButton /> for the simple case.
Export buttons from markup
Everything above assumes you want to write the wiring yourself — pick a target, choose formats, hang a click handler on a button. If you just want the export to exist, tag the content instead and skip all of it.
<div data-screen2api>
<!-- your gallery, invoice, dashboard, anything -->
</div>s2a.autoBind() // once, anywhereThat is the whole integration. An export button appears on the element, and clicking it captures that element and everything inside it.
It works out the formats
You don’t say what the element is; we look. A <table> is worth having as a spreadsheet, an image isn’t.
| Element | Offered |
|---|---|
<table>, ARIA grid, or a wrapper round one | csv, png, pdf |
<ul>, <ol>, <dl> | csv, png |
<img>, <canvas>, <svg>, <video> | png |
| anything else | png, pdf |
One format exports on click; more than one opens a small menu. It deliberately doesn’t export all of them at once — that would triple the quota a single click costs you. Override with data-s2a-formats whenever the guess is wrong.
It waits for the content
The button doesn’t appear until the element has a real size and its images have finished loading. This matters more than it sounds: on a gallery that fills in after load, an export button that works immediately hands the first person who clicks it a picture of half-drawn placeholders, and they conclude the product is broken. Content added later — infinite scroll, a client-side route change — is picked up automatically.
Every attribute
<div
data-screen2api
data-s2a-formats="png,pdf" <!-- override the detected set -->
data-s2a-editor="true" <!-- let the user annotate first -->
data-s2a-params='{"orderId":"ord_9"}' <!-- echoed back on your webhook -->
data-s2a-position="top-right" <!-- ...top-left | bottom-right |
bottom-left | outside-top-right |
outside-top-left -->
data-s2a-show="hover" <!-- default: always visible -->
data-s2a-label="Export" <!-- default: icon only -->
data-s2a-target="#something-else" <!-- capture that instead of me -->
data-s2a-area="element" <!-- element | viewport | document -->
data-s2a-scale="2"
data-s2a-exclude=".controls, nav"
data-s2a-redact="[data-pii]"
data-s2a-filename="order-9"
data-s2a-wait="false" <!-- skip the readiness wait -->
data-s2a-role="target" <!-- force target | trigger -->
data-s2a-webhook="false" <!-- user download only, no delivery -->
>On a button, it stays a button
Tag a <button>, <a> or role="button" and it behaves as a trigger rather than growing a button of its own — so the original markup-only form still works exactly as it did.
<button data-screen2api data-s2a-target="#invoice" data-s2a-formats="png,pdf">
Export invoice
</button>Anything ambiguous can be settled with data-s2a-role.
Defaults for the whole page
s2a.autoBind({
position: 'outside-top-right',
visibility: 'hover',
label: 'Export',
editor: true,
})Per-element attributes win over these. autoBind() returns a function that removes every button it added, which is what you call from a framework cleanup.
The outside-* positions place the button above the element rather than over it, which is useful when the content goes right to its own edge — but it sits in whatever is above, so check it against a sticky header before shipping it.
The buttons render in their own layer rather than inside your elements. Nothing is inserted into your DOM, so your flex and grid item counts, your :last-child rules and your global button styles are all left alone — and the button can never end up inside the capture it triggers.
API reference
capture options
Every option below can be passed per call to capture(), or set once as capture on createClient() and overridden per call. Project defaults set in the dashboard apply underneath both.
| Option | Type | Default | Description |
|---|---|---|---|
target | string | Element | (() => Element | null) | null | null | CSS selector, element, or a function resolved at capture time. Omitted means the whole page. A selector matching several elements captures the first — use captureAll() for one per match. |
area | 'element' | 'viewport' | 'document' | 'element' / 'document' | What to include. element is the target’s full box including parts scrolled out of view, and the default when you pass a target. document is the whole scrollable page, and the default when you don’t. viewport stops at the fold. |
engine | 'dom' | 'display-media' | 'dom' | dom clones the DOM and rasterizes it, with no permission prompt. display-media uses the Screen Capture API — pixel-exact including plugins and cross-origin iframes, but the browser asks the user for consent and they choose what to share. |
formats | FileFormat[] | ['png'] | Any of png, jpeg, webp, svg, pdf, html, csv. The scene renders once and encodes N times, so asking for three formats costs far less than three captures. |
scale | number | devicePixelRatio, capped at 3 | Pixel density multiplier. 2 on a normal display gives retina-sharp output at four times the bytes. Your project has a max_scale that clamps this. |
quality | number (0–1) | 0.92 | For lossy formats only — jpeg and webp. Ignored by png and svg. |
background | string | null | the element's own background | Painted under the capture. Pass null for transparency, which png and webp keep and jpeg cannot. |
exclude | string[] | [] | Selectors removed from the capture entirely, as though they were never in the page. |
redact | string[] | [] | Selectors pixelated before encoding. The original pixels are destroyed in the browser and never transmitted. |
maxDimension | number | 16384 | Hard cap on output pixels per side. Browsers refuse to allocate canvases much beyond this, so a very long page is scaled down rather than failing. |
waitForAssets | boolean | true | Wait for webfonts and images to settle before capturing. Turning it off is faster and risks capturing a half-loaded page. |
assetTimeout | number (ms) | 3000 | How long to wait before giving up on assets and capturing anyway. A capture missing one image beats no capture. |
tolerateAssetErrors | boolean | false | Skip cross-origin images that fail to fetch instead of failing the capture. Worth enabling if you embed third-party avatars. |
params | Record<string, unknown> | {} | Your own metadata, echoed back verbatim on the webhook. This is how you tie a capture to an invoice, a ticket or a user without a second lookup. |
webhook | boolean | true | Deliver this capture to your endpoints. Set false for an export meant only for the person who clicked it. The capture is still stored and still appears in your dashboard — use download() to keep it off our servers entirely. |
caption | string | — | Preset the caption, or read what the end user typed from the result. |
filename | string | derived from document.title | Stem for downloads and uploads. The extension is added per format. |
onProgress | (progress: number, stage: string) => void | — | Called with 0–1 and a stage label. Worth wiring for whole-page captures, which can take a second or two. |
csv | CsvOptions | — | Settings for the csv format. Ignored unless csv is in formats. |
The result
capture() resolves with a CaptureResult, or null when the user dismissed the editor. Dismissal is a normal outcome, not an error, so it does not reject — check for null before using the result.
const result = await s2a.capture({ target: '#invoice', formats: ['png', 'pdf'] })
if (!result) return // the user closed the editor
result.id // 'cap_9f3c…', once uploaded
result.files // RenderedFile[]
result.file('pdf')?.blob // the first file of a format
result.file('png')?.url // remote URL, present only after upload
result.width, result.height // output pixels
result.scale // the density actually used
result.timings // { render: 412, encode: 88, upload: 210 }
result.warnings // e.g. an image that could not be inlined
result.uploaded // false when enabled:false or no key
await result.copy() // PNG to the clipboard; false if the browser refused
result.dispose() // revoke every object URL this result holdsObject URLs are not garbage collected — call dispose() when you’re finished, or a long-lived page that captures repeatedly will hold every blob it ever made.
Client methods
| Option | Type | Default | Description |
|---|---|---|---|
capture(options?) | Promise<CaptureResult | null> | — | Render, optionally annotate, encode and upload. Null when the editor is dismissed. |
captureAll(options?) | Promise<{ results, errors }> | — | One capture per element matching the selector, run sequentially so the page stays responsive. Skips the editor. One failure does not abandon the rest. |
download(options?) | Promise<CaptureResult | null> | — | Capture and trigger a browser download, skipping the network entirely. Nothing reaches us. |
toCanvas(options?) | Promise<HTMLCanvasElement> | — | Render only — no encoding, no upload. For custom pipelines. |
toBlob(format?, options?) | Promise<Blob> | 'png' | One encoded blob, nothing else. |
attach(trigger, options?) | () => void | — | Wire a button to a capture. Returns a function that unbinds it — call it on unmount. |
autoBind(options?, root?) | () => void | document | Activate every [data-screen2api] element. Returns a function that removes everything it added. |
preload(target?) | Promise<void> | — | Warm the font and asset caches so the first real capture is not the slow one. Call it on idle, or when the user hovers your export button. |
configure(config) | this | — | Merge new settings into an existing client without recreating it. Useful when the key arrives after your app boots. |
Editor options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Show the annotation editor before sending. Can also be turned on project-wide from the dashboard without a frontend deploy. |
tools | EditorTool[] | all | Which tools appear, in order: select, pen, arrow, line, rect, ellipse, highlight, text, step, spotlight, blur, crop. |
defaultTool | EditorTool | 'arrow' | Preselected when the editor opens. |
caption / captionPlaceholder | boolean / string | true | The caption field under the canvas, and its placeholder text. |
palette / defaultColor | string[] / string | — | Stroke colours offered, and which one starts selected. |
confirmLabel / cancelLabel | string | 'Send' | Button text. Worth changing if the capture is not being sent anywhere. |
theme | 'light' | 'dark' | 'auto' | 'auto' | Follows the user’s system preference unless pinned. |
allowFormatChoice | boolean | false | Let the end user pick which formats get exported. |
branding | { show, prefix, label, logo, href } | screen2api | Replace the credit in the editor header with your own product’s name and mark, or set show:false to remove it. |
Errors
Everything the SDK throws is a Screen2ApiError with a code. Branch on the code, never on the message — messages get rewritten, codes do not.
| Option | Type | Default | Description |
|---|---|---|---|
no_target | client | — | The selector matched nothing. Usually a capture fired before the element rendered, or after it unmounted. |
render_failed | client | — | The scene could not be rasterized. Check result warnings and the console; a tainted canvas or a cross-origin stylesheet is the usual cause. |
encode_failed | client | — | Every requested format failed to encode. Nearly always a capture too large for the browser to allocate — lower scale or maxDimension. |
upload_failed | network | — | The files were produced but did not reach us. The blobs are still in the result, so you can retry or fall back to a download. |
unauthorized | network | — | Bad or revoked key — or a live key used from an origin that is not on the allowlist. Check the origin first; it is the more common of the two. |
rate_limited | network | — | Too many captures too quickly, or the monthly quota is spent. Back off and retry; the response carries Retry-After. |
permission_denied | client | — | display-media only: the user declined the screen-share prompt. Offer the dom engine instead. |
cancelled | client | — | The capture was aborted in flight. Dismissing the editor does not raise this — that resolves null. |
unsupported | client | — | Called outside a browser — server-side rendering, a Node test, a worker. Guard with typeof window !== "undefined". |
import { Screen2ApiError } from '@screen2api/sdk'
try {
await s2a.capture({ target: '#invoice' })
} catch (error) {
if (!(error instanceof Screen2ApiError)) throw error
switch (error.code) {
case 'rate_limited':
return toast('Too many exports right now — try again in a minute.')
case 'unauthorized':
// An origin problem, not a user problem. Tell yourself, not them.
return report(error)
case 'upload_failed':
// The files exist even though the upload failed.
return offerDownload()
default:
return toast('That export failed. Please try again.')
}
}A client-wide onError handler catches everything if you would rather not wrap each call — though capture() still rejects, so keep the catch when you need to branch.
Limits
What the DOM engine cannot capture
The default engine rebuilds your page from the DOM and computed styles. That is what makes it fast and prompt-free, and it also means a few things are genuinely invisible to it:
| Option | Type | Default | Description |
|---|---|---|---|
Cross-origin iframes | blank | display-media | Same-origin frames are captured. Anything else the browser will not let us read, so it renders empty — Stripe Elements, embedded maps, third-party video players. |
Video frames | poster only | display-media | A <video> renders as its poster, not the current frame. Draw the frame to a canvas yourself first if you need it. |
Tainted canvases | blank | — | A canvas that has drawn a cross-origin image without CORS cannot be read back. Serve those images with Access-Control-Allow-Origin. |
WebGL without preserveDrawingBuffer | blank | — | The buffer is cleared after each frame unless the context was created with preserveDrawingBuffer: true. |
Shadow DOM (closed) | blank | — | Open shadow roots are captured. Closed ones cannot be read at all. |
Native UI | blank | display-media | Select dropdowns while open, the print dialog, browser chrome, extensions — none of it is in the DOM. |
Where the table says display-media, switching engine fixes it: that path captures real pixels including everything above. The trade is a browser permission prompt, and the user choosing what to share.
Browser support
Chrome, Edge, Firefox and Safari, current and previous major versions. PDF output uses CompressionStream where available and falls back to embedding a JPEG where it is not, so the file is always valid — just larger on older Safari. There is no IE support and there will not be.
Retention — three days, seven at most
Captures are deleted from our storage after three days. Set retention_days on a project to choose anything from one to seven. Seven is the ceiling for every account and no plan raises it — we hold pictures of your users’ screens, and a window we can defend is worth more than one that flatters a pricing page.
What that means in practice:
| Option | Type | Default | Description |
|---|---|---|---|
Signed URLs | string | capture life | Clamped to the life of the file they point at, so a project on three-day retention never receives a seven-day link. A URL that looks valid and 404s when you finally use it is worse than a short one. |
Failed deliveries | resendable | capture life | Kept with their response so you can resend by hand for as long as the files exist. Nothing retries automatically. |
Capture record | row | 3–7 days | Removed with the files. Dashboard history goes with it — usage counts survive, aggregated by day. |
So if you need a capture kept, copy it somewhere of your own when the webhook arrives. Do not treat a signed URL as durable storage and do not store one for later — fetch the capture by id with a secret key instead, which mints a fresh URL for as long as the capture exists.
Size and quota
Output is capped at maxDimension pixels per side (16384 by default) because browsers refuse to allocate canvases much beyond that. A very long page is scaled to fit rather than failing. Per-project monthly capture limits are set in Settings; exceeding one returns rate_limited rather than silently dropping captures.
Content Security Policy
The SDK inlines images and fonts as data URLs and rasterizes through an SVG foreignObject, so a strict CSP needs to allow both:
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self' https://api.screen2api.com;If you load the bundle from our CDN rather than your own, add https://screen2api.com to script-src and keep the integrity hash — it is what makes that safe.
REST API
Everything the SDK does over the network is a plain HTTP API you can call yourself. Use a secret key from a server; these endpoints are not origin-restricted and a sk_ key in a browser is a compromised key.
| Option | Type | Default | Description |
|---|---|---|---|
POST /api/v1/captures | pk or sk | — | Create a capture and receive signed upload URLs for each format. This is what the SDK calls before it uploads. |
POST /api/v1/captures/:id/complete | pk or sk | — | Mark the uploads finished. This is what queues the webhook — a capture never completed is never delivered. |
GET /api/v1/captures/:id | sk | — | Fetch a capture with freshly signed URLs. Use this rather than storing the webhook URLs, which expire. |
GET /api/v1/config | pk | — | The project’s server-side defaults — formats, editor config, redaction selectors, max scale. The SDK fetches this once per key. |
POST /api/v1/files | sk | — | Open a document with the file API. Returns an id and page count. |
GET /api/v1/files/:id | sk | — | Metadata, page count and status for an opened file. |
GET /api/v1/files/:id/targets | sk | — | The redactable regions we found — form fields, text matches — with their coordinates. |
GET /api/v1/files/:id/preview | sk | — | A rendered page image, for showing the user what they are about to export. |
POST /api/v1/files/:id/export | sk | — | Export with redactions applied. Returns a signed URL and the hashes for both input and output. |
DELETE /api/v1/files/:id | sk | — | Delete the file and its exports immediately, rather than waiting for the TTL. |
curl -X POST https://api.screen2api.com/api/v1/captures \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"formats": ["png"],
"params": { "invoiceId": "inv_1042" },
"width": 1200,
"height": 1600
}'Send an Idempotency-Key on anything that creates something. Retrying with the same key returns the original result instead of making a second capture, which matters when a request times out and you cannot tell whether it landed.
Error shape
Every failure has the same body, and the code is stable:
{ "error": "rate_limited", "message": "Monthly capture limit reached." }| Option | Type | Default | Description |
|---|---|---|---|
400 | invalid_request | — | Malformed body or missing field. |
401 | unauthorized | — | Missing, malformed or revoked key. |
403 | forbidden_origin | — | Live publishable key used from an unlisted origin. |
404 | not_found | — | No such id in this project. Cross-project ids are 404, never 403. |
429 | rate_limited | — | Rate limit or monthly quota. Honour Retry-After. |
5xx | server_error | — | Ours. Safe to retry with the same idempotency key. |
Verifying a webhook
The screen2api-signature header is t=<unix>,v1=<hex>, an HMAC-SHA256 over <t>.<raw body>. Verify against the raw body — parsing and re-serializing changes the bytes and the signature will never match. Compare in constant time, and reject anything with more than five minutes of skew or a replayed request is a valid one.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('=')),
)
const timestamp = Number(parts.t)
// Five minutes of tolerance. Without this check the signature stays
// valid forever and a captured request can be replayed at leisure.
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1 ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}Answer 2xx quickly — we treat anything else as a failure and retry six times with backoff. Do the slow work after you have replied, or you will get duplicates. Every attempt, with its status and body, is on the Webhooks page.
Troubleshooting
It works locally and fails in production
The deployed origin is not on the allowlist. A pk_test_ key skips the check, which is exactly why it worked on localhost. Add the origin in Settings, scheme and port included.
Part of the capture is blank
Almost always a cross-origin iframe, a video, or a tainted canvas — see Limits. Check result.warnings, which names what could not be inlined. Switching to engine: 'display-media' captures all of it at the cost of a permission prompt.
The text renders in the wrong font
A webfont that could not be inlined, usually because it is served from a CDN without Access-Control-Allow-Origin. Self-host the font, or add the header. Rasterization happens inside an SVG foreignObject, which cannot make external requests — anything not inlined is not available at all.
The capture is cut off
Check area. viewport deliberately stops at the fold. For an element taller than the window you want element, and for the full page document. If output is being scaled down instead, you have hit maxDimension.
Captures are slow
Call preload() when the user hovers your export button — most of the cost is fetching and inlining fonts and images, and that work is cacheable. Then look at result.timings to see which stage is actually expensive. Dropping scale from 3 to 2 removes over half the pixels.
The webhook never arrives
Three things, in order: the capture must have been completed (an abandoned upload never fires), the endpoint must be enabled and subscribed to the event, and your server must answer 2xx within the timeout. Every attempt and response is on the Webhooks page — start there rather than guessing.
A capture URL that worked yesterday now 404s
Captures are deleted after three days by default, and signed URLs are clamped to the same window — see Retention. Raise retention_days to as much as seven if that is too tight, but if you need it to outlive that, copy the bytes somewhere of your own when the webhook arrives. Storing the URL and fetching it later will not work.
A delivery failed and nothing happened
That is the design — we deliver once and leave the retry to you, so a handler that half-succeeded isn’t run again behind your back. Fix the cause, then press Resend on the Webhooks page; the response code and body from the failed attempt are listed there to tell you what to fix.
Some captures never produce a webhook
Check for webhook: false — per call, on autoBind, on the client config, or as data-s2a-webhook on the element. The response from complete says skipped: true when delivery was turned off, which distinguishes it from having no endpoints configured.
Signature verification always fails
You are almost certainly verifying against a parsed body. Frameworks that JSON-parse before your handler runs change the bytes; you need the raw buffer. In Express that means express.raw() on this route, and in Next.js reading await request.text() before parsing.