Scrape thousands of URLs with a batch job
Submit up to 10,000 URLs per call to POST /v1/batch, poll the job until it is terminal, and page the JSONL results before they expire after 72 hours.
Use this when you have a list of URLs to fetch and do not need each answer immediately: a nightly catalogue refresh, a site archive, a price sweep. You submit the list once, get a job id back with 202, and the platform works through it with its own retries while you poll. Requires an API key with the batch scope.
Each item is one fetch or render, returned as html (the default), markdown, text or json. Extraction, screenshots, actions and sessions are not run on batch: a submission that asks for them is refused with a 400 naming each one, and nothing is queued or charged. See What a batch item can do.
Minimal request
curl https://api.spicrawl.com/v1/batch \
-H "Authorization: Bearer $SPICRAWL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "catalog-2026-09-22",
"urls": ["https://example.com/products/1", "https://example.com/products/2"],
"js_render": true
}'urls gives every URL the job-level settings. items gives each URL its own object with overrides and an external_id you can correlate on: {"url": "https://example.com/products/2", "external_id": "sku-42", "js_render": true}. Send one or the other, never both (400 ERR::REQUEST::INCOMPATIBLE_FLAGS). An item's value wins over the job-level value, including an explicit false. The CLI reads a URL-per-line file, a .json array of items, or a .jsonl file of items.
What a batch item can do
The worker runs each item with one fetch or one render, then returns the document in response_format. A /v1/scrape parameter is either honoured on a batch item or refused at submission, never accepted and skipped.
| Honoured | Notes |
|---|---|
response_format: html (default), json, markdown, text | html and json return the document as retrieved. markdown and text run the same conversion as /v1/scrape. Batch json is not the /v1/scrape envelope yet: it does not return the markdown content or sections. Use markdown on batch when you want the outline. |
main_content_only, include_tags, exclude_tags | Applied to markdown and text. main_content_only is off unless you set it. |
js_render, stealth | js_render renders the page with JavaScript (3 credits) and stealth uses hardened-fingerprint rendering (25). stealth is Coming soon. engine and headless are deprecated and ignored, as on /v1/scrape: rendering is chosen automatically, and the job's warnings report the ignored field. |
method, custom_headers, impersonate | A method other than GET works on a plain fetch only, without js_render or stealth. |
proxy, proxy_verify, premium_proxy, proxy_country, sticky_key | The managed pool (premium_proxy, proxy_country, sticky_key) is Coming soon. An item that sets none of these leaves through the same default exit as /v1/scrape, fixed when the job is submitted and shown as params.default_proxy_tier. |
wait, wait_for, wait_for_timeout, block_resources | Rendered items only (js_render or stealth). |
max_cost, parse_pdf: false | max_cost is checked per item at submission. |
Refused with 400 ERR::REQUEST::INVALID_PARAMETER, which names every offending parameter: actions, screenshot and its options, network_capture, extract, extract_preset, ai_extract, autoparse, links, session_id, cache/cache_ttl, original_status, allowed_status_codes, parse_pdf: true, mode: "auto", solve_captcha: true (not available on batch jobs yet), response_format: "pdf", and a non-GET method with js_render or stealth. A screenshot or PDF is always refused, because a batch item cannot use full-browser rendering; use /v1/scrape. Nothing is queued or charged. A value that asks for nothing (screenshot: false, []) is accepted. When the parameter comes from your project's defaults rather than the request, the detail says so.
A markdown or text item whose document is not HTML (a PDF, an image, JSON) fails with ERR::EXTRACTION::FAILED: the batch worker does not parse PDFs. Request html to get the document as retrieved.
For extraction, screenshots or actions on many URLs, run /v1/scrape with client-side concurrency and add the flags you need:
spicrawl scrape - --concurrency 8 --extract @fields.json < urls.txt > pages.jsonlWhat comes back
Submission returns 202 with the job object: id, status: "queued", status_url, results_url, total_items, estimated_credits (the hold) and progress. Read warnings: it reports a clamped concurrency, an ignored engine or headless field, or items not yet handed to the queue. Do not resubmit in that case; a recovery sweep dispatches them, and a resubmission is a second job billed again.
Poll GET /v1/batch/{id} every 2–5 seconds for jobs under about 1,000 items, every 10–30 seconds for larger ones. Progress counters are cheap to read.
| Status | Meaning | Keep polling? |
|---|---|---|
queued | Accepted, not started. | Yes |
running | Items in flight. | Yes |
paused | Operator state; no public endpoint pauses a job. | Yes |
cancelling | Cancel accepted; in-flight items are finishing. | Yes |
completed | Every item finished. | No |
failed | Aborted, e.g. failure_threshold reached. See error_code. | No |
cancelled | Cancelled and drained. | No |
GET /v1/batch/{id}/results returns finished items as JSON Lines (application/x-ndjson), one object per line, in seq order:
{"seq":1,"external_id":"sku-42","url":"https://example.com/products/2","status":"succeeded","attempts":1,"http_status":200,"request_id":"01J9Z7A1B2C3D4E5F6G7H8J9KA","credits_micro":4000000,"bytes":326000,"duration_ms":1840,"result":{"content":"<!doctype html>…","bytes":326000},"finished_at":"2026-09-22T06:41:14Z"}statusissucceeded,failed(readerror.codeanderror.retryable),cancelledorskipped. Unfinished items are never listed.http_statusis the target's status. A 404 here is a successful scrape of a missing page.result.contentis the page as plain text in the item'sresponse_format: the HTML, markdown or text itself. Your JSON parser has already decoded the line, so use the string as it is and do notjson.loadsit again. Forresponse_format: "json"on batch it is the document as retrieved, the same string ashtml, not the/v1/scrapeenvelope (see the table above).result.bytesis the length ofcontent.GET /v1/batch/{id}/tasks/{seq}/contentis different: it returns the payload as a single JSON string literal, which a raw HTTP client decodes once.resultis absent while the job is still running: bodies are inlined from a file written when the job becomes terminal.- Paging is in headers. If
X-Next-Cursoris present, request again withcursor=<value>(theLinkheader has the full URL).limitis 1–5,000, default 500. Filter withstatus=failedto list only failures.
Body caps: 512 KiB per item and 2 MiB per page, spent in seq order. Exactly one of these applies to each result:
| Field | Meaning | What to do |
|---|---|---|
content | Full body. | Use it. |
truncated: true | content is a prefix and not valid JSON. | Request that seq alone (cursor just before it, limit=1) for up to 512 KiB. |
omitted: true | The page's 2 MiB budget was spent. | Lower limit, or resume with a cursor just before this seq. |
unavailable | The result file could not be read. | Retry later. |
Results are readable until results_expire_at, 72 hours after submission by default. After that the results endpoint answers 410 ERR::REQUEST::BEYOND_RETENTION; the job object stays readable.
Limits and options
- Body up to 1 MiB (
413), 10,000 items per call, job-level parameters up to 64 KiB serialised, each item's overrides up to 8 KiB. - A bad item fails the whole submission;
detailstarts withItem N:. open: truekeeps the job accepting items viaPOST /v1/batch/{id}/items(same body shape, 10,000 per call, 100,000 per job). An open job never completes on its own: callPOST /v1/batch/{id}/closewhen done. Appended items use the job's original settings plus their own overrides.
| Field | Default | Effect |
|---|---|---|
concurrency | 10 | Items in flight at once. Above 50 is clamped to 50 with a warning. |
priority | 100 | 0–1000, relative to your other jobs. |
max_attempts | 3 | 1–10 attempts per item; only retryable failures are re-attempted. |
failure_threshold | none | Abort the job as failed after this many failed items. |
credit_budget | projected cost (none for an open job) | Ceiling on the whole run, in credits. Items beyond it are skipped. |
max_cost | none | Per-item ceiling, checked at submission. |
cache | false | Batch never reads from the result cache, unlike /v1/scrape. cache: true or a cache_ttl is refused with 400. |
webhook_endpoint_id | none | A registered webhook endpoint notified with the job object when it finishes. Fetch results yourself. |
Retry and cancel
POST /v1/batch/{id}/retryresets everyfaileditem to queued. Succeeded, cancelled and skipped items never re-run, andattemptsis not reset. A terminal job goes back toqueued. With nothing failed, it returnsitems_reset: 0.POST /v1/batch/{id}/cancelmoves a live job tocancelling; in-flight items finish (and are charged if they succeed), then the job becomescancelled. Cancelling a terminal job is409.
CLI: spicrawl batch retry <id>, spicrawl batch cancel <id>, spicrawl batch append <id> more.txt, spicrawl batch close <id>, spicrawl batch wait <id> --max-wait 30m (exit 11 on timeout).
Failure modes
| Code | HTTP | What to do |
|---|---|---|
ERR::REQUEST::INVALID_PARAMETER | 400 | A field or an item is invalid, or a parameter the batch worker cannot run. detail names the item and every parameter. |
ERR::REQUEST::MISSING_PARAMETER | 400 | Neither urls nor items. |
ERR::REQUEST::PAYLOAD_TOO_LARGE | 413 | Body over 1 MiB. Split the list, or use an open job with appends. |
ERR::LIMIT::QUOTA_EXCEEDED | 402 | The job's dearest cost does not fit under your monthly credit allowance. Submit fewer items or set max_cost. |
ERR::REQUEST::CONFLICT | 409 | Appending to a closed job, or cancelling a terminal one. |
ERR::REQUEST::BEYOND_RETENTION | 410 | Results expired. Resubmit if you still need them. |
Per-item failures are on the result line in error.code, from the same catalogue as /v1/scrape (ERR::UPSTREAM::TIMEOUT, ERR::PROXY::EXHAUSTED, ...). Re-run retryable ones with retry. ERR::EXTRACTION::FAILED on a markdown or text item means the document was not HTML; it is permanent, so resubmit that URL with response_format: "html".
Cost
Submitting is free (X-Credits-Charged: 0) but reserves the dearest possible cost of every item against your monthly allowance; that hold is estimated_credits, and the response's X-Credits-Remaining is the allowance left after it. Append and retry responses carry X-Credits-Remaining after their holds too. Credits held for items that fail, are cancelled or skipped are returned to the monthly allowance within about a minute of the item finishing. Appending to an open job reserves the appended items the same way, and is refused with 402 if the allowance cannot cover them. Each item is charged on success only, at the /v1/scrape price for how it was fetched (1 credit for a plain fetch, 3 with js_render). Billable target statuses are 200, 404 and 410. A js_render item whose page renders empty (no visible content) is not charged; its result carries "warnings":["EMPTY_RENDER"]. Retrying a failed item (retry) holds its price again first and is refused with 402if the allowance cannot cover it. Actual spend isprogress.credits_charged`. See Credits.
Related
- CLI batch commands
- Markdown for LLMs for per-URL markdown with
/v1/scrape - Proxies and geo
- Rate limits
Sessions and logins
Create a session with POST /v1/sessions, pass its id as session_id on /v1/scrape to reuse cookies, storage and a pinned exit IP, and release it when you are done.
CDP browser
Connect any DevTools Protocol (CDP) client to a real browser hosted by Spicrawl with GET /v1/browser, and open a live view so a person can watch it and take control for a login, a one-time code or a captcha.