Skip to main content
Batch is available to every Zenrows account. You need an active Zenrows API key. Replace YOUR_ZENROWS_API_KEY in every example with your key.
Each endpoint on this page also has its own page in the API Reference, with typed parameters, a response example for every status code, and a playground that sends the request with your key.

Basics

  • Base URL: https://async.api.zenrows.com/v1
  • Authentication: send X-API-Key: YOUR_ZENROWS_API_KEY on every request. The key travels in a header, never in the URL.
  • OpenAPI spec: the Batch API is described in OpenAPI 3.1 at www.zenrows.com/openapi.json.
  • Bodies: send JSON with Content-Type: application/json and receive JSON. Errors come back as application/problem+json (RFC 9457) with a stable code you can branch on, and a type that links to the code’s entry in API error codes.
  • Request ID: every response carries an X-Request-ID header. Include it, with the job ID, when you contact support.
  • Ownership: jobs belong to your account, so any of the account’s API keys can read and manage them. A job’s runs always spend the key it was created with, including that key’s credit caps. Another account gets 404.
  • Limits: the per-request and per-account limits are in the introduction.
The Node.js examples below reuse this small wrapper, which adds authentication, sets the Content-Type header when there is a body, and turns error responses into thrown objects:
Node.js

1. Create your jobs

There are several ways to create a job. Pick the one that matches how your URLs arrive.

Submit a job with all URLs up front

Create a closed job (one where you know all URLs up front) with POST /jobs. The response includes a job_id and the initial run status.
POST /jobs returns 201 Created for fewer than 10,000 tasks and 202 Accepted at 10,000 or more, whether the tasks are inline or read from a CSV upload. Scheduled-job submits return 201. Handle both statuses the same way. The request shape and response body are identical, so treat both the same: store job_id, then poll until stats.completed >= stats.total (or use a webhook). On a 202, stats.total is correct immediately, but GET /jobs/{id}/results may return partial pages for a few seconds while the task rows finish writing.

Configure the scrape and per-URL options

zenrows_params applies to every URL in the job. A per-task zenrows_params overrides the job-level keys for that URL, so you can set the common case once and tune exceptions such as a different proxy_country. Common keys: Batch doesn’t take the screenshot parameters. An unknown key, or a value the parameter doesn’t accept, rejects the whole submit with 400 invalid_argument, and invalid_tasks[] names the task and the key (unknown_param or invalid_param_value).
The API returns your external_id unchanged on every result, so you can match results back to your own records without tracking the API’s task IDs. It doesn’t need to be unique, but it must match ^[A-Za-z0-9._-]+$ and be 128 characters or fewer. Put free text, such as a search keyword, in metadata instead: up to 20 string values, keys up to 64 characters from the same set, values up to 1,024 bytes (UTF-8), 4 KiB in total.

POST target fetches

By default, Batch fetches every target URL with an HTTP GET. To fetch a target with POST instead, set "method": "POST" and a body on the task object, up to 16 KiB per task. Use it for read-only requests, such as search or GraphQL queries: a task can be retried, so the target may receive the same POST more than once.
  • A JSON object, array, number or boolean body is sent as JSON, with Content-Type: application/json.
  • A string body is sent verbatim, with Content-Type: application/x-www-form-urlencoded.
  • A Content-Type in custom_headers overrides either.
  • A POST task can’t use js_render, js_instructions or json_response; the submit is rejected with 400 invalid_argument and the reason method_param_conflict.
You can mix GET and POST targets in the same job. Tasks without a method field default to GET. Set custom_headers in zenrows_params to control the Content-Type sent with the POST body.

Open jobs: adding tasks in batches

Open (queue-mode) jobs let you add tasks in batches after creating the job, instead of supplying all URLs upfront.
Create an open job, add tasks with POST /jobs/{job_id}/tasks over time, then close it by sending last_batch: true on the final batch. Each add-tasks call accepts up to 10,000 URLs, and an open job holds up to 100,000 in total; past that, add-tasks returns 429 quota_exceeded, so open a new job. Send an Idempotency-Key on each add-tasks call so a retry adds its tasks once (see Idempotency).
last_batch: true (or POST /jobs/{id}/close) locks the job, and later adds return 409 last_batch_received or 409 job_not_open. An add-tasks call that races a close also answers 409 job_not_open, and none of its tasks are added. An open job counts as one of your account’s active jobs, even after you stop its run. A job holds a slot until you delete it, or until it’s closed and its latest run has ended (completed, stopped or failed); deleting frees it at once. One that receives no tasks for 48 hours closes automatically. To abandon a run that is still in flight, call POST /jobs/{id}/stop (see Control the current run). A very large submission also keeps ingesting task rows for a few seconds after it responds; POST /jobs/{id}/tasks can return 409 run_not_terminal during that window, so retry shortly.

