Batch and async renders
A render is a browser loading somebody else's page, and some pages take their time about it. Asking for one synchronously means holding a connection open for as long as that takes, which is fine from a script and awkward from a web request that has its own timeout to answer to. The asynchronous endpoints take the waiting off your side of the wire: you hand us a set of captures, we answer immediately, and you collect the results when they are ready.
When to use it
Two situations, and both of them are about time rather than about volume.
The first is a page that is slow to settle. A dashboard that waits on three API calls, a report that renders a
chart, anything you have to give a delay or a waitForSelector to. Synchronously that
is a request your own stack has to be willing to sit on, and the timeout that gives up first is usually not
ours. Asked for asynchronously, the render takes exactly as long as it takes and nothing on your side is
waiting for it.
The second is a set of captures rather than one: thumbnails for every entry on a list page, an archive of the pages that changed overnight. Done synchronously, that is a queue of your own with a pause between calls tuned to a render limit you have to know, retries for the items that met it anyway, and somewhere to keep the answers. Submitted as a batch, it is one request and one thing to collect. We do the pacing.
What it is not for is a capture you are about to show someone. A synchronous GET /snap answers
with the file itself, which is one round trip rather than a submission, a poll and a fetch.
Submitting a batch
POST /v1/renders with your bearer token and a JSON body. items is the list of
captures, at most 20 captures in one submission, and each item takes the same parameters as a
screenshot or a
PDF request. webhookUrl is optional and
says where to tell you the batch is done.
curl "https://api.screenshotbuddy.io/v1/renders" \
-H "Authorization: Bearer {token}" \
-H "Content-Type: application/json" \
-d '{"items":[{"url":"https://example.com/pricing","width":1200,"height":630},{"url":"https://example.com/blog","fullPage":true,"cacheTtl":86400}],"webhookUrl":"https://example.com/hooks/renders"}'
Written out, the body is this. Every field of an item is optional except url, and an item that
contradicts itself is refused exactly as the same query string would be over GET /snap: the rules
are not a looser second copy, they are the same rules.
{
"items": [
{
"url": "https://example.com/pricing",
"width": 1200,
"height": 630
},
{
"url": "https://example.com/blog",
"fullPage": true,
"cacheTtl": 86400
}
],
"webhookUrl": "https://example.com/hooks/renders"
}
The answer is a 202 carrying the whole batch document, every item queued. It is the
same document the poll endpoint serves, so you learn the shape of the answer from the answer to your own
submission, and you can start polling with the id in front of you.
When something is wrong with an item, the 422 names it under the index of the item it belongs to,
as items.0.width. Nothing is queued and nothing is charged: a batch is accepted whole or not at
all.
The submission itself renders nothing and reads nothing, so it spends neither of your per-minute budgets. It carries a small throttle of its own instead, 10 submissions per minute, which is generous for something that queues 20 renders at a time.
What an item may not ask for
Three rules exist here and nowhere else, and all three are about the render being asked for in advance rather than about the capture.
Caching may not be turned off. There is no response to put a rendering in, so an asynchronous
result is delivered through your cache: we render the page,
keep it, and hand you a link that fetches it. With cache=0 there would be nothing left to collect,
so an item that sends it is refused. Send that capture to GET /snap instead, where the rendering
travels back in the answer.
A cacheTtl has a higher floor. At least 3600 seconds, rather
than the seconds the synchronous endpoint allows. A rendering that lapsed while its own batch was still
draining would be one you could never collect, and an hour is comfortably longer than the quarter of an hour an
item may spend waiting for room to run.
Two items may not describe the same capture. Same URL, same options, in one submission: that is one render, and queueing it twice means two workers racing for it. Whichever lost the race would render the page a second time and take a second credit for it, so the batch is refused with a message naming the item it duplicates.
There is one more bound, on the account rather than on the item. At most 100 items may be
outstanding across all of your batches at once. It is counted that way, rather than per batch, because ten
batches of twenty is the same amount of queued work as one batch of two hundred. A submission that would take
you past it is refused with a 422 saying how many you already have waiting, which is a signal to
collect some results rather than to retry.
How your renders are paced
A batch buys you no extra capacity. Every item is counted against the same renders per minute your plan allows, and an account submitting twenty items renders them at the pace it would have rendered them at one by one.
What changes is who does the waiting. An item that meets your per-minute limit is not failed: it goes back to
the queue, waits out the number of seconds the limit reports, and asks again, for up to a quarter of an hour.
That is the whole reason to send a set rather than a loop. A daily or monthly ceiling is different, because
waiting is not what fixes it, so an item that meets one of those is failed with
rate_limited and the message that says which ceiling it was.
Items that are already in your cache are answered from it without rendering at all, which costs neither a credit nor a render slot. A batch made mostly of captures you already hold therefore drains far faster than the render limit alone suggests.
Polling for the result
GET /v1/renders/{id} with your bearer token answers with the batch as it stands, whatever
state that is. It renders nothing and carries no throttle of its own, so poll it as often as suits you. A batch
that is not yours, an id that never existed and a batch that has been deleted all answer 404
alike, so the endpoint says nothing about which ids are real.
{
"id": "01k1x8w0k3n6qv2r7y9c4h5t8m",
"status": "pending",
"created_at": "2026-08-05T10:15:00+00:00",
"finished_at": null,
"prunes_at": "2026-08-12T10:15:00+00:00",
"webhook": {
"url": "https://example.com/hooks/renders",
"delivered_at": null
},
"items": [
{
"id": "01k1x8w0k4a2be7d9f1g3h5j7k",
"url": "https://example.com/pricing",
"status": "done",
"error": null,
"etag": "\"8f14e45fceea167a\"",
"target_status": 200,
"expires_at": "2026-08-06T10:16:04+00:00",
"signed_url": "https://api.screenshotbuddy.io/v1/snap/signed?url=https%3A%2F%2Fexample.com%2Fpricing&width=1200&height=630&tokenId=42&expires=1786097764&signature=6f1c...",
"credit_cost": 1
},
{
"id": "01k1x8w0k5m4np6qr8s0t2u4v6",
"url": "https://example.com/blog",
"status": "failed",
"error": {
"code": "target_unreachable",
"message": "The target URL could not be reached or loaded. Check that it is publicly available and try again."
},
"etag": null,
"target_status": null,
"expires_at": null,
"signed_url": null,
"credit_cost": 0
},
{
"id": "01k1x8w0k6w8xy0za2b4c6d8e0",
"url": "https://example.com/changelog",
"status": "queued",
"error": null,
"etag": null,
"target_status": null,
"expires_at": null,
"signed_url": null,
"credit_cost": null
}
]
}
The batch's own status is pending until every item has finished and
complete afterwards. Each item carries a status of its own.
-
Status
queuedMeaningWaiting for a worker, or waiting out your per-minute render limit before trying again.
-
Status
renderingMeaningA browser is loading the page right now.
-
Status
doneMeaningThe page was rendered and kept. There is a result to fetch.
-
Status
failedMeaningThe render was refused or did not finish. The error field says why, and no credit was kept.
done and failed are the terminal ones. The set may grow, so treat a status you do not
recognise as not finished yet rather than as a failure; that is the one branch that keeps working when it does.
A finished item also reports target_status, the status the captured page itself answered, which is
the X-Target-Status of a synchronous
answer under another name: a page that served 404 renders and bills like any other, so this is what
tells a screenshot of your page from a screenshot of a sign-in form. It is null when we were not
told one, which covers an item that has not finished, a PDF item and one answered out of a rendering cached
before this was recorded.
A failed item carries an error object with the same code and message an
error envelope carries, from the same published set. One
branch handles a synchronous refusal and an asynchronous one, and the
missing_ability you would have seen on a
403 is the same code here when the token that submitted the batch was revoked, rotated or narrowed
away from the mode the item asked for.
Fetching the results
A finished item carries a signed_url. It is an ordinary
signed URL, minted for you rather than by you, and
opening it fetches the rendering: no bearer token, no credit, and it can go straight into an
<img src> or a download.
It is signed with the token that is doing the polling, not with the one that submitted the batch, so the link carries the permissions of whoever is asking now and stops working when their token is revoked or rotated. Its expiry never outlives the cached rendering behind it, which is what makes it free to open: a link that outlived its entry would render the page again and take another credit, charged to you and spent by whoever you forwarded it to.
signed_url is null in three situations, and none of them means the result is gone.
The item has not finished; or the cached rendering has lapsed, which expires_at told you was
coming; or the token you polled with has no signing secret to mint a link with, which is the case for a browser
session. In every one of them the other way of fetching the result still works: repeat the identical
GET /v1/snap request the item was submitted as. While the
rendering is still cached that is a hit, so it costs no credit and comes out of the far larger
cached-answer budget rather than out of your renders.
The webhook
Send a webhookUrl with the submission and we post the finished batch to it once, when every item
is terminal, rather than one message per item. The payload is exactly the document the poll endpoint serves,
built by the same code, so an integration that reads the webhook and falls back to polling is reading one shape
twice.
The URL has to be an http:// or https:// address on a publicly reachable host, HTTPS
in production. It is checked again at delivery rather than only at submission, because a hostname that resolved
publicly when you submitted can resolve somewhere internal by the time the batch finishes.
Three headers travel with each delivery.
-
Name
X-Webhook-IdTypestringDescriptionThe id of the batch. It is the same value on every attempt at delivering one batch, so it is what to record and compare against if you want to be sure you handle a delivery once.
-
Name
X-Webhook-TimestampTypeintegerDescriptionWhen the delivery was made, as a Unix timestamp in seconds. It is covered by the signature, so it cannot be moved without signing again.
-
Name
X-Webhook-SignatureTypestringDescriptionThe proof the delivery is ours: a lowercase hex HMAC-SHA256, 64 characters, keyed with the signing secret of the token the batch was submitted with.
The key is the same signing secret that signs a signed URL, shown once when you reveal it under Settings, API tokens. The signature is taken over three things joined together, in this order:
-
snap-webhook-v1\nThe literal string
snap-webhook-v1and a newline. It is there so that an HMAC of your signing secret can never be mistaken for anything else that key signs, a signed URL above all, and the version in it means a future change to what is signed can say so rather than quietly changing what an existing signature means. -
X-Webhook-Timestampand a newlineThe header value exactly as it arrived, as digits, followed by one newline.
-
the raw request body
The bytes as they arrived, before any JSON parsing. Parsing and re-encoding produces different bytes and therefore a different signature, so read the body first and verify it before you decode it.
<?php
function snapWebhookIsAuthentic(string $body, string $timestamp, string $signature, string $secret): bool
{
// Refuse a delivery that is more than five minutes old, so a signature
// somebody captured cannot be replayed at you tomorrow.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', "snap-webhook-v1\n" . $timestamp . "\n" . $body, $secret);
// Constant time: a normal comparison leaks how much of the signature was
// right, one character at a time.
return hash_equals($expected, $signature);
}
$body = file_get_contents('php://input');
if (! snapWebhookIsAuthentic(
$body,
$_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '',
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
getenv('SCREENSHOTBUDDY_SIGNING_SECRET')
)) {
http_response_code(403);
exit;
}
$batch = json_decode($body, true);
Answer with any 2xx and the delivery is done; the batch records the moment under
webhook.delivered_at. Anything else, or a connection that fails, is retried twice, after about
half a minute and then about five minutes, which covers a receiver being restarted or deployed. After the third
attempt we stop. That is deliberate: the webhook is a convenience rather than the record, and everything it
carried is still on the poll endpoint for as long as the batch is kept.
One case sends no webhook at all. If the token the batch was submitted with has been revoked or rotated by the time the batch finishes, there is no secret left to sign with, and posting the payload unsigned would hand your endpoint a document it has no way to verify. Polling with a current token still works.
What it costs
A credit per rendered item, exactly as a synchronous call. Each item reports what it actually cost as
credit_cost: 1 for a render, 0 for an item answered from the cache and
for an item that failed, because a failed render is refunded. It is null until the item has
finished, since an item that has not run yet has not decided which of those it will be.
Submitting costs nothing, polling costs nothing, and opening a signed_url while the rendering is
still cached costs nothing. Adding up the credit_cost of every item is therefore the whole bill
for a batch.
Credits are checked per item at render time rather than once at submission, which is what a batch that outlives its allowance needs: the items you could pay for are rendered, and the ones you could not are failed with the billing code that says so. Topping up and submitting those again is all it takes.
How long a batch is kept
A batch is deleted 7 days after it finishes, and every answer says when that will be as
prunes_at. A batch that is still running is told the earliest it could go, which moves out as the
batch takes longer. After that moment the id answers 404, so treat prunes_at as the
deadline for reading anything out of a batch you still care about.
A week is sized for the case the webhook exists for failing entirely: your endpoint was down over a weekend and
you come back to it on Monday. It is not an archive, and it is not the lifetime of the renderings themselves,
which is the cacheTtl each item asked for and is reported per item as expires_at. The
two run out at their own pace, and a batch record whose renderings have lapsed still tells you what was
rendered and what it cost.
Get one capture right before you queue twenty of them
Every item of a batch takes the parameters a single request takes, so settle them in the browser and paste the finished set into your submission.