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

> Nothing about a render changes by asking for it this way. The same permissions, the same credit, the same cache and the same per-minute limits apply to every item.

## 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](https://screenshotbuddy.io/documentation/rate-limits.md) 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 https://api.screenshotbuddy.io/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](https://screenshotbuddy.io/documentation/taking-screenshots.md) or a [PDF](https://screenshotbuddy.io/documentation/creating-pdfs.md) request. `webhookUrl` is optional and says where to tell you the batch is done.

```bash
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.

```json
{
    "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](https://screenshotbuddy.io/documentation/caching.md): 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](https://screenshotbuddy.io/documentation/rate-limits.md) 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 https://api.screenshotbuddy.io/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.

```json
{
    "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 | Meaning |
| --- | --- |
| `queued` | Waiting for a worker, or waiting out your per-minute render limit before trying again. |
| `rendering` | A browser is loading the page right now. |
| `done` | The page was rendered and kept. There is a result to fetch. |
| `failed` | The 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`](https://screenshotbuddy.io/documentation/taking-screenshots.md) 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](https://screenshotbuddy.io/documentation/errors.md) 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](https://screenshotbuddy.io/documentation/signed-urls.md), 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 https://api.screenshotbuddy.io/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.

| Header | Type | Description |
| --- | --- | --- |
| `X-Webhook-Id` | string | The 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. |
| `X-Webhook-Timestamp` | integer | When the delivery was made, as a Unix timestamp in seconds. It is covered by the signature, so it cannot be moved without signing again. |
| `X-Webhook-Signature` | string | The 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](https://screenshotbuddy.io/documentation/signed-urls.md), shown once when you reveal it under [Settings, API tokens](https://screenshotbuddy.io/settings/api-tokens). The signature is taken over three things joined together, in this order:

1. `snap-webhook-v1\n`, the literal string `snap-webhook-v1` and 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.
2. The `X-Webhook-Timestamp` header value exactly as it arrived, as digits, followed by one newline.
3. The raw request body, as the bytes 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
<?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.