Large inputs: CSV upload

For lists too large to inline, upload a CSV in three steps:
  1. Create an input slot with POST /job_inputs.
  2. PUT the file to the returned presigned URL using the exact headers it specifies
  3. Submit a job referencing file_input_id.
The fields map points the canonical url (required) and external_id (optional) fields at your CSV columns by header name (with header: true) or 0-based index.
Limits: 50 MB and 100,000 rows. The presigned upload link works for 30 minutes, and the uploaded file for 24 hours; after that, a submit returns 404 file_input_not_found. A CSV can feed a closed regular job or a scheduled job, not an open job (409 file_input_unsupported_for_job_type). Values mapped to external_id must match [A-Za-z0-9._-], up to 128 characters.

Scheduled jobs

A scheduled job stores its URLs as a template, and each fire creates a new run. Provide exactly one of at, rate, or calendar (plus a timezone for at and calendar). Times of day must be full hours, and the minimum interval between runs is 60 minutes. An account holds up to 5 scheduled jobs (429 quota_exceeded past that). A fire is skipped while the job’s previous run is still pending or running. The request below schedules a job to run every 6 hours.
For a calendar schedule, replace the schedule object. The example below runs at 09:00 and 18:00 on Mon/Wed/Fri in Berlin time. Use an at object instead for a one-shot run at a single wall-clock time.
Manage a schedule with PUT /jobs/{id}/schedule to replace it, and POST /jobs/{id}/schedule/state with {"schedule_state": "paused"} or {"schedule_state": "active"} to pause or resume.

2. Track your jobs

Check progress and find your jobs once they are running.

Check job status

Read a job with GET /jobs/{job_id}. Progress lives in latest_run.stats, where completed equals successful plus failed.
To wait for a run to finish, poll this endpoint until latest_run.status reaches a terminal state (completed, failed, or stopped), backing off between calls rather than running a tight loop. A run with latest_run.pause_state: "paused" makes no progress until you resume it, so stop polling and report it instead of waiting.

Why a run failed

When a run ends with status failed for a reason that affects the whole run, the run carries two extra fields:
  • failure_reason: a stable code you can branch on.
  • failure_detail: a human-readable explanation, such as which credit cap was reached and when it resets.
failure_reason is an open list. New values can be added at any time, so your code must accept values not listed here, for example by falling back to showing failure_detail.

API key credit caps and Batch

A Batch job uses the credit caps of the API key it runs with. A capped key affects Batch in three ways:
  • Refused requests. Starting a job, adding tasks, resuming, or rerunning with a key that’s at one of its caps returns 402 with the problem code api_key_cap_reached. The detail names the cap and when it resets.
  • A run in progress stops. If the key reaches a cap during a run, the run stops with status failed and failure_reason api_key_cap_reached, and failure_detail says which cap and when it resets. Tasks already in progress finish. The remaining tasks stay pending: they aren’t processed and aren’t charged. Because a run processes many tasks in parallel, it can go further over a cap than single requests do before it stops, so leave room in caps for keys you use with Batch.
  • Scheduled runs are checked when they start. If the key is capped, the account has no credits, or the subscription is inactive, the run is recorded as failed right away with the matching failure_reason and a failure_detail. Nothing is scraped.
To recover:
  1. Raise or remove the cap in the dashboard (Settings, API keys, the key’s menu, Credit caps), or wait for the cap window to reset.
  2. Rerun only the tasks that didn’t complete with POST /jobs/{job_id}/rerun?status=failed,pending (see Reruns and retrying failures). A scheduled run refused at start has no tasks, so there is nothing to filter. Rerun the whole job with POST /jobs/{job_id}/rerun, or let the next scheduled run pick it up.

List and paginate jobs

List your jobs with GET /jobs, optionally filtered by status (open or closed) and type (regular or scheduled), and page with cursor until the response has no next_cursor. limit defaults to 50, up to 100.
The cursor loop is the same as for results, and GET /jobs/{id}/runs pages identically.

Control the current run

