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

# Node.js SDK

> Submit batch scraping jobs, track them, and collect results with the official ZenRows Node.js SDK. Covers every Batch Scraper API feature with runnable examples.

<Note>
  The Batch Scraper API is in **public beta**, available to every ZenRows account. You need an active ZenRows API key. The `ZenRowsBatchClient` shown here ships under the `zenrows/batch` subpath, separate from the SDK's synchronous scraping client, `ZenRows`.
</Note>

Use this guide to submit batch scraping jobs, track them, and collect results with the official ZenRows Node.js SDK. The client is fully typed, with request and response models generated from the API's OpenAPI spec and exposed as camelCase (`job.jobId`, `run.data.stats.successful`, `row.resultUrl`).

<Tip>
  Prefer raw HTTP, or work in another language? The [REST API guide](/batch-scraper-api/developer-guide-restapi) covers the same use cases against the plain REST API. For the complete reference, see the [SDK repository on GitHub](https://github.com/ZenRows/zenrows-node-sdk).
</Tip>

## Install and Authenticate

While the batch client is being published to npm, install the SDK from the beta branch:

```bash theme={null}
npm install "github:ZenRows/zenrows-node-sdk#feature/batch-api/rc"
```

<Note>
  This branch install is temporary. The batch client is not on npm yet; once it is published, `npm install zenrows` will include it.
</Note>

```typescript theme={null}
import { ZenRowsBatchClient } from "zenrows/batch";

const client = new ZenRowsBatchClient("YOUR_ZENROWS_API_KEY");
```

## Quickstart: Submit, Wait, Get Results

`submitRegular` takes one params object: a task source (`urls`, bare strings or per-task objects, or `fileInputId`) plus optional job fields. It returns a `JobRef`. `job.run.wait()` blocks until the current run is terminal and returns its `RunHandle`, whose `.data` snapshot is ready. `results()` is an async stream you can iterate with `for await` or `.forEach`.

```typescript theme={null}
const job = await client.submitRegular({
  urls: [
    "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie",
    "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket",
  ],
  zenrowsParams: { js_render: "true", premium_proxy: "true" },
});

const run = await job.run.wait();
console.log(`${run.data.stats.successful}/${run.data.stats.total} succeeded`);

for await (const row of run.results({ status: "successful" })) {
  console.log(row.externalId ?? row.taskId, "->", row.resultUrl);
}
```

<Tip>
  Need full control over the request body? `client.submitJob({...})` accepts the raw (camelCase) wire shape and returns the same `JobRef`. For very large submissions that return `202 Accepted`, pass `waitForIngest: true` so results pages are complete before you read them.
</Tip>

## Configuring the Scrape

`zenrowsParams` sets scraping options for every URL in the job. The parameter keys themselves stay in their API form (`js_render`, `premium_proxy`, and so on).

```typescript theme={null}
const job = await client.submitRegular({
  urls: ["https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie"],
  zenrowsParams: {
    js_render: "true",
    premium_proxy: "true",
    proxy_country: "us",
    wait_for: ".price",
    response_type: "markdown", // html | markdown | plaintext | pdf
  },
});
```

Other common keys include `js_instructions`, `wait`, `block_resources`, `json_response`, `css_extractor`, `autoparse`, `outputs`, `original_status`, `allowed_status_codes`, and `session_id`. ZenRows derives the output format from these and records it on each result. Custom request headers (`custom_headers`) are not supported yet.

## Per-URL Options and Correlating Results

To set a per-task `externalId`, `metadata`, or `zenrowsParams`, pass objects instead of bare strings. Per-task `zenrowsParams` override the job-level ones on a key clash.

```typescript theme={null}
const job = await client.submitRegular({
  urls: [
    { url: "https://www.scrapingcourse.com/ecommerce/product/artemis-running-short",
      externalId: "order-42", zenrowsParams: { proxy_country: "de" } },
    { url: "https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee",
      externalId: "order-43", metadata: { sku: "ABC-1" } },
  ],
  zenrowsParams: { js_render: "true" },
});
```

<Tip>
  The API returns your `externalId` unchanged on every result, so you can match results back to your own records. It must match `^[A-Za-z0-9._-]+$` and be 128 characters or fewer; the SDK validates this before sending.
</Tip>

## Downloading Content

`results()` gives you metadata plus a presigned `resultUrl` per task, and the SDK downloads bodies straight from that URL with no extra API round-trip. Run these on the current run (`job.run.*`) or any specific run (`client.getRun(jobId, runId)`).

```typescript theme={null}
// One file per task, client-side, tune parallelism:
await client.job(jobId).run.downloadToDir("./out", { concurrency: 8 });

// The whole run as one server-side zip:
await client.job(jobId).run.downloadAllResults("results.zip");

// A single task's body, to disk or memory, inside a loop:
for await (const row of run.results({ status: "successful" })) {
  await run.downloadTaskToDir(row, "./out");
  // or: const { body } = await run.downloadTaskToMemory(row);
}
```

<Note>
  `downloadAllResults` is a server-side zip capped at **1 GiB** per run. For larger runs, or one file per task, use `downloadToDir`, which fetches bodies client-side with no size limit.
</Note>

## Open Jobs: Streaming URLs in Batches

When you don't have every URL up front, open a job with `submitOpen`, add tasks in batches, and close it. The run stays active and completes once it finishes the final batch.

```typescript theme={null}
const job = await client.submitOpen({ zenrowsParams: { js_render: "true" } });

await job.addTasks({ tasks: [{ url: "https://www.scrapingcourse.com/ecommerce/page/1" }] });
await job.addTasks({ tasks: [{ url: "https://www.scrapingcourse.com/ecommerce/page/2" }], lastBatch: true });

const run = await job.run.wait();
```

Setting `lastBatch: true` (or calling `job.close()`) locks the job, and the API rejects further adds. CSV file input is not supported for open jobs; use a closed job for CSV.

## Large Inputs: CSV Upload

For lists too large to inline, upload a CSV once and reference it. `uploadCsv` allocates the slot, PUTs your file to the presigned URL, and returns a `fileInputId` you pass to a submit call.

```typescript theme={null}
const fileInputId = await client.uploadCsv("urls.csv", {
  fields: { url: "URL", externalId: "Customer Ref" }, // column name (header: true) or 0-based index
  header: true,
});
const job = await client.submitRegular({ fileInputId, zenrowsParams: { js_render: "true" } });
```

Each CSV row becomes a task. Only the `url` and optional `externalId` columns are read, and the job-level `zenrowsParams` apply to all of them. Limits: 50 MB / 100,000 rows.

## Listing and Pagination

The SDK offers both single-page calls (`listJobs`, `listRuns`, `listResults`) and auto-paginating async streams that yield loaded handles.

```typescript theme={null}
for await (const job of client.iterJobs({ jobType: "regular", status: "closed" })) {
  console.log(job.jobId, job.data.status);
}

for await (const run of client.iterRuns("01J...", { pageSize: 50 })) {
  console.log(run.runId, run.data.status);
}

for await (const row of client.iterResults("01J...", { status: "failed" })) {
  console.log(row.taskId, row.error?.code);
}
```

Each iterator is an `AsyncStream`, so you can also use `.forEach(...)` instead of `for await`.

## Reruns and Retrying Failures

Each rerun creates a brand-new run on the same job.

```typescript theme={null}
// Re-run every task:
const full = await client.job(jobId).rerun();

// Re-run only the failures (pass { includePending: true } to also pick up tasks that never started).
// Successful results carry over, so you don't pay to re-scrape them:
const run = await client.job(jobId).retryFailed();
await run.wait();
```

## Scheduled Jobs

Submit a recurring job with a typed schedule. The schedule is a discriminated union: `{ every }` interval, `{ at, tz }` one-shot, `{ times, tz }` daily, `{ times, days, tz }` weekly, or `{ times, dates, tz }` monthly. The compiler enforces exactly one shape and a required `tz` (except for `every`).

```typescript theme={null}
const job = await client.submitScheduled({
  schedule: { every: "6h" },
  urls: ["https://www.scrapingcourse.com/ecommerce/"],
});
```

A job carries two facets that match the API's two scopes. `job.schedule.*` controls the recurring schedule, and `job.run.*` controls the **current run**. This disambiguates the API's two distinct "pause" actions:

```typescript theme={null}
await job.schedule.pause();  // stop firing new runs
await job.schedule.resume();
await job.schedule.update({ times: ["09:00"], days: ["mon", "fri"], tz: "Europe/Berlin" });

await job.run.pause();       // reversibly suspend the current run (a different endpoint)
await job.run.resume();
await job.run.stop();        // end the current run
```

For a specific historical run, use `client.run(jobId, runId)`, a `RunRef` with read and download operations only.

## Completion Notifications: Webhooks

Attach a webhook at submit time, and ZenRows POSTs a `run.completed` event to your HTTPS URL when a run finishes. Set `signature: true` for HMAC-signed deliveries (see [Signed Webhooks](#signed-webhooks-hmac-keys)); it defaults to unsigned. You can also manage it later with `client.putJobWebhook(...)` / `client.deleteJobWebhook(...)`, and probe a receiver with `client.testWebhook(...)`.

```typescript theme={null}
const job = await client.submitRegular({
  urls: ["https://www.scrapingcourse.com/ecommerce/"],
  webhook: { url: "https://your-app.com/webhooks/zenrows", signature: true },
});
```

Deliveries are at-least-once, so deduplicate on the `X-ZenRows-Event-Id` header.

<Tip>
  No webhook receiver? Just poll for completion with `job.run.wait()` or `run.wait()`.
</Tip>

## Bulk Export (ZIP)

Bundle an entire run's result bodies into a single server-side zip: `downloadAllResults` starts the export, waits for it, and streams the file to disk.

```typescript theme={null}
await client.job(jobId).run.downloadAllResults("results.zip");
```

<Note>
  The server-side zip is capped at **1 GiB** per run. For larger runs, or one file per task, use `downloadToDir`, which fetches bodies client-side with no size limit. For manual control, `client.startResultsExport(...)` and `client.waitForExport(...)` expose the two steps separately.
</Note>

## Estimating Cost

Estimate usage **before** submitting with `client.estimateCost`. It takes the same body as `submitJob` and runs client-side with no API call. The tiers are 1 (base), 5 (`js_render`), 10 (`premium_proxy`), 25 (both), and a dynamic 1 to 25 for `mode=auto`.

```typescript theme={null}
const est = await client.estimateCost({
  type: "regular",
  tasks: [{ url: "https://a" }, { url: "https://b" }],
  zenrowsParams: { js_render: "true" },
});
console.log(String(est)); // "10 credits (2 tasks)"
console.log(est.format()); // per-tier breakdown table
```

An estimate assumes each URL succeeds once; it is a single number unless the job uses `mode=auto`, which yields a range.

## Checking Actual Cost

Once a run finishes, read the **actual usage it consumed** from `run.data.stats.spend` (`{ credits, cost }`).

```typescript theme={null}
const run = await client.job(jobId).run.wait();
const spend = run.data.stats.spend;
if (spend) console.log(`this run used ${spend.credits} usage (cost ${spend.cost})`);
```

Only successful requests are charged, so realized spend is at most the estimate. Per-task `spend` is indicative and often absent on successful rows, so rely on the run-level rollup for actuals. Your ZenRows account statement remains the authoritative record for billing.

## Signed Webhooks: HMAC Keys

Signed deliveries (see [Webhooks](#completion-notifications-webhooks)) need an active HMAC key for your organization. Rotation uses two slots, active plus candidate, so you can roll keys without downtime.

```typescript theme={null}
const created = await client.rotateHmacKey(); // the first call creates the active key
console.log(created.kid, created.secret);     // the secret is returned ONCE, so store it now

// Phased rotation:
await client.rotateHmacKey();       // stage a candidate (start accepting both)
await client.finalizeHmacKey();     // promote the candidate to active, retire the old one
// await client.cancelHmacRotation(); // or discard the candidate
await client.listHmacKeys();        // metadata only; never returns secrets
```

Your receiver verifies the `X-Signature: t=<unix>,v1=<hex>,kid=<active>` header by recomputing `HMAC-SHA256(secret, "<t>.<raw_body>")` and comparing the result to `v1`, selecting the secret by `kid`.

## Error Handling

Every non-2xx surfaces as `BatchApiError`. Its `code` property carries the stable code from the RFC 7807 body.

```typescript theme={null}
import { BatchApiError } from "zenrows/batch";

try {
  await client.getJob("does-not-exist");
} catch (err) {
  if (err instanceof BatchApiError && err.code === "not_found") {
    // ...
  } else {
    throw err;
  }
}
```

Task-level failures don't throw. They appear as `failed` rows in `results()`, each with an `error` object you can read through `row.error.code` and `row.error.detail`.

## Timeouts and Retries

Transient failures (`429`, `502`, `503`, `504`, and network errors) are retried automatically with jittered exponential backoff, honoring `Retry-After`. Retries apply only to idempotent requests: `GET` / `PUT` / `DELETE`, plus `POST`s that carry an `idempotencyKey` (submit and rerun). Tune both through the constructor.

```typescript theme={null}
const client = new ZenRowsBatchClient("YOUR_ZENROWS_API_KEY", {
  timeout: 30_000, // per-request, ms
  retries: 3,      // 0 disables
});
```

## Idempotency

Pass `idempotencyKey` to a submit or rerun to guard against accidentally creating duplicate jobs. Each key can be used once: a later call that reuses the same key raises a `409` conflict, whether or not the body is identical, and the API does not store or replay the original result. It is therefore not a safe-retry token, so if a call fails midway, check whether the job was created before retrying with a new key.

```typescript theme={null}
const job = await client.submitRegular({
  urls: ["https://www.scrapingcourse.com/ecommerce/"],
  idempotencyKey: "nightly-2026-06-08",
});
```

The full Batch surface (jobs, runs, tasks, results, content, history, file inputs, and HMAC keys) is reachable through methods on `ZenRowsBatchClient`. See the [SDK repository on GitHub](https://github.com/ZenRows/zenrows-node-sdk).
