> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zenrows.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> The Zenrows API (Fetch, Extract via extract=auto, and Batch) is described in OpenAPI 3.1 at https://www.zenrows.com/openapi.json and listed in the RFC 9727 API catalog at https://www.zenrows.com/.well-known/api-catalog. Use the spec for exact parameter names and types.

# Start a Batch job from a CSV file

> Start a Zenrows Batch job straight from a CSV file of URLs, with your own IDs carried into every result.

Already have your URLs in a spreadsheet export or a database dump? Upload the CSV file as it is and point Batch at the columns that hold the URL and your own ID. You skip building a JSON body, and every result comes back tagged with the ID from your file.

**Uses:** [Batch CSV upload](/batch/developer-guide-restapi#large-inputs-csv-upload) (`POST /job_inputs`, then `POST /jobs` with `file_input_id`) and [`zenrows_params`](/batch/developer-guide-restapi#configure-the-scrape-and-per-url-options)

## Ask your AI agent

Paste this prompt into a coding agent such as Claude Code or Cursor, with the [Zenrows docs connected](/academy/connect-your-ai-agent#give-your-agent-the-zenrows-docs). The agent looks up the details and writes the calls.

```text theme={"dark"}
Using the Zenrows docs, write a script that uploads products.csv to Zenrows Batch,
using the product_url column as the URL and the sku column as my ID, scrapes every
page as markdown, and saves each page to a file named after its sku.
The same script should work for a file with hundreds of thousands of rows.
```

## Prepare the CSV

Any CSV works. You tell Batch which column holds the URL and, optionally, which one holds your ID. This recipe uses a file with a header row:

```text products.csv theme={"dark"}
sku,product_url,name
MH09,https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/,Abominable Hoodie
WJ08,https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/,Adrienne Trek-Jacket
WP07,https://www.scrapingcourse.com/ecommerce/product/aeon-capri/,Aeon Capri
```

Extra columns, like `name` here, are ignored.

## Upload the file and create the job

Three calls do it:

1. `POST /job_inputs` with `"type": "csv"` creates an upload slot. In `csv.fields`, map `url` (required) and `external_id` (optional) to your column names. The response holds a `file_input_id` and a presigned `upload` URL.
2. `PUT` the file to `upload.url`, sending the headers listed in `upload.headers`.
3. `POST /jobs` with `file_input_id` instead of `tasks`. Scrape settings go in `zenrows_params` as usual.

<CodeGroup>
  ```python Python (Requests) theme={"dark"}
  import time
  import requests

  BASE = "https://async.api.zenrows.com/v1"
  headers = {"X-API-Key": "YOUR_ZENROWS_API_KEY"}

  # 1. Create an upload slot and map your CSV columns
  slot = requests.post(
      f"{BASE}/job_inputs",
      headers=headers,
      json={
          "type": "csv",
          "csv": {"header": True, "fields": {"url": "product_url", "external_id": "sku"}},
      },
      timeout=60,
  ).json()

  # 2. Upload the file to the presigned URL, with the headers the slot gives you
  with open("products.csv", "rb") as f:
      upload = requests.put(slot["upload"]["url"], headers=slot["upload"]["headers"], data=f, timeout=120)
  upload.raise_for_status()

  # 3. Create the job from the uploaded file
  job = requests.post(
      f"{BASE}/jobs",
      headers=headers,
      json={
          "type": "regular",
          "status": "closed",
          "file_input_id": slot["file_input_id"],
          "zenrows_params": {"mode": "auto", "response_type": "markdown"},
      },
      timeout=60,
  )
  job.raise_for_status()
  job_id = job.json()["job_id"]
  print("Submitted", job_id)

  # 4. Wait for the run to finish
  while True:
      run = requests.get(f"{BASE}/jobs/{job_id}", headers=headers, timeout=60).json()["latest_run"]
      if run["status"] in {"completed", "failed", "stopped"}:
          break
      time.sleep(5)
  print(run["status"], run["stats"])

  # 5. Each result carries the external_id from your sku column
  cursor = None
  while True:
      page = requests.get(
          f"{BASE}/jobs/{job_id}/results",
          headers=headers,
          params={"status": "all", "cursor": cursor},
          timeout=60,
      ).json()
      for row in page["results"]:
          print(row["external_id"], row["status"], row["url"])
      cursor = page.get("next_cursor")
      if not cursor:
          break
  ```

  ```javascript Node.js theme={"dark"}
  import { readFile } from "node:fs/promises";

  const BASE = "https://async.api.zenrows.com/v1";
  const headers = { "X-API-Key": "YOUR_ZENROWS_API_KEY", "Content-Type": "application/json" };

  async function api(method, path, body) {
    const res = await fetch(`${BASE}${path}`, { method, headers, body: body && JSON.stringify(body) });
    if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
    return res.json();
  }

  // 1. Create an upload slot and map your CSV columns
  const slot = await api("POST", "/job_inputs", {
    type: "csv",
    csv: { header: true, fields: { url: "product_url", external_id: "sku" } },
  });

  // 2. Upload the file to the presigned URL, with the headers the slot gives you
  const upload = await fetch(slot.upload.url, {
    method: slot.upload.method,
    headers: slot.upload.headers,
    body: await readFile("products.csv"),
  });
  if (!upload.ok) throw new Error(`Upload failed: ${upload.status}`);

  // 3. Create the job from the uploaded file
  const { job_id } = await api("POST", "/jobs", {
    type: "regular",
    status: "closed",
    file_input_id: slot.file_input_id,
    zenrows_params: { mode: "auto", response_type: "markdown" },
  });
  console.log("Submitted", job_id);

  // 4. Wait for the run to finish
  let run;
  do {
    await new Promise((r) => setTimeout(r, 5000));
    ({ latest_run: run } = await api("GET", `/jobs/${job_id}`));
  } while (!["completed", "failed", "stopped"].includes(run.status));
  console.log(run.status, run.stats);

  // 5. Each result carries the external_id from your sku column
  let cursor;
  do {
    const query = new URLSearchParams({ status: "all", ...(cursor && { cursor }) });
    const page = await api("GET", `/jobs/${job_id}/results?${query}`);
    for (const row of page.results) console.log(row.external_id, row.status, row.url);
    cursor = page.next_cursor;
  } while (cursor);
  ```

  ```bash cURL theme={"dark"}
  # 1. Create an upload slot and map your CSV columns
  slot=$(curl -s -X POST "https://async.api.zenrows.com/v1/job_inputs" \
    -H "X-API-Key: YOUR_ZENROWS_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "type": "csv", "csv": { "header": true, "fields": { "url": "product_url", "external_id": "sku" } } }')

  # 2. Upload the file to the presigned URL, with the Content-Type from upload.headers
  curl -s -X PUT "$(echo "$slot" | jq -r '.upload.url')" \
    -H "Content-Type: $(echo "$slot" | jq -r '.upload.headers["Content-Type"]')" \
    --data-binary @products.csv

  # 3. Create the job from the uploaded file
  curl -s -X POST "https://async.api.zenrows.com/v1/jobs" \
    -H "X-API-Key: YOUR_ZENROWS_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{
      \"type\": \"regular\",
      \"status\": \"closed\",
      \"file_input_id\": \"$(echo "$slot" | jq -r '.file_input_id')\",
      \"zenrows_params\": { \"mode\": \"auto\", \"response_type\": \"markdown\" }
    }"
  ```
</CodeGroup>

The cURL version needs <a href="https://jqlang.org/" rel="nofollow">`jq`</a> to read the slot response. Check progress and read results the same way as any job, see [Scrape a list of URLs](/academy/scrape-from-a-list-of-urls).

## Example output

The Python run:

```text theme={"dark"}
Submitted 01M4YGHEVQS8NP3YED6WQ06TBB
completed {'total': 3, 'completed': 3, 'successful': 3, 'failed': 0, 'spend': {'credits': 3, 'cost': 0.003}}
WP07 successful https://www.scrapingcourse.com/ecommerce/product/aeon-capri/
MH09 successful https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/
WJ08 successful https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket/
```

The `POST /job_inputs` response, with the presigned link shortened:

```json theme={"dark"}
{
  "file_input_id": "01M4YGGRW96TE2FT8K4QBED4EQ",
  "upload": {
    "method": "PUT",
    "url": "https://....s3.us-east-1.amazonaws.com/inputs/01M4YGGRW96TE2FT8K4QBED4EQ.csv?...",
    "headers": { "Content-Type": "text/csv" }
  }
}
```

## Use the SDKs

Both SDKs wrap the slot and the upload in one call, `upload_csv` in Python and `uploadCsv` in Node.js, and return the `file_input_id`:

<CodeGroup>
  ```python Python (SDK) theme={"dark"}
  # pip install zenrows
  from zenrows import ZenRowsBatchClient

  client = ZenRowsBatchClient(api_key="YOUR_ZENROWS_API_KEY")

  # Upload the CSV and map its columns; returns the file_input_id
  file_input_id = client.upload_csv(
      "products.csv",
      header=True,
      fields={"url": "product_url", "external_id": "sku"},
  )

  job = client.submit_regular(
      file_input_id=file_input_id,
      zenrows_params={"mode": "auto", "response_type": "markdown"},
  )
  run = job.run.wait(timeout=900)
  print(f"{run.stats.successful}/{run.stats.total} pages scraped")

  saved = run.download_to_dir("./pages", use_external_id=True)
  print(f"Saved {saved} files to ./pages")
  ```

  ```javascript Node.js (SDK) theme={"dark"}
  // npm install zenrows
  import { readFile } from "node:fs/promises";
  import { ZenRowsBatchClient } from "zenrows";

  const client = new ZenRowsBatchClient("YOUR_ZENROWS_API_KEY");

  // Upload the CSV and map its columns; returns the file_input_id
  const fileInputId = await client.uploadCsv(await readFile("products.csv"), {
    header: true,
    fields: { url: "product_url", external_id: "sku" },
  });

  const job = await client.submitRegular(undefined, fileInputId, {
    zenrowsParams: { mode: "auto", response_type: "markdown" },
  });
  const run = await job.run.wait();
  console.log(`${run.stats.successful}/${run.stats.total} pages scraped`);

  const saved = await run.downloadToDir("./pages", { useExternalId: true });
  console.log(`Saved ${saved} files to ./pages`);
  ```
</CodeGroup>

```text Output theme={"dark"}
3/3 pages scraped
Saved 3 files to ./pages
```

With `use_external_id=True` (`useExternalId: true` in Node.js) each file is named after your `sku` column: `MH09`, `WJ08`, `WP07`. The Python SDK adds the result type as an extension, for example `MH09.markdown`.

## Customize it

| Change | How |
| - | - |
| A CSV without a header row | Set `"header": false` and map columns by 0-based index: `"fields": {"url": 1, "external_id": 0}` |
| Semicolon or tab separated files | Add `"delimiter": ";"` to `csv` (`delimiter=` in the Python SDK, `delimiter:` in Node.js) |
| Structured JSON instead of pages | `"extract": "auto"` in `zenrows_params`, see [Get structured JSON from any page](/academy/extract/auto-extraction) |
| Run the same file on a schedule | Pass the `file_input_id` to a scheduled job, see [Run a Batch job on a schedule](/academy/batch/scheduled-jobs) |
| A notification when it finishes | Add a `webhook`, see [Get notified when a Batch job finishes](/academy/batch/webhooks) |
| Save every result in one step | See [Download all results of a job at once](/academy/batch/export-results) |

## Troubleshooting

* **The upload returns `403`**: the presigned upload link is no longer fresh. Create a new slot with `POST /job_inputs` and upload right away.
* **`POST /jobs` returns `400` with `header has no column`**: the names in `fields` must match your header row exactly, including case and spaces. Open the file and copy the header names, or switch to column indexes with `"header": false`.
* **Results have no `external_id`**: add `"external_id": "<column>"` to `fields`. Without it, match results by `url` or `task_id`.
* **Some rows failed**: list them with `status=failed` and rerun them, see [Retry the tasks that failed](/academy/batch/retry-failed-tasks).

## Related recipes

<CardGroup cols={2}>
  <Card title="Scrape a list of URLs" icon="list" href="/academy/scrape-from-a-list-of-urls">
    Send URLs inline as one Batch job.
  </Card>

  <Card title="Add URLs to a job while it runs" icon="playlist-add" href="/academy/batch/open-jobs">
    Stream URLs into an open job as you find them.
  </Card>

  <Card title="Download all results of a job at once" icon="download" href="/academy/batch/export-results">
    Save every page to a folder or one ZIP file.
  </Card>

  <Card title="Retry the tasks that failed" icon="refresh" href="/academy/batch/retry-failed-tasks">
    Rerun only the failures and keep the successes.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.