Stop, pause, or resume a job’s latest run. Each endpoint is a POST with no request body and returns the refreshed run object on 200.
Stop and cancel. /stop moves the latest run from running or pending to the terminal state stopped. No new tasks are picked up, tasks already in flight may still finish and record a result, and pending tasks are left as-is (not re-queued, not failed). Result bodies are kept; to remove them, follow up with DELETE /jobs/{id}/runs/{run_id} (see Delete a job or a run). It is idempotent on an already-stopped run, returns 409 run_not_stoppable if the run is completed, failed or deleted (use a rerun to start fresh), and 404 on a deleted job. /stop sends no webhook event. /cancel is a pure alias for /stop. Pause and resume. /pause reversibly suspends the latest run: the dispatcher stops polling its queue, while the run’s status (running or pending) is unchanged, since pause is orthogonal to status. Tasks already in flight may still settle. /resume restarts polling. Both are idempotent (pausing a paused run, or resuming an active run, is a no-op), and both return 409 run_not_stoppable when the latest run is terminal.
In-flight gateway calls still consume credits while a pause takes effect. For a hard cost-stop, use /stop (or /cancel) instead.
This is not the same as pausing a schedule. For scheduled jobs, POST /jobs/{id}/schedule/state controls whether new runs fire. The /pause and /resume endpoints here act on the current run.

Delete a job or a run

You can remove data yourself when you no longer need it. Two levels are available: delete a single run’s results, or delete an entire job.
You are not required to delete anything to manage storage. A successful result stays readable through the API for 14 days, counted from the task’s updated_at: when it finished, or when a partial rerun carried it over, and the stored body is deleted automatically within 30 days of being scraped. These endpoints are for removing data sooner than that.
Delete a run. DELETE /jobs/{id}/runs/{run_id} removes that run’s stored result bodies while leaving the job and its other runs in place. Use it once you have downloaded what you need from the run. It returns 202 Accepted and is idempotent, so deleting an already-deleted run is safe. It returns 409 run_has_descendants when a later partial rerun reuses this run’s results; delete those runs first, or delete the whole job. Delete a job. DELETE /jobs/{id} removes the entire job, including every run and result, and frees its active-job slot. Deletion of the stored contents happens in the background, so the request returns 202 Accepted right away and the data is cleared shortly after. Once deleted, GET /jobs/{id} returns 404 and the job no longer appears in the jobs list. Stopping a run first is not required; deleting a job with an active run stops it as part of the delete.
Delete is permanent. Download or export any results you want to keep before deleting a run or a job.

3. Collect your results

Retrieve the scraped content once a run completes.

Download content

List a run’s results with GET /jobs/{job_id}/results, filtered by status (successful, failed, processing for tasks that are pending or in progress, or all, the default) and paged with the cursor query parameter until the response has no next_cursor. These read the job’s latest run. To read an earlier run, such as the one a rerun replaced, use GET /jobs/{job_id}/runs/{run_id}/results and GET /jobs/{job_id}/runs/{run_id}/tasks/{task_id}/content. They take the same parameters. A task keeps its task_id across runs, so the same id reads its result in each run.
Each row’s result_url is a presigned link valid for 2 hours, so fetch it promptly, and re-list the run to get fresh links. A successful task’s body stays available for 14 days, counted from the task’s updated_at: when it finished, or when a partial rerun carried it over. After that its row comes back with status: "expired" and no result_url, and GET /jobs/{job_id}/tasks/{task_id}/content returns 410 result_expired. The successful filter still returns expired rows, so skip any row without a result_url. To remove results sooner, see Delete a job or a run.
To page through every result and download each body, loop over the pages until the response has no next_cursor, fetching each presigned result_url promptly.
You can also fetch one task’s body directly with GET /jobs/{job_id}/tasks/{task_id}/content.
  • 200: returns the content
  • 409 task_not_ready: the task is still pending or processing
  • 410 result_expired: the body aged past its 14-day availability window
  • 422: the task failed and the body holds the stored error.
A failed task’s results row already carries row.error ({ code, detail, ... }), so you rarely need the content endpoint to inspect a failure. Treat the error code as a hint: it does not yet reliably distinguish blocked, not-found, and timeout.

Bulk export (ZIP)

Bundle an entire run’s result bodies into a single ZIP. Start the export with POST /jobs/{job_id}/runs/{run_id}/exports, poll the export until status is completed, then download the presigned download_url.
The API presigns download_url fresh on each poll, valid for 2 hours. The export itself expires 12 hours after creation; polling it after that returns 410 result_expired, so start a new export. A run whose combined results exceed 100 GiB fails with a clear error instead of producing a partial archive. An export of a partial-rerun run holds only the tasks that run scraped itself, not the results it carried over.
Start the export, poll until it is completed, then download the ZIP.

4. Webhooks and notifications

