# Bulk screenshots

Start up to 100 screenshots with one call, then follow the whole batch with one status request or receive each result by webhook.

Send a list of requests in one `POST`. Each item in the list is a normal set of options.

```bash
curl -X POST "https://curlshot.com/api/v1/bulk" \
  -H "X-Access-Key: YOUR_ACCESS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "requests": [
      { "url": "https://example.com" },
      { "url": "https://en.wikipedia.org/wiki/Eiffel_Tower", "full_page": true, "format": "jpeg" },
      { "url": "https://news.ycombinator.com", "viewport_device": "iphone_15_pro" }
    ]
  }'
```

You get an answer at once, with one job for each request:

```json
{
  "batch_id": "batch_fb25e0f51f9e8897ae9e",
  "batch_url": "https://curlshot.com/api/v1/batches/batch_fb25e0f51f9e8897ae9e",
  "jobs": [
    {
      "job_id": "job_54bd07ee26b45aea296beb07",
      "status": "queued",
      "job_url": "https://curlshot.com/api/v1/jobs/job_54bd07ee26b45aea296beb07",
      "status_url": "https://curlshot.com/api/v1/jobs/job_54bd07ee26b45aea296beb07"
    },
    {
      "job_id": "job_46a76fd421d91c315971efc6",
      "status": "queued",
      "job_url": "https://curlshot.com/api/v1/jobs/job_46a76fd421d91c315971efc6",
      "status_url": "https://curlshot.com/api/v1/jobs/job_46a76fd421d91c315971efc6"
    },
    {
      "job_id": "job_0c1f6b2e9d7a4c35b8e0f914",
      "status": "queued",
      "job_url": "https://curlshot.com/api/v1/jobs/job_0c1f6b2e9d7a4c35b8e0f914",
      "status_url": "https://curlshot.com/api/v1/jobs/job_0c1f6b2e9d7a4c35b8e0f914"
    }
  ]
}
```

The jobs come back in the same order as your `requests`. So the first job belongs to `example.com`, the second to the Wikipedia article, and the third to Hacker News. Your ids will differ from these samples.

`batch_url` is the address that reports on the whole batch. `job_url` and `status_url` are one address under two names, and report on a single job.

## How bulk works

