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/sdk

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

OptionTypeDefaultDescription
pk_live_… / pk_test_…publishablebrowserShips 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_…secretserverFull 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:3000

pk_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> — including colspan and rowspan, 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-row and data-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 out

You 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.pdf

The 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, anywhere

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

ElementOffered
<table>, ARIA grid, or a wrapper round onecsv, png, pdf
<ul>, <ol>, <dl>csv, png
<img>, <canvas>, <svg>, <video>png
anything elsepng, 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.

OptionTypeDefaultDescription
targetstring | Element | (() => Element | null) | nullnullCSS 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.
formatsFileFormat[]['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.
scalenumberdevicePixelRatio, capped at 3Pixel 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.
qualitynumber (0–1)0.92For lossy formats only — jpeg and webp. Ignored by png and svg.
backgroundstring | nullthe element's own backgroundPainted under the capture. Pass null for transparency, which png and webp keep and jpeg cannot.
excludestring[][]Selectors removed from the capture entirely, as though they were never in the page.
redactstring[][]Selectors pixelated before encoding. The original pixels are destroyed in the browser and never transmitted.
maxDimensionnumber16384Hard 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.
waitForAssetsbooleantrueWait for webfonts and images to settle before capturing. Turning it off is faster and risks capturing a half-loaded page.
assetTimeoutnumber (ms)3000How long to wait before giving up on assets and capturing anyway. A capture missing one image beats no capture.
tolerateAssetErrorsbooleanfalseSkip cross-origin images that fail to fetch instead of failing the capture. Worth enabling if you embed third-party avatars.
paramsRecord<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.
webhookbooleantrueDeliver 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.
captionstringPreset the caption, or read what the end user typed from the result.
filenamestringderived from document.titleStem for downloads and uploads. The extension is added per format.
onProgress(progress: number, stage: string) => voidCalled with 0–1 and a stage label. Worth wiring for whole-page captures, which can take a second or two.
csvCsvOptionsSettings 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 holds

Object 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

OptionTypeDefaultDescription
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?)() => voidWire a button to a capture. Returns a function that unbinds it — call it on unmount.
autoBind(options?, root?)() => voiddocumentActivate 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)thisMerge new settings into an existing client without recreating it. Useful when the key arrives after your app boots.

Editor options

OptionTypeDefaultDescription
enabledbooleanfalseShow the annotation editor before sending. Can also be turned on project-wide from the dashboard without a frontend deploy.
toolsEditorTool[]allWhich tools appear, in order: select, pen, arrow, line, rect, ellipse, highlight, text, step, spotlight, blur, crop.
defaultToolEditorTool'arrow'Preselected when the editor opens.
caption / captionPlaceholderboolean / stringtrueThe caption field under the canvas, and its placeholder text.
palette / defaultColorstring[] / stringStroke colours offered, and which one starts selected.
confirmLabel / cancelLabelstring'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.
allowFormatChoicebooleanfalseLet the end user pick which formats get exported.
branding{ show, prefix, label, logo, href }screen2apiReplace 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.

OptionTypeDefaultDescription
no_targetclientThe selector matched nothing. Usually a capture fired before the element rendered, or after it unmounted.
render_failedclientThe scene could not be rasterized. Check result warnings and the console; a tainted canvas or a cross-origin stylesheet is the usual cause.
encode_failedclientEvery requested format failed to encode. Nearly always a capture too large for the browser to allocate — lower scale or maxDimension.
upload_failednetworkThe 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.
unauthorizednetworkBad 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_limitednetworkToo many captures too quickly, or the monthly quota is spent. Back off and retry; the response carries Retry-After.
permission_deniedclientdisplay-media only: the user declined the screen-share prompt. Offer the dom engine instead.
cancelledclientThe capture was aborted in flight. Dismissing the editor does not raise this — that resolves null.
unsupportedclientCalled 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:

OptionTypeDefaultDescription
Cross-origin iframesblankdisplay-mediaSame-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 framesposter onlydisplay-mediaA <video> renders as its poster, not the current frame. Draw the frame to a canvas yourself first if you need it.
Tainted canvasesblankA canvas that has drawn a cross-origin image without CORS cannot be read back. Serve those images with Access-Control-Allow-Origin.
WebGL without preserveDrawingBufferblankThe buffer is cleared after each frame unless the context was created with preserveDrawingBuffer: true.
Shadow DOM (closed)blankOpen shadow roots are captured. Closed ones cannot be read at all.
Native UIblankdisplay-mediaSelect 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:

OptionTypeDefaultDescription
Signed URLsstringcapture lifeClamped 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 deliveriesresendablecapture lifeKept with their response so you can resend by hand for as long as the files exist. Nothing retries automatically.
Capture recordrow3–7 daysRemoved 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.

OptionTypeDefaultDescription
POST /api/v1/capturespk or skCreate a capture and receive signed upload URLs for each format. This is what the SDK calls before it uploads.
POST /api/v1/captures/:id/completepk or skMark the uploads finished. This is what queues the webhook — a capture never completed is never delivered.
GET /api/v1/captures/:idskFetch a capture with freshly signed URLs. Use this rather than storing the webhook URLs, which expire.
GET /api/v1/configpkThe project’s server-side defaults — formats, editor config, redaction selectors, max scale. The SDK fetches this once per key.
POST /api/v1/filesskOpen a document with the file API. Returns an id and page count.
GET /api/v1/files/:idskMetadata, page count and status for an opened file.
GET /api/v1/files/:id/targetsskThe redactable regions we found — form fields, text matches — with their coordinates.
GET /api/v1/files/:id/previewskA rendered page image, for showing the user what they are about to export.
POST /api/v1/files/:id/exportskExport with redactions applied. Returns a signed URL and the hashes for both input and output.
DELETE /api/v1/files/:idskDelete 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." }
OptionTypeDefaultDescription
400invalid_requestMalformed body or missing field.
401unauthorizedMissing, malformed or revoked key.
403forbidden_originLive publishable key used from an unlisted origin.
404not_foundNo such id in this project. Cross-project ids are 404, never 403.
429rate_limitedRate limit or monthly quota. Honour Retry-After.
5xxserver_errorOurs. 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.

Want to see it work first? Try the Screen API — that one runs entirely in your browser — or Try the File API, which drives these endpoints for real and needs one of your keys.