Get notified when a run finishes, and secure those callbacks.

Set up webhooks

Attach a webhook so the API POSTs one event to your HTTPS URL when a run finishes on its own: run.completed when it completes, or run.failed when an account-level error ends it, for example on running out of credits. Stopping a run yourself sends no event. Set signature: true for HMAC-signed deliveries (see Manage HMAC keys). You can attach it on the submit body, read it with GET /jobs/{id}/webhook, replace it with PUT /jobs/{id}/webhook (send both url and signature), remove it with DELETE /jobs/{id}/webhook, and probe a receiver with POST /webhook/test. Delivery. Each delivery is a POST with Content-Type: application/json, User-Agent: zenrows-webhook/v1, an X-ZenRows-Event-Id header, and, when signed, an X-Signature header. Answer with any 2xx within 10 seconds; acknowledge first and process the event afterwards. A delivery that fails, times out or gets a non-2xx answer is retried with exponential backoff, starting at 30 seconds and growing up to 8 hours between attempts, for up to 3 days, and then dropped. Because a retry can repeat an event you already received, deduplicate on X-ZenRows-Event-Id. Payload. The body uses the schema zenrows.webhook/v1. external_id and metadata are the job’s own, and appear only when you set them. failure_reason and failure_detail appear only on run.failed.
signature: true needs an active HMAC key on your account. Create one first with POST /v1/hmac/keys/rotate, as shown in Manage HMAC keys; without it, the request fails with 400 webhook_signing_requires_active_key. To try these samples before you create a key, set signature to false.
Webhook delivery is best effort: a delivery that keeps failing is dropped after 3 days of retries. The run state and result URLs remain the reliable source of truth, so poll GET /jobs/{id} if an event never arrives.
When a run fails, the webhook receives a run.failed event. It carries the run’s failure_reason and failure_detail, the same fields described in Why a run failed.

Manage HMAC keys

Signed webhook deliveries need an active HMAC key for your organization. Rotation uses two slots, an active key plus a candidate, so you can roll keys without downtime.
  • POST /hmac/keys/rotate creates the first key or stages a candidate (the secret is returned once)
  • POST /hmac/keys/rotate/finalize promotes the candidate and retires the old key
  • DELETE /hmac/keys/rotate discards a staged candidate
  • GET /hmac/keys lists key metadata without secrets

Verify signed webhooks

When signature: true, each delivery includes an X-Signature header in the form t=<unix>,v1=<hex>,kid=<key id>, where t is the Unix timestamp of the attempt, v1 is the signature, and kid identifies the key used. To verify a delivery:
  1. Look up the secret that matches the delivery’s kid. Keep both keys during a rotation.
  2. Base64-decode the secret to its 32 key bytes. The API returns it base64-encoded, and the HMAC key is the decoded bytes, not the text.
  3. Compute HMAC-SHA256(key, "<t>.<raw_body>"), signing the timestamp, a literal dot, and the raw request body, as lowercase hex.
  4. Compare the result to v1 in constant time. If they match, the delivery is authentic.
  5. Reject a delivery whose t is more than 5 minutes from your clock, so a captured delivery can’t be replayed later.
The SDKs do all of this in one call: verify_webhook_signature in Python, verifyWebhookSignature in Node.js and VerifyWebhookSignature in Go. Always sign the raw request body, never a re-serialized version, or the signature will not match. If signature is false, no X-Signature header is sent, so authenticate the callback another way, such as a secret path in the URL. Either way, deduplicate on the X-ZenRows-Event-Id header, since the same delivery can arrive more than once. The examples below check the timestamp and compare the expected signature to v1 in your receiver.

5. Reruns and retrying failures

POST /jobs/{job_id}/rerun replays a job as a new run. Add ?status=failed to re-run only the failures (append ,pending to also include tasks that never started). Already-successful tasks carry over, so you only re-scrape, and only pay for, what failed. The response reports retried_tasks and inherited_tasks. Every rerun returns 202 Accepted and writes the new run’s tasks in the background: latest_run.ingest_status reads pending until they’re all written, then done. Until then, results pages for the new run can be partial, and another rerun or add-tasks call returns 409 run_not_terminal. Send an Idempotency-Key so a retried rerun can’t start a second run (see Idempotency). A job runs one run at a time. Start a rerun only after the previous run is completed, stopped, or failed; while it’s pending or running, the API answers 409 run_not_terminal. A partial rerun that matches no task returns 409 no_matching_tasks; a scheduled run refused at start has no tasks, so run a full rerun for it.
Partial reruns (filtering by ?status=failed or ?status=failed,pending) must start within 10 days of when the run you’re retrying started. That is the job’s latest run, not its first. After that, the API returns 409 partial_rerun_window_expired; start a full rerun instead, which replays every task regardless of previous status.