A bulk call is a shortcut for many [async](https://curlshot.com/docs/async-and-webhooks.md) requests. Nothing is rendered while you wait. The pages are rendered in the background, and you collect the results afterwards.

Each request in the list is its own job. It has its own options, its own result and its own status. One job can fail while the others succeed.

The `batch_id` is a label shared by every job from the same call.

## The request body

| Field | Required | Meaning |
| --- | --- | --- |
| `requests` | yes | A list of option sets. Up to 100 per call. |
| `webhook_url` | no | An address that receives the result of every job in this call. An item can carry its own `webhook_url`, which wins for that item. |

Inside `requests`, each item takes the same options as the [screenshot endpoint](https://curlshot.com/docs/screenshot-url.md), written as JSON. Every option on the [options reference](https://curlshot.com/docs/options.md) works.

Because the body is JSON, use real JSON types. Write `"full_page": true` and `"viewport_width": 1280`, without quotes around the value. Lists can be real lists, for example `"hide_selectors": [".ad", "#chat"]`.

Each item needs exactly one of `url`, `html` or `markdown`, the same as a single request.

> **Keep the key in a header**
>
> In a bulk call, send your access key in the `X-Access-Key` header, or as `Authorization: Bearer YOUR_ACCESS_KEY`. That keeps it out of the address. See [Authentication and API keys](https://curlshot.com/docs/authentication.md).

## Follow the whole batch with one call

Ask the `batch_url`. One request tells you how far the batch is and returns every job in it.

```bash
curl "https://curlshot.com/api/v1/batches/batch_fb25e0f51f9e8897ae9e" \
  -H "X-Access-Key: YOUR_ACCESS_KEY"
```

```json
{
  "batch_id": "batch_fb25e0f51f9e8897ae9e",
  "batch_url": "https://curlshot.com/api/v1/batches/batch_fb25e0f51f9e8897ae9e",
  "status": "done",
  "total": 3,
  "counts": { "queued": 0, "processing": 0, "done": 3, "failed": 0 },
  "jobs": [
    {
      "job_id": "job_54bd07ee26b45aea296beb07",
      "batch_id": "batch_fb25e0f51f9e8897ae9e",
      "status": "done",
      "job_url": "https://curlshot.com/api/v1/jobs/job_54bd07ee26b45aea296beb07",
      "screenshot": {
        "id": "0d03cca8e56b496b9ae71d02a4d20675",
        "url": "https://curlshot.com/api/v1/files/0d03cca8e56b496b9ae71d02a4d20675.png?expires=1791155499&token=wLR-uTrZnw9uU18PyJCR2NZS1azZtN_4Y6z98lyhejg",
        "format": "png",
        "bytes": 81876,
        "width": 1280,
        "height": 1024,
        "render_ms": 263,
        "cached": false,
        "expires_at": "2026-10-04T23:11:39.275Z"
      },
      "error_code": null,
      "error_message": null,
      "created_at": "2026-10-03T23:11:38.983Z",
      "finished_at": "2026-10-03T23:11:39.275Z"
    }
  ]
}
```

The sample shows the first job only. The real `jobs` list holds all three, in the order of your `requests`.

| Field | Meaning |
| --- | --- |
| `status` | `processing` while any job is still waiting or rendering. `done` when every job has ended, whether it worked or failed. |
| `total` | How many jobs the batch holds. |
| `counts` | How many jobs are in each state: `queued`, `processing`, `done` and `failed`. |
| `jobs` | Every job, in the same form as a [single job answer](https://curlshot.com/docs/async-and-webhooks.md#get-the-result-by-polling). |

A batch with `"status": "done"` can still hold failed jobs. Look at `counts.failed`, then at the `error_code` of each job.

Download each file from its `screenshot.url`. That link needs no access key, and it stops working at `expires_at`. See [Async and webhooks](https://curlshot.com/docs/async-and-webhooks.md#get-the-result-by-polling).

An unknown `batch_id`, or one from another account, answers [`job_not_found`](https://curlshot.com/docs/errors.md#job_not_found) with status `404`.

Here is a small loop that waits for a whole batch. Polling means asking again every so often until the work is done.

```javascript
const headers = { 'X-Access-Key': 'YOUR_ACCESS_KEY' }
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

// `batchUrl` is the `batch_url` from the bulk response.
async function waitForBatch(batchUrl) {
  for (;;) {
    const batch = await (await fetch(batchUrl, { headers })).json()
    if (batch.status === 'done') return batch.jobs
    await sleep(2000)
  }
}
```

### Batch or single job

Use `batch_url` when you want the whole set: one request per round, however many jobs there are.

Use a `job_url` when you care about one job, for example to show its result as soon as it is ready:

```bash
curl "https://curlshot.com/api/v1/jobs/job_54bd07ee26b45aea296beb07" \
  -H "X-Access-Key: YOUR_ACCESS_KEY"
```

The answer is the job object you saw inside `jobs` above.

Both calls are free. They have their own, larger [rate limit](https://curlshot.com/docs/usage-and-limits.md#status-calls-have-their-own-rate-limit), separate from your renders.

## Collect the results by webhook

With a webhook you do not poll at all. A webhook is an address on your server that we call when a job ends.

Put `webhook_url` next to `requests`. It applies to every job in the call.

```bash
curl -X POST "https://curlshot.com/api/v1/bulk" \
  -H "X-Access-Key: YOUR_ACCESS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook_url": "https://your-app.example/hooks/screenshot",
    "requests": [
      { "url": "https://developer.mozilla.org", "dark_mode": true },
      { "url": "https://github.com/microsoft/playwright", "block_cookie_banners": true }
    ]
  }'
```

You receive one `POST` per job, not one for the whole batch. Each message carries the `batch_id`, so you can group them:

```json
{
  "event": "screenshot.completed",
  "job_id": "job_54bd07ee26b45aea296beb07",
  "batch_id": "batch_fb25e0f51f9e8897ae9e",
  "status": "done",
  "job_url": "https://curlshot.com/api/v1/jobs/job_54bd07ee26b45aea296beb07",
  "screenshot": {
    "id": "0d03cca8e56b496b9ae71d02a4d20675",
    "url": "https://curlshot.com/api/v1/files/0d03cca8e56b496b9ae71d02a4d20675.png?expires=1791155499&token=wLR-uTrZnw9uU18PyJCR2NZS1azZtN_4Y6z98lyhejg",
    "format": "png",
    "bytes": 301447,
    "width": 1280,
    "height": 1024,
    "render_ms": 1980,
    "cached": false,
    "expires_at": "2026-10-04T23:11:41.000Z"
  },
  "error_code": null,
  "error_message": null,
  "created_at": "2026-10-03T23:11:38.983Z",
  "finished_at": "2026-10-03T23:11:41.102Z"
}
```

The messages can arrive in any order, because the jobs finish at different times. Match them to your requests by `job_id`.

Each message is signed with the `x-signature` and `x-timestamp` headers. [Check that the webhook is real](https://curlshot.com/docs/async-and-webhooks.md#check-that-the-webhook-is-real) before you trust it.

> **Tip**
>
> Save the `job_id` of every job when the bulk call returns, together with your own record id. When a webhook arrives, you can look up which of your records it belongs to.

## When an item is wrong

The whole list is checked before anything is queued. If one item is wrong, no job is started and nothing is counted.

Here the second item has no `https://` in front of its address:

```bash
curl -X POST "https://curlshot.com/api/v1/bulk" \
  -H "X-Access-Key: YOUR_ACCESS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "requests": [
      { "url": "https://example.com" },
      { "url": "example.com" }
    ]
  }'
```

```json
{
  "error_code": "invalid_options",
  "error_message": "requests[1].url: must be a full URL starting with http:// or https://",
  "documentation_url": "https://curlshot.com/docs/errors#invalid_options",
  "errors": [
    { "index": 1, "field": "url", "message": "must be a full URL starting with http:// or https://" }
  ]
}
```

The `errors` list has one entry per problem. `index` is the position of the item in your `requests` list, counted from `0`. So `1` is the second item. `field` is the option that is wrong.

Fix those items, or take them out, and send the call again.

A different code, [`invalid_request`](https://curlshot.com/docs/errors.md#invalid_request), means the body as a whole could not be used. You get it when the body is not valid JSON, when `requests` is missing or empty, or when the list is longer than the limit.

```json
{
  "error_code": "invalid_request",
  "error_message": "The request body is not valid JSON.",
  "documentation_url": "https://curlshot.com/docs/errors#invalid_request"
}
```

## Sign a bulk call

If your key has **Require signature** turned on, a bulk call must be signed too. You sign two things: your access key, and `body_sha256`, a fingerprint of the exact body you send. That way nobody can reuse your signature with a different list.

The steps and a working sample are on the [Signed links](https://curlshot.com/docs/signed-links.md#sign-a-bulk-call) page.

## What it costs

Each job is counted on its own. A job that renders successfully uses one screenshot from your [quota](https://curlshot.com/docs/usage-and-limits.md). A job that fails is free.

So a bulk call with 100 requests uses at most 100 screenshots. The bulk call itself costs nothing extra.

The batch must fit in what you have left. If it needs more screenshots than remain in this period, the call answers [`quota_exceeded`](https://curlshot.com/docs/errors.md#quota_exceeded) and nothing is queued. Check [`GET /usage`](https://curlshot.com/docs/usage-and-limits.md) before a big batch.

[Caching](https://curlshot.com/docs/caching.md) works inside bulk too. Add `"cache": true` to an item, and a stored copy is returned for free when that same request was made before.

A large batch is not rendered all at once. Jobs wait in a queue and are rendered as slots become free, so the last job of a big batch finishes later than the first.

## How a batch counts against your limits

A bulk call is a short way to send many requests, and it is counted that way.

- **Rate limit.** The call counts as one request per item. A batch of 25 uses 25 of your requests for this minute. If that many are not left, the call answers [`rate_limited`](https://curlshot.com/docs/errors.md#rate_limited) with a `Retry-After` header and nothing is queued. A batch that is larger than your whole per-minute limit is still possible: it is accepted when you have sent nothing else in the last minute or two, and it uses the whole minute.
- **Queue.** An account can have only so many jobs waiting or rendering at one time. A batch that would go over answers [`queue_limit`](https://curlshot.com/docs/errors.md#queue_limit), status `429`, and nothing is queued. Wait for running jobs to finish, then send it again.
- **Concurrency.** Your jobs render at most as many at once as your plan's concurrency, however many renderers we run. The rest wait their turn.
- **Turns.** The queue is shared by all customers, and it serves accounts in turn: everyone's first waiting job, then everyone's second, and so on. A batch of 100 does not hold up somebody else's single job, and somebody else's batch does not hold up yours.

The numbers behind these limits are on the [Usage and limits](https://curlshot.com/docs/usage-and-limits.md#fair-use-limits) page.

Bulk is part of your plan. A plan can allow fewer than 100 items per call, or no bulk calls at all, in which case the answer is [`feature_not_available`](https://curlshot.com/docs/errors.md#feature_not_available). The limits of each plan are on the [pricing page](https://curlshot.com/pricing).

> **Common mistakes**
>
> - **More than 100 requests in one call.** That returns `invalid_request`. Split a longer list into several calls.
> - **You send batch after batch without a pause.** Each item counts against your rate limit, and waiting jobs count against your queue. Read `Retry-After` on a `429` and wait that long.
> - **The options are text instead of JSON types.** `"full_page": "yes"` works, but `"viewport_width": "wide"` does not. Use `true`, `false` and plain numbers.
> - **You wait for one webhook for the whole batch.** There is one webhook per job. Count the messages with the same `batch_id`, or ask the `batch_url`.
> - **You rely on the order of the webhooks.** Jobs finish when they finish. Only the `jobs` lists in the API answers follow the order of your `requests`.
> - **You read `"status": "done"` on a batch as "everything worked".** It means "nothing is still running". Check `counts.failed`.

## Where to go next

- [Async and webhooks](https://curlshot.com/docs/async-and-webhooks.md): the job answer, the webhook payload and the signature check in detail.
- [Usage and limits](https://curlshot.com/docs/usage-and-limits.md): how to see how many screenshots you have left before a big batch.
- [Signed links](https://curlshot.com/docs/signed-links.md#sign-a-bulk-call): how to sign a bulk call.
- [How to archive pages as PDF](https://curlshot.com/docs/guides/archive-pages-as-pdf.md): a batch job from start to finish.
