Async Jobs & Webhooks
Add async=true to any /v1/take request and the API answers at once with 202 and a job, instead of waiting for the render. The page is rendered in the background, with up to 300 seconds for slow pages, and you poll the job or receive a signed webhook when it is done.
Billing
A job is billed like the same request without async, when it is created (the X-Cost header of the 202). A job that fails, or that you delete before it starts, is refunded. Polling, listing, downloading and deleting jobs is free. A webhook that cannot be delivered does not change the job or its charge.
Create a job
Send the request you would send without async, with async=true (or "async": true in a POST body). Every attribute works as in a sync request: formats, PDF and markdown options, HTML input and upload.
Create a job
curl -G https://api.screenshotbase.com/v1/take \
-H "apikey: YOUR-API-KEY" \
-H "Idempotency-Key: order-42-screenshot" \
--data-urlencode "url=https://example.com" \
-d full_page=1 \
-d async=true \
--data-urlencode "webhook_url=https://example.com/webhooks/screenshotbase"
202 Accepted
{
"job_id": "631442883836252160",
"status": "queued",
"format": "png",
"external_identifier": null,
"quota": { "charged": 1, "refunded": 0 },
"result": null,
"error": null,
"webhook": { "status": "pending" },
"created_at": "2026-10-09T12:00:00+00:00",
"started_at": null,
"finished_at": null,
"expires_at": "2026-10-16T12:00:00+00:00",
"links": {
"self": "https://api.screenshotbase.com/v1/jobs/631442883836252160",
"result": null
}
}
The Location header of the 202 is the job's URL. What a sync request checks before it renders is checked when the job is created: the parameters, the target URL, your quota and the webhook_url, so those errors come back at once and nothing is billed.
Parameters
- Name
async- Type
- boolean
- Description
Set to 1 (or
true) to answer at once with202and a job instead of the result. Every other attribute ofGET /v1/takeandPOST /v1/takeworks as in a sync request. Default value is 0.
- Name
webhook_url- Type
- string
- Description
Only with
async: an https URL of a public host that receives a signed POST when the job is completed or failed (see webhooks). Maximum 2048 characters.
- Name
external_identifier- Type
- string
- Description
Only with
async: your own reference in printable ASCII, returned with the job, in its webhook and as theX-Screenshotbase-External-Identifierheader of the webhook. Maximum 255 characters.
- Name
Idempotency-Key- Type
- header
- Description
A request header, not a parameter: 1 to 255 printable ASCII characters without spaces. For 24 hours a request with the same key answers with the job the first one created (
Idempotent-Replayed: true, not billed again); the same key with another request answers422, and while the first request is still being answered,409.
Get the result
Poll GET /v1/jobs/{job_id} (free; about once a second at most) until status is completed or failed, or wait for the webhook. Then download the result from GET /v1/jobs/{job_id}/result: the same answer the request without async would have given, the image, the PDF, the markdown or, with upload=true, the JSON with the stored file's url. Before the job is completed it answers 409 with the error code job_not_completed.
cURL
curl https://api.screenshotbase.com/v1/jobs/631442883836252160 -H "apikey: YOUR-API-KEY"
curl https://api.screenshotbase.com/v1/jobs/631442883836252160/result -H "apikey: YOUR-API-KEY" -o example.png
A failed job carries error, what the request without async would have answered, for example {"status": 502, "code": "target_unreachable", "message": "..."}, and quota.refunded.
| Endpoint | What it does |
|---|---|
GET /v1/jobs | Your jobs, newest first: limit (up to 100), before (the last job_id of the previous page) and status. |
GET /v1/jobs/{job_id} | The job: status (queued, processing, completed, failed), result, error, quota. |
GET /v1/jobs/{job_id}/result | The result, kept for 7 days after the job finished (expires_at). |
DELETE /v1/jobs/{job_id} | Deletes the job and its result. A queued job is refunded; one that is rendering answers 202 and is deleted, refunded, when its render ends. |
Jobs belong to your account and to the kind of key that created them: the jobs of a sandbox key are only visible with a sandbox key, the others only with a live key.
Webhooks
With webhook_url, the job is sent to that URL as a signed POST when it is completed (screenshot.completed) or failed (screenshot.failed). In the dashboard, under Webhooks (team owners and admins), you can also add endpoints that receive these events for every job, send a test event, see every delivery and send one again.
Webhook body
{
"id": "whd_01ja2y6k7q9w4v8t5r3n1m0p2z",
"event": "screenshot.completed",
"created_at": "2026-10-09T12:00:07+00:00",
"data": { "job_id": "631442883836252160", "status": "completed", "...": "the job as GET /v1/jobs/{job_id} shows it" }
}
Every delivery has these headers:
| Header | |
|---|---|
X-Screenshotbase-Event | screenshot.completed or screenshot.failed |
X-Screenshotbase-Delivery | The delivery's id, the same on every retry: use it to ignore a delivery you already handled. |
X-Screenshotbase-Timestamp | Unix time of the attempt. |
X-Screenshotbase-Signature | v1= and the hex HMAC-SHA256 of <timestamp>.<body> with your signing secret. For 24 hours after you rotate the secret, a second v1= signature with the previous one follows, separated by a comma. |
X-Screenshotbase-External-Identifier | Your external_identifier, when the job has one. |
The signing secret of webhook_url deliveries is shown, and can be rotated, on the Webhooks page of the dashboard; every endpoint you add there has its own. Check the signature against the raw body before you parse it, and refuse old timestamps:
Verify a webhook (Node.js)
import crypto from 'node:crypto'
function verify(rawBody, headers, secret) {
const timestamp = headers['x-screenshotbase-timestamp']
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
return headers['x-screenshotbase-signature'].split(',').some((signature) =>
signature.trim().length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature.trim()), Buffer.from(expected)))
}
Verify a webhook (PHP)
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SCREENSHOTBASE_TIMESTAMP'];
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
$valid = abs(time() - (int) $timestamp) <= 300 && array_filter(
explode(',', $_SERVER['HTTP_X_SCREENSHOTBASE_SIGNATURE']),
fn ($signature) => hash_equals($expected, trim($signature)),
) !== [];
Answer with any 2xx status within 10 seconds. Any other answer, a timeout or a redirect counts as failed, and the delivery is tried again after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours (6 attempts over about 2.6 hours). The URL must be https on port 443 of a public host; it is checked again before every attempt.
Idempotency
Send an Idempotency-Key header with every create, so that a retry after a network error does not create (and bill) a second job. For 24 hours, a request with the same key answers with the job the first one created, with Idempotent-Replayed: true and at no cost; the same key with a different request answers 422, and while the first request is still being answered, 409 with Retry-After: 1.
Limits
- A job's render has 300 seconds from when it starts;
timeoutmay be up to 295 anddelayup to 30. - Your account may have its plan's renders at the same time times 10 jobs queued or rendering at once; past that, the create answers
429concurrency_limit_exceeded(withoutRetry-After: send it again when one of your jobs is done) and is not billed. Each account's jobs are rendered one at a time for now; the others wait in the queue. - A job that cannot start within 15 minutes, because the renderers are busy, fails with
503renderer_busyand is refunded. - Jobs, their results and their HTML input are deleted 7 days after the job finished.
When your quota is used up
Once the monthly quota of your plan is used up, every request with your API key answers 429 until the quota resets or you upgrade, the job endpoints included, so the result of the job that used your last request cannot be downloaded until then. Its webhook still goes out; with upload=true it carries the stored file's url.
Errors
Besides the errors of /v1/take, the job endpoints answer:
404not_foundfor a job that does not exist, belongs to another account or kind of key, was deleted, or whose result is no longer kept;409job_not_completedfor the result of a job that is not completed;409idempotency_key_in_usewhile the first request with the sameIdempotency-Keyis being answered;403forbiddenfor a key that belongs to no screenshotbase account.
AI agents (MCP)
On the MCP server, take and takePost accept async, and the tools listJobs, getJob, getJobResult and deleteJob work with jobs.