6. Cost

Estimate spend before you submit, and read actual spend after a run.

Estimate cost

Each task costs the credits of its tier, the same as on Fetch, and the rate card is small and stable, so you can estimate cost on the client with no API call.
  • 1 credit (basic request)
  • 5 credits (JavaScript rendering)
  • 10 credits (premium proxy)
  • 25 credits (both)
  • a 1 to 25 credit range for mode=auto
Sum the tier across your tasks. The estimate assumes each URL succeeds once, so it is a single number unless a task uses mode=auto.
The estimate assumes each URL succeeds once, so it is a single number unless a task uses mode=auto. For more details on credit costs, see the Pricing Page.

Check actual cost

Once a run finishes, you can read the credits it actually consumed at two levels:
  • Run total: the spend object on latest_run.stats, read from the GET /jobs/{id} response shown below.
  • Per task: GET /jobs/{id}/tasks/{task_id}/history (or GET /jobs/{id}/runs/{run_id}/tasks/{task_id}/history for an earlier run) lists each attempt with its own spend object. Result rows don’t carry a spend field.
Batch bills at Fetch rates. Successful tasks are billed, and so are tasks that fail because the target answered 404 or 410, the same as on Fetch. Other failed tasks aren’t billed. Because those 404 and 410 tasks show as failed, read stats.spend rather than counting successes. A task’s attempt history shows what each attempt was charged. Treat this figure as an indicative signal. Your Zenrows account statement remains the authoritative record for billing.

7. Reference

Behaviors and error details that apply across the API.

Idempotency

Send an Idempotency-Key header on a submit (POST /jobs), a rerun (POST /jobs/{job_id}/rerun) or an add-tasks call (POST /jobs/{job_id}/tasks), so a retry can’t create a second job or run, or append the same tasks twice. Other routes ignore the header. A key can be up to 128 characters; a longer one returns 400 invalid_argument. Keys last 24 hours and belong to your account; an add-tasks key also belongs to its job.
  • Same key, same request: the API replays the original status and body, so a retry after a timeout or a dropped connection is safe. For a rerun, the request is the job and its ?status= filter.
  • Same key, different request: the body must match byte for byte, including field order and whitespace. A different body, or a different ?status= on a rerun, returns 409 idempotency_key_conflict.
  • Same key while the first attempt is still running: 409 idempotency_key_conflict. Retry shortly.
  • A refused submit or rerun doesn’t use up the key. A submit or rerun the API refuses before it creates the job or run, for example with 400, 409 no_matching_tasks or 429 quota_exceeded, leaves the key free: fix the cause and retry with the same key.
  • Add-tasks keeps its key, refused or not. A retry with the same body is checked again, and a corrected body needs a new key, since the same key with a different body is 409 idempotency_key_conflict.
  • The credit check runs first. A retry of a call that succeeded returns 402 if the account has run out of credits since.
On add-tasks, a retry after a call that failed part way, such as a 503 after the tasks were stored or a lost response, finishes that call: the retried tasks keep the same task IDs, so the job gets no duplicate rows and stats.total counts each task once.
If you never saw the answer, such as after a timeout or a dropped connection, resend the same body with the same key: you get the original response if the submit went through. A 503 that arrives after the job was created has no stored response, so its retry returns 409 idempotency_key_conflict. Check whether the job was created by listing your recent jobs, then either use its job_id or submit again with a new key. A late 503 can leave a visible job with a stopped run, which is safe to DELETE.

Error handling reference

Errors use RFC 9457 problem JSON (application/problem+json) with a stable code you can branch on, a title, a detail explaining the case, the HTTP status, and a type URL that links to the code’s entry in API error codes. A 400 adds invalid_tasks[] with {index, reason, value} for each bad task. Task-level failures are not HTTP errors; they appear as failed rows in the results, each with its own error object. Success codes: POST /jobs returns 201 Created, or 202 Accepted when the job has 10,000 or more tasks (inline or from a CSV); both carry the same body. Scheduled-job submits return 201. Every rerun returns 202. DELETE on a job or a run returns 202. Every response carries an X-Request-ID header; include it when you contact support. API error codes lists every Batch code, including the HMAC and CSV upload ones, with its meaning. On a non-2xx response, read the code and, for a 400, the invalid_tasks array from the problem body.

Next steps