Delivery
How a finished capture reaches you, and how to run one when nobody is at the keyboard.
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.
Scheduled captures
A schedule sends your endpoint a signed capture.scheduled webhook on an interval. Your handler takes the capture and uploads it through the normal API. Set them up under Webhooks in the dashboard.
{
"event": "capture.scheduled",
"project_id": "prj_…",
"schedule": {
"id": "sch_…",
"name": "Nightly revenue PDF",
"params": { "target": "#revenue", "formats": ["pdf"] },
"interval_minutes": 1440,
"last_run_at": "2026-08-14T02:00:00.000Z"
}
}Why we ask you rather than doing it ourselves
The obvious design is that we render your authenticated page from our servers on a timer. We deliberately do not, because it would mean storing something that can log in as your user — a session cookie, a token, a password.
The whole position of this product is that nothing sensitive reaches us: captures render in your user’s own browser and we only ever see the picture they chose to send. Holding credentials would invert that, and it would make the security page untrue. So you get the timer, and you keep the credentials.
In practice your handler does what your app already can — open the page in a session it controls, or render server-side — then calls the REST API with the result. The payload carries whatever params you set on the schedule, which is how it says which capture is due.
What to expect from the timing
- Intervals run from 15 minutes to 30 days. Not cron expressions — the cases people actually want are hourly, daily and weekly, and a cron parser is a dependency and a support burden for expressiveness nobody has asked for.
- A new schedule first fires one interval after you create it, not immediately.
- The next run is calculated from when it fired, so a job that was down for a day resumes on schedule rather than firing twenty-four times catching up.
- A schedule advances before the webhook is sent. A send that fails waits for the next interval rather than retrying — the same one-delivery rule as every other webhook here, and you can resend from the dashboard.