Skip to content

Type an option name like full_page, an error code, or a topic.

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.

Request
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:

Response, status 202
{
  "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 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

FieldRequiredMeaning
requestsyesA list of option sets. Up to 100 per call.
webhook_urlnoAn 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, written as JSON. Every option on the options reference 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.

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

Request
curl "https://curlshot.com/api/v1/batches/batch_fb25e0f51f9e8897ae9e" \
  -H "X-Access-Key: YOUR_ACCESS_KEY"
Response, when the batch is done
{
  "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.

FieldMeaning
statusprocessing while any job is still waiting or rendering. done when every job has ended, whether it worked or failed.
totalHow many jobs the batch holds.
countsHow many jobs are in each state: queued, processing, done and failed.
jobsEvery job, in the same form as a single job answer.

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.

An unknown batch_id, or one from another account, answers 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.

Node.js
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:

Request
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, 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.

Request
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:

POST to your webhook_url
{
  "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 before you trust it.

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

Request
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" }
    ]
  }'
Response, status 400
{
  "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, 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.

Response, status 400
{
  "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 page.

#What it costs

Each job is counted on its own. A job that renders successfully uses one screenshot from your quota. 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 and nothing is queued. Check GET /usage before a big batch.

Caching 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 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, 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 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. The limits of each plan are on the pricing page.

#Where to go next