# API Error Codes Source: https://docs.zenrows.com/api-error-codes Complete list of Zenrows API error codes with troubleshooting steps, status code meanings, and solutions for common request failures. Understanding the Zenrows Fetch error codes is crucial for troubleshooting and optimizing your interactions with Zenrows. Below are common errors you may encounter, along with explanations and recommended actions. Each error code corresponds to specific conditions encountered during Fetch usage, from authentication problems to request handling and server responses. ## 400 Bad Request ### REQS001 Requests To This Domain Are Forbidden **Problem:** Requests to this URL are forbidden. **Solution:** 1. Check our [target sites' access restrictions and user behavior guidelines](/forbidden-sites) to see if the domain is explicitly blocked 2. Try using a different domain that provides similar data 3. If you need to scrape this specific domain for a legitimate business purpose, contact our support team to discuss potential options ### REQS002 Request Requirements Unsatisfied **Problem:** The requested URL domain needs JavaScript rendering and/or Premium Proxies due to its high-level security defenses. **Solution:** 1. Read the error message to understand the specific requirements for the domain 2. Add `js_render=true` and/or `premium_proxy=true` parameters to your request, depending on the domain's requirements ### REQS004 Invalid Params Provided **Problem:** Some parameters or even the URL are invalid or not properly encoded. **Solution:** 1. Read the error message to understand the specific issue 2. Ensure your URL is properly URL-encoded (check our [guide on encoding URLs](/fetch/faq#how-to-encode-urls)) or the http you use is doing it for you 3. Validate that all parameter values match the expected types (boolean, string, etc.) 4. Remove any invalid or unsupported parameters from your request 5. Check that you are not sending query parameters for the target page as parameters for the API call ### RESP004 CSS Extractor Parameter Is Not Valid **Problem:** The `css_extractor` parameter sent in your request is not valid. **Solution:** 1. Check your CSS selector syntax for errors (like missing brackets or quotes) 2. Simplify complex selectors that might be causing issues 3. Test your CSS selector on the actual page using browser developer tools first (`document.querySelector('.your-selector')`) 4. If extracting multiple elements, ensure your JSON structure follows the correct format: ```js theme={"dark"} css_extractor={ "title": "h1.product-title", "price": "span.product-price" } ``` ### REQS006 Invalid CAPTCHA Solver Key **Problem:** The configured CAPTCHA solver integration API key is invalid. **Solution:** 1. Verify your CAPTCHA solver API key in your [account integrations](https://app.zenrows.com/settings/integrations) page 2. Check that your CAPTCHA solver account has sufficient funds 3. Confirm the CAPTCHA solver service is operational through their status page 4. Try re-generating a new API key from your CAPTCHA solver service ### RESP008 Non-Retryable Error not related to Zenrows **Problem:** The target returned an error tied to the site's own configuration or certificate, not something Zenrows can solve or bypass by changing request parameters. Zenrows does not retry RESP008 automatically, but the underlying condition can be temporary on the target's side. **Solution:** 1. Check if the target website is accessible from your own browser or host 2. If you're using geolocation, try a different country as the site might only be available in specific regions 3. If the error reports a certificate problem but the certificate checks out fine when you access the site directly, the condition may be intermittent on the target's side. Wait a few minutes and retry once or twice. Don't retry in a loop, because repeated 400 errors can trigger a temporary IP block ([BLK0001](#BLK0001)). If it keeps failing, contact support with the `X-Request-Id` response header 4. For SSL certificate issues, try accessing the HTTP version of the site if available ## 401 Unauthorized ### AUTH001 API Key Missing **Problem:** No `apikey` information was sent in your request. **Solution:** 1. Add the `apikey` parameter to your request 2. Check your code to ensure the apikey is being included in every request 3. For API clients like Axios or Requests, set up default parameters to include your API key automatically 4. Verify the API endpoint structure - the key should be sent as a query parameter to the API endpoint ### AUTH002 Invalid API Key **Problem:** The `apikey` sent does not match the expected format. **Solution:** 1. Copy your API key directly from the [Zenrows Playground](https://app.zenrows.com/fetch) 2. Ensure no extra spaces or characters were accidentally included 3. Check for any string formatting or concatenation issues in your code 4. If using environment variables, verify they're loading correctly ### AUTH003 API Key Not Found **Problem:** The `apikey` sent in your request is not valid. **Solution:** 1. Verify you're using the correct API key from your [Zenrows Playground](https://app.zenrows.com/fetch) 2. Check if your API key has been revoked or regenerated recently 3. Ensure your account is active and working, you can perform a request from the Playground itself 4. For team accounts, confirm with your administrator that the key is still valid ## 402 Payment Required ### AUTH004 Usage Exceeded **Problem:** This `apikey` has spent its credit allowance for the current billing period. **This is not a permanent block.** The allowance renews at the end of the period, and requests succeed again from then on with no action on your part. Topping up or upgrading is how you carry on *before* that. Not to be confused with [AUTH006](#AUTH006), the concurrency limit — too many requests *at once*, rather than too many across the period. AUTH006 clears in seconds; AUTH004 clears at the period boundary. **Solution:** 1. Check where you stand and when the period ends: call `/v1/subscriptions/self/details` with your API key in the `X-API-Key` header, or run `zenrows usage`. Neither counts against your concurrency limit, so both are safe to call from a script right after a failure. 2. If you can wait for the renewal date, retry after it. Retrying sooner returns AUTH004 again unless the allowance itself has changed — a top-up or a plan upgrade raises it immediately, mid-period. 3. To continue now, top up in the [Zenrows Billing page](https://app.zenrows.com/billing) or upgrade in the [Zenrows Plans page](https://app.zenrows.com/plans). 4. For temporary needs or punctual issues, contact our support team. **Writing a client or agent?** Read `usage_percent` and `period_ends_at` from `/v1/subscriptions/self/details` before a large run, and back off rather than retry when you see AUTH004. A Fetch request costs between 1 and 25 credits depending on `js_render` and `premium_proxy`, and Browser Sessions and residential bandwidth draw on the same balance at their own rates, so a run can consume far more of the allowance than its request count suggests. ### AUTH005 API Key Is No Longer Valid **Problem:** This `apikey` has reached its validity period. **Solution:** 1. Check your subscription in the [Zenrows Billing page](https://app.zenrows.com/billing) 2. Check if your account is active and working, you can perform a request from the [Playground](https://app.zenrows.com/fetch) itself 3. Contact support if you believe there is an issue with your account ### AUTH010 Feature Is Not Included In Plan **Problem:** The requested feature is not included in your subscription plan. **Solution:** 1. Review the [features included in each plan](https://www.zenrows.com/pricing) 2. Upgrade to a plan that includes the feature you need 3. Modify your code to avoid using premium features if you don't upgrade 4. Contact our support team if you believe there is an issue with your account ### AUTH011 No Subscription Found **Problem:** This account does not have an active subscription. **Solution:** 1. Purchase a subscription plan from the [Zenrows plans page](https://app.zenrows.com/plans) 2. Check for any failed payment attempts in your account history 3. Verify your payment method details are correct 4. Contact our support team if you believe there is an issue with your account ### AUTH012 Subscription Does Not Allow Use Of The Product **Problem:** This account subscription does not allow the use of this service. **Solution:** 1. Check which Zenrows products your subscription includes 2. Upgrade to a plan that includes the product you're trying to use 3. Ensure you're using the correct API endpoint for your subscription 4. Contact our support team if you believe there is an issue with your account ### AUTH014 API Key Credit Cap Reached **Problem:** This `apikey` has reached one of its [credit caps](/first-steps/api-key-credit-caps) (per billing period, daily, weekly or monthly). Your other API keys keep working. Not to be confused with [AUTH004](#AUTH004), which means your account has no credits left. AUTH014 affects only the key that reached its cap. **Solution:** 1. Raise or remove the cap on the [API keys page](https://app.zenrows.com/settings/api-keys). 2. Or wait for the cap's window to reset. Daily, weekly and monthly caps reset in UTC; the billing-period cap resets with your billing period. ## 403 Forbidden ### AUTH009 User Is Not Verified **Problem:** This `apikey` belongs to a user that has not verified the email account. **Solution:** 1. Check your email inbox (including spam folder) for a verification email 2. Request a new verification email 3. Ensure your email address is entered correctly in your profile 4. Contact support if you continue to have verification issues ### BLK0001 IP Address Blocked **Problem:** Your IP address has been blocked for exceeding the maximum error rate allowed. **Solution:** 1. Wait a few minutes before retrying requests 2. Implement error handling in your code to prevent excessive failed requests 3. Use exponential backoff when retrying failed requests 4. If using the service in a high-traffic environment, consider implementing a queue system to manage request rates Visit our [troubleshooting guide](/fetch/troubleshooting/ip-address-blocked) for step-by-step instructions, common causes, and best practices to quickly restore access. ### REQS007 Extract Is Not Prepared For This Domain **Problem:** You asked Extract for a domain it has not been prepared for. The message names the domain. **Solution:** 1. Ask for the domain with [`POST /v1/extract/prepared-domains`](/extract/endpoints#getting-a-domain-prepared), then retry once it reports `ready` 2. Call [`GET /v1/extract/domains`](/extract/endpoints#which-domains-extract-serves) to see what Extract serves right now 3. Use [`css_extractor`](/fetch/features/css-extractor) meanwhile, which works on any website without preparation. The deprecated [`autoparse`](/fetch/features/autoparse) parameter also still works, but new integrations should not start there Preparation is per domain, not per account: once a domain is prepared it works for every Zenrows API key. Changing or upgrading your plan does not affect this error. ## 404 Not Found ### RESP002 Page Not Found **Problem:** The requested URL page returned a 404 HTTP Status Code. RESP002 and [RESP007](#RESP007) both return HTTP 404. Check the `code` field in the JSON error body to tell them apart: RESP002 means the page was not found on a domain that resolves, and RESP007 means the domain does not resolve. **Solution:** 1. Verify the URL exists by opening it in a browser 2. Check for typos or encoding issues in the URL 3. If the page was recently available, it might have been moved or deleted 4. For dynamic sites, try adding `js_render=true` as some 404 pages are generated via JavaScript 5. Note that these requests are billed ### RESP007 Site Not Found **Problem:** The requested target domain could not be resolved, or there is no DNS record associated with it. This returns an HTTP 404, the same status as [RESP002](#RESP002). Check the `code` field in the JSON error body: `RESP007` means the domain does not resolve (DNS failure), and `RESP002` means the page was not found. A retry will not change the result. **Solution:** 1. Verify the domain exists by checking in your browser 2. Check for typos in the domain name 3. If using an IP address, ensure it's correctly formatted 4. If the domain is new or rarely accessed, DNS propagation might still be in progress 5. Try using premium proxies and geolocation since the domain might be available only in certain countries 6. Note that these requests are billed ## 405 Method Not Allowed ### REQS005 Method Not Allowed **Problem:** The HTTP verb used to access this page is not allowed. **Solution:** 1. Change your request method to one of the allowed methods: GET, POST, or PUT 2. For complex requests, consider breaking them down into multiple simpler requests 3. Check if the endpoint you're trying to access has specific method requirements ## 407 Proxy Authentication Required ### AUTH007 Invalid Proxy-Authorization Header **Problem:** The Proxy-Authorization header sent does not match the expected format. **Solution:** 1. Ensure the Proxy-Authorization header is a base64 encoded string of `:` 2. Check your base64 encoding function for errors or incorrect character handling 3. Try API calls instead of proxy mode to ensure that the API key and account are working properly ## 413 Content Too Large ### RESP005 Response Size Exceeded The Limit **Problem:** The response data size is bigger than the maximum allowed download size. **Solution:** 1. Request specific parts of the page using CSS selectors instead of the entire page 2. Split large pages into multiple smaller requests by targeting specific sections 3. Use pagination parameters if available on the target site 4. If using JSON Response, the full response will be considered for the size limit - try removing it and see if it works Looking for ways to handle large responses? Check out our [troubleshooting guide](/fetch/troubleshooting/response-too-large) for practical strategies, examples, and tips to work within response size limits. ## 422 Unprocessable Entity ### RESP001 Could Not Get Content **Problem:** The service couldn't get the content. **Solution:** 1. Add `js_render=true` as the site might require JavaScript to load content 2. Enable `premium_proxy=true` if the site has anti-bot measures 3. Try adding geolocation to the request set to a country where the site is available 4. Increase wait time with `wait=5000` or higher if content loads slowly 5. Check the error's body for more specific details about the failure 6. Try using custom headers with a referer to mimic a real browser: ```js theme={"dark"} params={ // ... "custom_headers": true, } headers={ "Referer": "https://www.google.com" } ``` 7. If you already use `js_render=true` and `premium_proxy=true` and still get 422 after adding extras, remove `wait`, `wait_for`, and the custom `referer` header, then retest with only those two. Keep `proxy_country` if the site is region-restricted. Some protected sites respond better to the minimal configuration. See the [troubleshooting guide](/fetch/troubleshooting/troubleshooting-guide#still-failing-after-stacking-parameters-retest-with-the-minimal-config) for an example ## 424 Failed Dependency ### RESP006 Failed To Solve CAPTCHA **Problem:** The CAPTCHA solver provider was unable to solve the CAPTCHA detected in the page. **Solution:** 1. Check your CAPTCHA solver service account for sufficient balance 2. Try adding premium proxies to the request 3. Implement retry logic with increasing wait times between attempts ## 429 Too Many Requests When a burst of requests fails together, group the failures by HTTP status and `code` before troubleshooting. A burst can mix [404](#404) and 429 errors, and [AUTH006](#AUTH006) (concurrency) and [AUTH008](#AUTH008) (rate limit) both return 429. Fix each code with its own solution. ### AUTH006 Concurrency Exceeded **Problem:** The concurrency limit was reached. **Solution:** 1. Implement a queue system in your code to limit concurrency requests 2. Monitor the `Concurrency-Remaining` header to adjust your request rate dynamically 3. Increase wait times between batches of requests 4. For high-volume scraping needs, upgrade to a plan with higher concurrency limits 5. Learn more about [how Zenrows concurrency works](/fetch/features/concurrency) and implement the provided code examples 6. Note that canceled requests on the client side will not release concurrency until the processing is done in the server side - we recommend not setting a timeout below 3 minutes ### AUTH008 Rate Limit Exceeded **Problem:** The rate limit was reached. **Solution:** 1. Implement exponential backoff between requests 2. Distribute requests evenly over time rather than sending them in bursts 3. Set up a queue system with configurable delay between requests 4. For time-sensitive projects, consider upgrading to a plan with higher rate limits 5. Monitor usage patterns to identify and optimize peak request periods ## 500 Internal Server Error ### CTX0001 Context Cancelled **Problem:** The request was canceled from the client's side. **Solution:** 1. Check your client's timeout settings - we recommend not setting a timeout below 3 minutes 2. If you're canceling requests manually, review your cancellation logic 3. Ensure your network connection is stable during the entire request ### ERR0001 Unknown Error **Problem:** An internal error occurred. **Solution:** 1. Retry the request after a short delay (10-30 seconds) 2. Check the [Zenrows status page](https://status.zenrows.com/) for any ongoing issues 3. Implement error logging to capture the full error response for troubleshooting 4. If the error persists, contact support with details of your request ### ERR0000 Unknown Error **Problem:** An unexpected internal error occurred. **Solution:** 1. Retry the request after a short delay (10-30 seconds) 2. Check the [Zenrows status page](https://status.zenrows.com/) for any ongoing issues 3. Implement error logging to capture the full error response for troubleshooting 4. If the error persists, contact support with details of your request ## 502 Bad Gateway ### RESP003 Could Not Parse Content **Problem:** The request failed because the URL could not be automatically parsed. **Solution:** 1. Remove the `autoparse` parameter and process the raw HTML response 2. Contact support with details of your request if the issue persists ## 503 Service Unavailable ### RESP009 Service Temporarily At Capacity **Problem:** All scraping capacity is busy right now. This is a temporary condition on our side. **Solution:** 1. Retry the request after a short delay; this condition usually clears within seconds as capacity frees up 2. Increase the wait between retries (exponential backoff) and add a small random delay to each one (jitter), so repeated retries do not all arrive at the same instant 3. Treat it like any transient 503: the request is safe to retry 4. If you see it sustained over a longer period, contact support with your account and request details ## 504 Gateway Timeout ### CTX0002 Operation Timeout Exceeded **Problem:** The request exceeded the maximum allowed time and was aborted. **Solution:** 1. Check if the target website is slow or unresponsive in a browser 2. Reduce the complexity of your request if using JS Instructions 3. Try adding premium proxies to the request or geolocation to a country where the site is available 4. Break complex scraping tasks into smaller, more focused requests 5. If the issue persists, contact support with details of your request and use case ## Batch and Extract error codes [Batch](/batch/introduction) and the [Extract prepared-domains endpoints](/extract/endpoints) answer errors with lowercase `code` values, and each error's `type` links to its row below. Extract requests made with `extract=auto` use the uppercase codes above, such as [REQS007](#REQS007). For Batch, see how to handle each one in the [error handling reference](/batch/developer-guide-restapi#error-handling-reference). | HTTP | `code` | Meaning | | - | - | - | | 400 | `invalid_argument` | Batch: validation failed; `invalid_tasks[]` lists `{index, reason}`. Extract: the request body is missing or malformed; `detail` says what, and `reason` carries a tag such as `invalid_domain`. | | 401 | `unauthenticated` | Missing or invalid API key. | | 402 | `payment_required` | No credits available. | | 402 | `api_key_cap_reached` | The API key reached one of its [credit caps](/first-steps/api-key-credit-caps). The Batch equivalent of [AUTH014](#AUTH014). See [API key credit caps and Batch](/batch/developer-guide-restapi#api-key-credit-caps-and-batch). | | 404 | `not_found` | Batch: job, run, or task missing or not owned by you. Extract: this account has never submitted that domain. | | 409 | `conflict` / `run_not_terminal` / `idempotency_key_conflict` | State conflict; see `detail`. | | 409 | `partial_rerun_window_expired` | Batch: a partial rerun was requested more than 10 days after the original run. Start a full rerun instead. | | 409 | `in_progress` | Extract: that domain already has a preparation running. Poll its status instead of resubmitting. | | 409 | `idempotency_conflict` | Extract: the same `Idempotency-Key` was reused with a different body. | | 422 | (on `/content`) | Task failed; the body is the stored error. | | 429 | `quota_exceeded` | Account limit reached, such as the maximum of 3 concurrent active jobs. | | 429 | `out_of_allowance` | Extract: no domain preparation slots left this month. Check `GET /v1/extract/prepared-domains/allowance`. | | 503 | `internal` | Transient; safe to retry with a backoff. | # API Versioning and Deprecation Source: https://docs.zenrows.com/api-versioning How Zenrows versions its API, how a version is retired, and the twelve-month notice you get before anything you depend on stops working. Zenrows versions its API in the URL path. Every endpoint you call carries the version you are pinned to, so an integration written today keeps behaving the way it did on the day you wrote it. | Surface | Base URL | Version | | - | - | - | | Fetch and Extract | `https://api.zenrows.com/v1/` | `v1` | | Batch | `https://async.api.zenrows.com/v1` | `v1` | `v1` is the only version that has ever existed, and no version has ever been retired. ## What Changes Inside a Version A version is a compatibility promise, not a freeze. Inside `v1` we add, and we do not take away. **You can expect, without notice:** * New endpoints. * New optional request parameters, with the previous behavior as their default. * New fields in a response body. * New response headers. Write your integration so an unrecognized field or header is ignored rather than treated as an error. That is the one thing we ask of you in return, and it is what lets us ship improvements without moving anyone to a new version. **You will never see inside a version:** * A parameter removed, or its default changed. * A field removed from a response, or its type changed. * An endpoint removed, or its path changed. * An error code reused for a different condition. Error codes are part of the contract. `AUTH008` will always mean a rate limit was reached. See [API Error Codes](/api-error-codes) for the catalog. ## How a Version Is Retired If we ever need to retire a version, this is what happens, in this order. We publish the deprecation here and on the changelog, and email every account with traffic on the affected version. The version keeps working, unchanged, for at least **twelve months** from the announcement. Not twelve months from when you read about it: twelve months from the day it is announced. Every response from the deprecated version carries the standard HTTP fields, so your code can detect the deadline without anyone reading an email: ```http theme={"dark"} Deprecation: @1789603200 Sunset: Fri, 17 Sep 2027 00:00:00 GMT Link: ; rel="deprecation" ``` `Deprecation` (RFC 9745) is when the version became deprecated, given as a Unix timestamp in seconds (the `@` prefix). `Sunset` (RFC 8594) is when it stops answering. Both are present from the announcement onward, and both point to the same instant twelve months apart. We publish a guide covering every breaking difference with the announcement, not at the end of the window. ## What This Means if You Are an Agent Read `Sunset` on any response. If it is present, the version you are calling has a deadline, and the `Link` header points at this page. Nothing else about the response changes: a deprecated version answers exactly as it always did, right up to its sunset date. If you build integrations that run unattended, treat the presence of a `Sunset` header as a condition worth surfacing to a human, the same way you would surface a payment failure. ## Machine-Readable Description The full API surface is described in OpenAPI 3.1 at [www.zenrows.com/openapi.json](https://www.zenrows.com/openapi.json), and listed in the RFC 9727 catalog at [app.zenrows.com/.well-known/api-catalog](https://app.zenrows.com/.well-known/api-catalog). # Developer Guide: REST API Source: https://docs.zenrows.com/batch/developer-guide-restapi Drive the Zenrows Batch directly over HTTP. Each request is shown in Python, Node.js, Java, PHP, Go, Ruby, and cURL, with the full original logic preserved. 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. ## 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. * **Bodies:** send JSON with `Content-Type: application/json` and receive JSON. Errors come back as `application/problem+json`, a standard JSON error format (RFC 7807) with a stable `code` you can branch on. 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: ```javascript Node.js theme={"dark"} const BASE = process.env.BATCH_API_URL ?? "https://async.api.zenrows.com/v1"; const API_KEY = process.env.ZENROWS_API_KEY; async function api(method, path, { body, query, headers } = {}) { const url = new URL(BASE + path); for (const [k, v] of Object.entries(query ?? {})) { if (v !== undefined && v !== null) url.searchParams.set(k, v); } const res = await fetch(url, { method, headers: { "X-API-Key": API_KEY, ...(body ? { "Content-Type": "application/json" } : {}), ...headers, }, body: body ? JSON.stringify(body) : undefined, }); if (res.status === 204) return null; const isJson = (res.headers.get("content-type") ?? "").includes("json"); const payload = isJson ? await res.json() : await res.arrayBuffer(); if (!res.ok) { // RFC 7807 problem: { type, title, status, detail, code, invalid_tasks? } const err = new Error(payload?.detail || payload?.title || `HTTP ${res.status}`); err.status = res.status; err.code = payload?.code; err.problem = payload; throw err; } return payload; } ``` ## 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 submissions under 10,000 inline tasks and **`202 Accepted`** for 10,000 or more tasks (or a CSV input). 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. ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json'} payload = { 'type': 'regular', 'status': 'closed', 'zenrows_params': {'js_render': 'true', 'premium_proxy': 'true'}, 'tasks': [ {'url': 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie', 'external_id': 'order-1'}, {'url': 'https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket', 'external_id': 'order-2'}, ], } response = requests.post('https://async.api.zenrows.com/v1/jobs', headers=headers, data=json.dumps(payload)) print(response.json()) ``` ```javascript Node.js theme={"dark"} // Reuses the api() helper from the Basics section. const submitted = await api("POST", "/jobs", { body: { type: "regular", status: "closed", zenrows_params: { js_render: "true", premium_proxy: "true" }, tasks: [ { url: "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie", external_id: "order-1" }, { url: "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket", external_id: "order-2" }, ], }, }); console.log("submitted", submitted.job_id, submitted.latest_run.status); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; public class SubmitJob { public static void main(final String... args) throws Exception { String body = "{" + "\"type\":\"regular\",\"status\":\"closed\"," + "\"zenrows_params\":{\"js_render\":\"true\",\"premium_proxy\":\"true\"}," + "\"tasks\":[" + "{\"url\":\"https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie\",\"external_id\":\"order-1\"}," + "{\"url\":\"https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket\",\"external_id\":\"order-2\"}" + "]}"; String response = Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .bodyString(body, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} 'regular', 'status' => 'closed', 'zenrows_params' => ['js_render' => 'true', 'premium_proxy' => 'true'], 'tasks' => [ ['url' => 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie', 'external_id' => 'order-1'], ['url' => 'https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket', 'external_id' => 'order-2'], ], ]); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, 'https://async.api.zenrows.com/v1/jobs'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: YOUR_ZENROWS_API_KEY', 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $response = curl_exec($ch); echo $response . PHP_EOL; curl_close($ch); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "io" "log" "net/http" ) func main() { payload := []byte(`{ "type": "regular", "status": "closed", "zenrows_params": { "js_render": "true", "premium_proxy": "true" }, "tasks": [ { "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie", "external_id": "order-1" }, { "url": "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket", "external_id": "order-2" } ] }`) req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs", bytes.NewBuffer(payload)) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' payload = { type: 'regular', status: 'closed', zenrows_params: { js_render: 'true', premium_proxy: 'true' }, tasks: [ { url: 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie', external_id: 'order-1' }, { url: 'https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket', external_id: 'order-2' } ] } conn = Faraday.new res = conn.post('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.headers['Content-Type'] = 'application/json' req.body = payload.to_json end print(res.body) ``` ```bash cURL theme={"dark"} curl -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", "zenrows_params": { "js_render": "true", "premium_proxy": "true" }, "tasks": [ { "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie", "external_id": "order-1" }, { "url": "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket", "external_id": "order-2" } ] }' ``` ### 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: * [`js_render`](/fetch/features/js-rendering) * [`premium_proxy`](/fetch/features/premium-proxy) * [`proxy_country`](/fetch/features/proxy-country) * [`js_instructions`](/fetch/features/js-instructions) * [`wait_for`](/fetch/features/wait-for) * [`wait`](/fetch/features/wait) * [`block_resources`](/fetch/features/block-resources) * [`custom_headers`](/fetch/features/headers) * [`json_response`](/fetch/features/json-response) * [`css_extractor`](/fetch/features/css-extractor) * [`extract`](/extract/setup) * [`autoparse`](/fetch/features/autoparse) (deprecated, use `extract`) * `response_type` ([`markdown`](/fetch/features/markdown), [`plaintext`](/fetch/features/plaintext), or [`pdf`](/fetch/features/pdf)) * [`outputs`](/fetch/features/output-filters) * [`original_status`](/fetch/features/other#original-http-code) * [`allowed_status_codes`](/fetch/features/other#return-content-on-error) * [`session_id`](/fetch/features/other#session-id) ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json'} payload = { 'type': 'regular', 'status': 'closed', 'zenrows_params': {'js_render': 'true', 'proxy_country': 'us', 'response_type': 'markdown'}, 'tasks': [ {'url': 'https://www.scrapingcourse.com/ecommerce/product/artemis-running-short', 'external_id': 'order-42', 'zenrows_params': {'proxy_country': 'de'}}, {'url': 'https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee', 'metadata': {'sku': 'ABC-1'}}, ], } response = requests.post('https://async.api.zenrows.com/v1/jobs', headers=headers, data=json.dumps(payload)) print(response.json()) ``` ```javascript Node.js theme={"dark"} const job = await api("POST", "/jobs", { body: { type: "regular", status: "closed", zenrows_params: { js_render: "true", proxy_country: "us", response_type: "markdown" }, tasks: [ { url: "https://www.scrapingcourse.com/ecommerce/product/artemis-running-short", external_id: "order-42", zenrows_params: { proxy_country: "de" } }, { url: "https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee", metadata: { sku: "ABC-1" } }, ], }, }); console.log(job.job_id); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; public class ConfigureJob { public static void main(final String... args) throws Exception { String body = "{" + "\"type\":\"regular\",\"status\":\"closed\"," + "\"zenrows_params\":{\"js_render\":\"true\",\"proxy_country\":\"us\",\"response_type\":\"markdown\"}," + "\"tasks\":[" + "{\"url\":\"https://www.scrapingcourse.com/ecommerce/product/artemis-running-short\",\"external_id\":\"order-42\",\"zenrows_params\":{\"proxy_country\":\"de\"}}," + "{\"url\":\"https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee\",\"metadata\":{\"sku\":\"ABC-1\"}}" + "]}"; String response = Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .bodyString(body, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} 'regular', 'status' => 'closed', 'zenrows_params' => ['js_render' => 'true', 'proxy_country' => 'us', 'response_type' => 'markdown'], 'tasks' => [ ['url' => 'https://www.scrapingcourse.com/ecommerce/product/artemis-running-short', 'external_id' => 'order-42', 'zenrows_params' => ['proxy_country' => 'de']], ['url' => 'https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee', 'metadata' => ['sku' => 'ABC-1']], ], ]); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, 'https://async.api.zenrows.com/v1/jobs'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: YOUR_ZENROWS_API_KEY', 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); echo curl_exec($ch) . PHP_EOL; curl_close($ch); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "io" "log" "net/http" ) func main() { payload := []byte(`{ "type": "regular", "status": "closed", "zenrows_params": { "js_render": "true", "proxy_country": "us", "response_type": "markdown" }, "tasks": [ { "url": "https://www.scrapingcourse.com/ecommerce/product/artemis-running-short", "external_id": "order-42", "zenrows_params": { "proxy_country": "de" } }, { "url": "https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee", "metadata": { "sku": "ABC-1" } } ] }`) req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs", bytes.NewBuffer(payload)) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' payload = { type: 'regular', status: 'closed', zenrows_params: { js_render: 'true', proxy_country: 'us', response_type: 'markdown' }, tasks: [ { url: 'https://www.scrapingcourse.com/ecommerce/product/artemis-running-short', external_id: 'order-42', zenrows_params: { proxy_country: 'de' } }, { url: 'https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee', metadata: { sku: 'ABC-1' } } ] } conn = Faraday.new res = conn.post('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.headers['Content-Type'] = 'application/json' req.body = payload.to_json end print(res.body) ``` ```bash cURL theme={"dark"} curl -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", "zenrows_params": { "js_render": "true", "proxy_country": "us", "response_type": "markdown" }, "tasks": [ { "url": "https://www.scrapingcourse.com/ecommerce/product/artemis-running-short", "external_id": "order-42", "zenrows_params": { "proxy_country": "de" } }, { "url": "https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee", "metadata": { "sku": "ABC-1" } } ] }' ``` 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 does not need to be unique. ### POST target fetches By default, Batch fetches every target URL with an HTTP GET. To fetch a target with POST instead, set `method` and `body` on the task object. The body is stored verbatim and sent as-is with each request; maximum size is **16 KiB** per task. ```json theme={"dark"} { "type": "regular", "status": "closed", "tasks": [ { "url": "https://api.example.com/search", "method": "POST", "body": "{\"query\": \"web scraping\", \"page\": 1}", "zenrows_params": { "custom_headers": { "Content-Type": "application/json" } } }, { "url": "https://www.example.com/page", "external_id": "get-target" } ] } ``` 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. ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json'} job = requests.post('https://async.api.zenrows.com/v1/jobs', headers=headers, data=json.dumps({'type': 'regular', 'status': 'open', 'zenrows_params': {'js_render': 'true'}})).json() job_id = job['job_id'] requests.post(f'https://async.api.zenrows.com/v1/jobs/{job_id}/tasks', headers=headers, data=json.dumps({'tasks': [{'url': 'https://www.scrapingcourse.com/ecommerce/page/1'}]})) requests.post(f'https://async.api.zenrows.com/v1/jobs/{job_id}/tasks', headers=headers, data=json.dumps({'tasks': [{'url': 'https://www.scrapingcourse.com/ecommerce/page/2'}], 'last_batch': True})) ``` ```javascript Node.js theme={"dark"} const { job_id } = await api("POST", "/jobs", { body: { type: "regular", status: "open", zenrows_params: { js_render: "true" } }, }); await api("POST", `/jobs/${job_id}/tasks`, { body: { tasks: [{ url: "https://www.scrapingcourse.com/ecommerce/page/1" }] }, }); await api("POST", `/jobs/${job_id}/tasks`, { body: { tasks: [{ url: "https://www.scrapingcourse.com/ecommerce/page/2" }], last_batch: true }, // closes the job }); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; public class OpenJob { public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String created = Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", key) .bodyString("{\"type\":\"regular\",\"status\":\"open\",\"zenrows_params\":{\"js_render\":\"true\"}}", ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(created); // read job_id from this response String jobId = "JOB_ID"; Request.post("https://async.api.zenrows.com/v1/jobs/" + jobId + "/tasks") .addHeader("X-API-Key", key) .bodyString("{\"tasks\":[{\"url\":\"https://www.scrapingcourse.com/ecommerce/page/1\"}]}", ContentType.APPLICATION_JSON) .execute().discardContent(); Request.post("https://async.api.zenrows.com/v1/jobs/" + jobId + "/tasks") .addHeader("X-API-Key", key) .bodyString("{\"tasks\":[{\"url\":\"https://www.scrapingcourse.com/ecommerce/page/2\"}],\"last_batch\":true}", ContentType.APPLICATION_JSON) .execute().discardContent(); } } ``` ```php PHP theme={"dark"} 'regular', 'status' => 'open', 'zenrows_params' => ['js_render' => 'true']]); $jobId = $job['job_id']; post("/jobs/$jobId/tasks", ['tasks' => [['url' => 'https://www.scrapingcourse.com/ecommerce/page/1']]]); post("/jobs/$jobId/tasks", ['tasks' => [['url' => 'https://www.scrapingcourse.com/ecommerce/page/2']], 'last_batch' => true]); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "log" "net/http" ) func post(path string, body string) { req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1"+path, bytes.NewBufferString(body)) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() } func main() { // Create the open job, then read job_id from the response body. post("/jobs", `{"type":"regular","status":"open","zenrows_params":{"js_render":"true"}}`) jobId := "JOB_ID" post("/jobs/"+jobId+"/tasks", `{"tasks":[{"url":"https://www.scrapingcourse.com/ecommerce/page/1"}]}`) post("/jobs/"+jobId+"/tasks", `{"tasks":[{"url":"https://www.scrapingcourse.com/ecommerce/page/2"}],"last_batch":true}`) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' conn = Faraday.new conn.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' conn.headers['Content-Type'] = 'application/json' job = JSON.parse(conn.post('https://async.api.zenrows.com/v1/jobs', { type: 'regular', status: 'open', zenrows_params: { js_render: 'true' } }.to_json).body) job_id = job['job_id'] conn.post("https://async.api.zenrows.com/v1/jobs/#{job_id}/tasks", { tasks: [{ url: 'https://www.scrapingcourse.com/ecommerce/page/1' }] }.to_json) conn.post("https://async.api.zenrows.com/v1/jobs/#{job_id}/tasks", { tasks: [{ url: 'https://www.scrapingcourse.com/ecommerce/page/2' }], last_batch: true }.to_json) ``` ```bash cURL theme={"dark"} # 1) Create the open job and note its job_id. curl -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": "open", "zenrows_params": { "js_render": "true" } }' # 2) Add a batch of tasks. curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/tasks" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" -H "Content-Type: application/json" \ -d '{ "tasks": [{ "url": "https://www.scrapingcourse.com/ecommerce/page/1" }] }' # 3) Add the final batch and close the job. curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/tasks" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" -H "Content-Type: application/json" \ -d '{ "tasks": [{ "url": "https://www.scrapingcourse.com/ecommerce/page/2" }], "last_batch": true }' ``` `last_batch: true` (or `POST /jobs/{id}/close`) locks the job, and later adds return `409`. To abandon a run that is still in flight, call `POST /jobs/{id}/stop` (see [Control the current run](#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` 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 / 100,000 rows. Upload slots live 24 hours. ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} # 1) Create the slot. slot = requests.post('https://async.api.zenrows.com/v1/job_inputs', headers={**headers, 'Content-Type': 'application/json'}, data=json.dumps({'type': 'csv', 'csv': {'header': True, 'fields': {'url': 'URL', 'external_id': 'Customer Ref'}}})).json() # 2) Upload the file with the exact content-type the slot requires. with open('urls.csv', 'rb') as f: requests.put(slot['upload']['url'], headers=slot['upload']['headers'], data=f) # 3) Submit referencing the slot. job = requests.post('https://async.api.zenrows.com/v1/jobs', headers={**headers, 'Content-Type': 'application/json'}, data=json.dumps({'type': 'regular', 'status': 'closed', 'file_input_id': slot['file_input_id'], 'zenrows_params': {'js_render': 'true'}})).json() print(job['job_id']) ``` ```javascript Node.js theme={"dark"} import { readFile } from "node:fs/promises"; // 1) Create the slot. const slot = await api("POST", "/job_inputs", { body: { type: "csv", csv: { header: true, fields: { url: "URL", external_id: "Customer Ref" } } }, }); // 2) Upload the body with the EXACT content-type the slot requires. const csvBytes = await readFile("urls.csv"); const put = await fetch(slot.upload.url, { method: slot.upload.method, // "PUT" headers: slot.upload.headers, // e.g. { "Content-Type": "text/csv" } body: csvBytes, }); if (!put.ok) throw new Error(`upload failed: ${put.status}`); // 3) Submit referencing the slot instead of inline tasks. const job = await api("POST", "/jobs", { body: { type: "regular", status: "closed", file_input_id: slot.file_input_id, zenrows_params: { js_render: "true" } }, }); console.log(job.job_id); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; import java.io.File; public class CsvUpload { public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; // 1) Create the slot, then read upload.url, upload.headers, and file_input_id from the response. String slot = Request.post("https://async.api.zenrows.com/v1/job_inputs") .addHeader("X-API-Key", key) .bodyString("{\"type\":\"csv\",\"csv\":{\"header\":true,\"fields\":{\"url\":\"URL\",\"external_id\":\"Customer Ref\"}}}", ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(slot); // 2) PUT the file to the presigned URL with the content-type the slot requires. Request.put("PRESIGNED_UPLOAD_URL") .bodyFile(new File("urls.csv"), ContentType.create("text/csv")) .execute().discardContent(); // 3) Submit referencing the slot. String job = Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", key) .bodyString("{\"type\":\"regular\",\"status\":\"closed\",\"file_input_id\":\"FILE_INPUT_ID\",\"zenrows_params\":{\"js_render\":\"true\"}}", ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(job); } } ``` ```php PHP theme={"dark"} 'csv', 'csv' => ['header' => true, 'fields' => ['url' => 'URL', 'external_id' => 'Customer Ref']], ])); curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: $key", 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $slot = json_decode(curl_exec($ch), true); curl_close($ch); // 2) Upload the file with the exact content-type the slot requires. $ch = curl_init($slot['upload']['url']); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('urls.csv')); curl_setopt($ch, CURLOPT_HTTPHEADER, $slot['upload']['headers']); curl_exec($ch); curl_close($ch); // 3) Submit referencing the slot. $ch = curl_init('https://async.api.zenrows.com/v1/jobs'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ 'type' => 'regular', 'status' => 'closed', 'file_input_id' => $slot['file_input_id'], 'zenrows_params' => ['js_render' => 'true'], ])); curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: $key", 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); echo curl_exec($ch) . PHP_EOL; curl_close($ch); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "io" "log" "net/http" "os" ) func main() { key := "YOUR_ZENROWS_API_KEY" // 1) Create the slot, then read upload.url, upload.headers, and file_input_id from the response. slotReq, _ := http.NewRequest("POST", "https://async.api.zenrows.com/v1/job_inputs", bytes.NewBufferString(`{"type":"csv","csv":{"header":true,"fields":{"url":"URL","external_id":"Customer Ref"}}}`)) slotReq.Header.Set("X-API-Key", key) slotReq.Header.Set("Content-Type", "application/json") slotResp, err := http.DefaultClient.Do(slotReq) if err != nil { log.Fatalln(err) } defer slotResp.Body.Close() slotBody, _ := io.ReadAll(slotResp.Body) log.Println(string(slotBody)) // 2) PUT the file to the presigned URL with the content-type the slot requires. file, _ := os.Open("urls.csv") defer file.Close() putReq, _ := http.NewRequest("PUT", "PRESIGNED_UPLOAD_URL", file) putReq.Header.Set("Content-Type", "text/csv") if _, err := http.DefaultClient.Do(putReq); err != nil { log.Fatalln(err) } // 3) Submit referencing the slot. jobReq, _ := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs", bytes.NewBufferString(`{"type":"regular","status":"closed","file_input_id":"FILE_INPUT_ID","zenrows_params":{"js_render":"true"}}`)) jobReq.Header.Set("X-API-Key", key) jobReq.Header.Set("Content-Type", "application/json") jobResp, err := http.DefaultClient.Do(jobReq) if err != nil { log.Fatalln(err) } defer jobResp.Body.Close() jobBody, _ := io.ReadAll(jobResp.Body) log.Println(string(jobBody)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' key = 'YOUR_ZENROWS_API_KEY' conn = Faraday.new # 1) Create the slot. slot = JSON.parse(conn.post('https://async.api.zenrows.com/v1/job_inputs', { type: 'csv', csv: { header: true, fields: { url: 'URL', external_id: 'Customer Ref' } } }.to_json, { 'X-API-Key' => key, 'Content-Type' => 'application/json' }).body) # 2) Upload the file with the exact headers the slot requires. conn.put(slot['upload']['url'], File.read('urls.csv'), slot['upload']['headers']) # 3) Submit referencing the slot. job = JSON.parse(conn.post('https://async.api.zenrows.com/v1/jobs', { type: 'regular', status: 'closed', file_input_id: slot['file_input_id'], zenrows_params: { js_render: 'true' } }.to_json, { 'X-API-Key' => key, 'Content-Type' => 'application/json' }).body) puts job['job_id'] ``` ```bash cURL theme={"dark"} # 1) Create the slot. Read upload.url, upload.headers, and file_input_id from the response. curl -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": "URL", "external_id": "Customer Ref" } } }' # 2) Upload the file to the presigned URL with the content-type it requires. curl -X PUT "PRESIGNED_UPLOAD_URL" -H "Content-Type: text/csv" --data-binary @urls.csv # 3) Submit referencing the slot. curl -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": "FILE_INPUT_ID", "zenrows_params": { "js_render": "true" } }' ``` ### 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. The request below schedules a job to run every 6 hours. ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json'} payload = { 'type': 'scheduled', 'status': 'closed', 'schedule': {'rate': {'every': 6, 'unit': 'hour'}}, 'tasks': [{'url': 'https://www.scrapingcourse.com/ecommerce/'}], } response = requests.post('https://async.api.zenrows.com/v1/jobs', headers=headers, data=json.dumps(payload)) print(response.json()) ``` ```javascript Node.js theme={"dark"} const job = await api("POST", "/jobs", { body: { type: "scheduled", status: "closed", schedule: { rate: { every: 6, unit: "hour" } }, tasks: [{ url: "https://www.scrapingcourse.com/ecommerce/" }], }, }); console.log(job.job_id); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; public class ScheduledJob { public static void main(final String... args) throws Exception { String body = "{\"type\":\"scheduled\",\"status\":\"closed\"," + "\"schedule\":{\"rate\":{\"every\":6,\"unit\":\"hour\"}}," + "\"tasks\":[{\"url\":\"https://www.scrapingcourse.com/ecommerce/\"}]}"; String response = Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .bodyString(body, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} 'scheduled', 'status' => 'closed', 'schedule' => ['rate' => ['every' => 6, 'unit' => 'hour']], 'tasks' => [['url' => 'https://www.scrapingcourse.com/ecommerce/']], ]); $ch = curl_init('https://async.api.zenrows.com/v1/jobs'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: YOUR_ZENROWS_API_KEY', 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); echo curl_exec($ch) . PHP_EOL; curl_close($ch); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "io" "log" "net/http" ) func main() { payload := []byte(`{ "type": "scheduled", "status": "closed", "schedule": { "rate": { "every": 6, "unit": "hour" } }, "tasks": [{ "url": "https://www.scrapingcourse.com/ecommerce/" }] }`) req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs", bytes.NewBuffer(payload)) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' payload = { type: 'scheduled', status: 'closed', schedule: { rate: { every: 6, unit: 'hour' } }, tasks: [{ url: 'https://www.scrapingcourse.com/ecommerce/' }] } conn = Faraday.new res = conn.post('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.headers['Content-Type'] = 'application/json' req.body = payload.to_json end print(res.body) ``` ```bash cURL theme={"dark"} curl -X POST "https://async.api.zenrows.com/v1/jobs" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" -H "Content-Type: application/json" \ -d '{ "type": "scheduled", "status": "closed", "schedule": { "rate": { "every": 6, "unit": "hour" } }, "tasks": [{ "url": "https://www.scrapingcourse.com/ecommerce/" }] }' ``` 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. ```json theme={"dark"} { "schedule": { "calendar": { "times_of_day": ["09:00", "18:00"], "cadence": { "weekly": { "days": ["mon", "wed", "fri"] } } }, "timezone": "Europe/Berlin" } } ``` 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`. ```python Python theme={"dark"} ## pip install requests import requests response = requests.get('https://async.api.zenrows.com/v1/jobs/JOB_ID', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}) job = response.json() print(job['latest_run']['status'], job['latest_run']['stats']) ``` ```javascript Node.js theme={"dark"} const job = await api("GET", "/jobs/JOB_ID"); console.log(job.latest_run.status, job.latest_run.stats); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class GetJob { public static void main(final String... args) throws Exception { String response = Request.get("https://async.api.zenrows.com/v1/jobs/JOB_ID") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { req, err := http.NewRequest("GET", "https://async.api.zenrows.com/v1/jobs/JOB_ID", nil) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new res = conn.get('https://async.api.zenrows.com/v1/jobs/JOB_ID') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' end print(res.body) ``` ```bash cURL theme={"dark"} curl "https://async.api.zenrows.com/v1/jobs/JOB_ID" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` To wait for a run to finish, poll this endpoint until `latest_run.status` reaches a **terminal** state (`completed`, `failed`, `stopped`, or `deleted`), backing off between calls rather than running a tight loop. ```python Python theme={"dark"} ## pip install requests import time, requests def wait_for_job(job_id, timeout=600): terminal = {'completed', 'failed', 'stopped', 'deleted'} headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} deadline = time.time() + timeout delay = 2.0 while True: job = requests.get(f'https://async.api.zenrows.com/v1/jobs/{job_id}', headers=headers).json() run = job.get('latest_run') if run and run['status'] in terminal: return job if time.time() > deadline: raise TimeoutError('timeout waiting for job') time.sleep(delay) delay = min(delay * 1.5, 15.0) # back off gradually, capped at 15s job = wait_for_job('JOB_ID') s = job['latest_run']['stats'] # { total, completed, successful, failed } print(job['latest_run']['status'], f"{s['successful']}/{s['total']} successful") ``` ```javascript Node.js theme={"dark"} async function waitForJob(jobId, { timeoutMs = 600_000 } = {}) { const TERMINAL = new Set(["completed", "failed", "stopped", "deleted"]); const deadline = Date.now() + timeoutMs; let delay = 2000; for (;;) { const job = await api("GET", `/jobs/${jobId}`); const run = job.latest_run; if (run && TERMINAL.has(run.status)) return job; if (Date.now() > deadline) throw new Error("timeout waiting for job"); await new Promise((r) => setTimeout(r, delay)); delay = Math.min(delay * 1.5, 15_000); // back off gradually, capped at 15s } } const job = await waitForJob("JOB_ID"); const s = job.latest_run.stats; // { total, completed, successful, failed } console.log(`${job.latest_run.status}: ${s.successful}/${s.total} successful`); ``` ```java Java theme={"dark"} import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hc.client5.http.fluent.Request; import java.util.Set; public class WaitForJob { static final ObjectMapper M = new ObjectMapper(); public static void main(final String... args) throws Exception { Set terminal = Set.of("completed", "failed", "stopped", "deleted"); long deadline = System.currentTimeMillis() + 600_000; long delay = 2000; while (true) { String body = Request.get("https://async.api.zenrows.com/v1/jobs/JOB_ID") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString(); JsonNode run = M.readTree(body).path("latest_run"); if (terminal.contains(run.path("status").asText())) { System.out.println(run.path("stats")); break; } if (System.currentTimeMillis() > deadline) throw new RuntimeException("timeout waiting for job"); Thread.sleep(delay); delay = Math.min((long) (delay * 1.5), 15_000); // back off gradually, capped at 15s } } } ``` ```php PHP theme={"dark"} $deadline) throw new RuntimeException('timeout waiting for job'); usleep((int)($delay * 1000000)); $delay = min($delay * 1.5, 15.0); // back off gradually, capped at 15s } ?> ``` ```go Go theme={"dark"} package main import ( "encoding/json" "io" "log" "net/http" "time" ) func getJob(id string) map[string]any { req, _ := http.NewRequest("GET", "https://async.api.zenrows.com/v1/jobs/"+id, nil) req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var job map[string]any json.Unmarshal(body, &job) return job } func main() { terminal := map[string]bool{"completed": true, "failed": true, "stopped": true, "deleted": true} deadline := time.Now().Add(10 * time.Minute) delay := 2 * time.Second for { job := getJob("JOB_ID") run, _ := job["latest_run"].(map[string]any) status, _ := run["status"].(string) if run != nil && terminal[status] { log.Println(run["stats"]) break } if time.Now().After(deadline) { log.Fatalln("timeout waiting for job") } time.Sleep(delay) if delay < 15*time.Second { delay = time.Duration(float64(delay) * 1.5) } } } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' def get_job(id) res = Faraday.get("https://async.api.zenrows.com/v1/jobs/#{id}") do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' end JSON.parse(res.body) end terminal = ['completed', 'failed', 'stopped', 'deleted'] deadline = Time.now + 600 delay = 2.0 loop do job = get_job('JOB_ID') run = job['latest_run'] if run && terminal.include?(run['status']) p run['stats'] break end raise 'timeout waiting for job' if Time.now > deadline sleep(delay) delay = [delay * 1.5, 15.0].min # back off gradually, capped at 15s end ``` ```bash cURL theme={"dark"} # cURL has no loop of its own; poll with a small shell loop until the run is terminal. delay=2 while :; do status=$(curl -s "https://async.api.zenrows.com/v1/jobs/JOB_ID" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" | jq -r '.latest_run.status') echo "status: $status" case "$status" in completed|failed|stopped|deleted) break ;; esac sleep "$delay" delay=$(( delay < 15 ? delay + delay/2 + 1 : 15 )) # back off, capped at 15s done ``` ### 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` | Meaning | | - | - | | `insufficient_credits` | Your account has no credits available. | | `subscription_inactive` | Your subscription isn't active. | | `api_key_cap_reached` | The API key reached one of its [credit caps](/first-steps/api-key-credit-caps). See [API key credit caps and Batch](#api-key-credit-caps-and-batch). | `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](/first-steps/api-key-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](#5-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` and `type`, and page with `cursor` until `next_cursor` is null. ```python Python theme={"dark"} ## pip install requests import requests response = requests.get('https://async.api.zenrows.com/v1/jobs', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}, params={'status': 'closed', 'type': 'regular'}) print(response.json()) ``` ```javascript Node.js theme={"dark"} const page = await api("GET", "/jobs", { query: { status: "closed", type: "regular" } }); console.log(page.jobs); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class ListJobs { public static void main(final String... args) throws Exception { String response = Request.get("https://async.api.zenrows.com/v1/jobs?status=closed&type=regular") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { req, err := http.NewRequest("GET", "https://async.api.zenrows.com/v1/jobs?status=closed&type=regular", nil) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new res = conn.get('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.params['status'] = 'closed' req.params['type'] = 'regular' end print(res.body) ``` ```bash cURL theme={"dark"} curl "https://async.api.zenrows.com/v1/jobs?status=closed&type=regular" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` The cursor loop is the same as for results, and `GET /jobs/{id}/runs` pages identically. ```python Python theme={"dark"} ## pip install requests import requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} cursor = None while True: page = requests.get('https://async.api.zenrows.com/v1/jobs', headers=headers, params={'status': 'closed', 'type': 'regular', 'cursor': cursor}).json() for job in page['jobs']: latest = job.get('latest_run') print(job['job_id'], job['status'], latest['stats']['total'] if latest else 0) cursor = page['next_cursor'] if not cursor: break ``` ```javascript Node.js theme={"dark"} async function* iterJobs({ status, type } = {}) { let cursor; do { const page = await api("GET", "/jobs", { query: { status, type, cursor } }); yield* page.jobs; cursor = page.next_cursor; } while (cursor); } for await (const job of iterJobs({ status: "closed", type: "regular" })) { console.log(job.job_id, job.status, job.latest_run?.stats.total ?? 0); } ``` ```java Java theme={"dark"} import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hc.client5.http.fluent.Request; public class ListJobsPaged { static final ObjectMapper M = new ObjectMapper(); public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String cursor = null; do { String url = "https://async.api.zenrows.com/v1/jobs?status=closed&type=regular" + (cursor != null ? "&cursor=" + cursor : ""); JsonNode page = M.readTree(Request.get(url).addHeader("X-API-Key", key) .execute().returnContent().asString()); for (JsonNode job : page.path("jobs")) { long total = job.path("latest_run").path("stats").path("total").asLong(0); System.out.println(job.path("job_id").asText() + " " + job.path("status").asText() + " " + total); } cursor = page.path("next_cursor").asText(null); } while (cursor != null && !cursor.isEmpty()); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "encoding/json" "io" "log" "net/http" ) func main() { key := "YOUR_ZENROWS_API_KEY" cursor := "" for { url := "https://async.api.zenrows.com/v1/jobs?status=closed&type=regular" if cursor != "" { url += "&cursor=" + cursor } req, _ := http.NewRequest("GET", url, nil) req.Header.Set("X-API-Key", key) resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } body, _ := io.ReadAll(resp.Body) resp.Body.Close() var page struct { Jobs []struct { JobID string `json:"job_id"` Status string `json:"status"` LatestRun *struct { Stats struct { Total int `json:"total"` } `json:"stats"` } `json:"latest_run"` } `json:"jobs"` NextCursor string `json:"next_cursor"` } json.Unmarshal(body, &page) for _, job := range page.Jobs { total := 0 if job.LatestRun != nil { total = job.LatestRun.Stats.Total } log.Println(job.JobID, job.Status, total) } cursor = page.NextCursor if cursor == "" { break } } } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' key = 'YOUR_ZENROWS_API_KEY' cursor = nil loop do res = Faraday.get('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = key req.params['status'] = 'closed' req.params['type'] = 'regular' req.params['cursor'] = cursor if cursor end page = JSON.parse(res.body) page['jobs'].each do |job| total = job.dig('latest_run', 'stats', 'total') || 0 puts "#{job['job_id']} #{job['status']} #{total}" end cursor = page['next_cursor'] break unless cursor end ``` ```bash cURL theme={"dark"} cursor="" while :; do page=$(curl -s "https://async.api.zenrows.com/v1/jobs?status=closed&type=regular&cursor=$cursor" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY") echo "$page" | jq -r '.jobs[] | [.job_id, .status, (.latest_run.stats.total // 0)] | @tsv' cursor=$(echo "$page" | jq -r '.next_cursor // empty') [ -z "$cursor" ] && break done ``` ### 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`. | Endpoint | Purpose | | - | - | | `POST /jobs/{id}/stop` | Stop the current run (terminal). | | `POST /jobs/{id}/cancel` | Alias for `/stop`, identical behavior. | | `POST /jobs/{id}/pause` | Reversibly suspend the current run. | | `POST /jobs/{id}/resume` | Resume a paused run. | ```python Python theme={"dark"} ## pip install requests import requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} # Stop (terminal). /cancel is an identical alias. requests.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/stop', headers=headers) # Pause the current run, then resume it later. requests.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/pause', headers=headers) requests.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/resume', headers=headers) ``` ```javascript Node.js theme={"dark"} // Stop (terminal). /cancel is an identical alias. await api("POST", "/jobs/JOB_ID/stop"); // Pause the current run, then resume it later. await api("POST", "/jobs/JOB_ID/pause"); await api("POST", "/jobs/JOB_ID/resume"); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class ControlRun { public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String base = "https://async.api.zenrows.com/v1/jobs/JOB_ID"; // Stop (terminal); /cancel is an identical alias. Request.post(base + "/stop").addHeader("X-API-Key", key).execute().discardContent(); // Pause and resume the current run. Request.post(base + "/pause").addHeader("X-API-Key", key).execute().discardContent(); Request.post(base + "/resume").addHeader("X-API-Key", key).execute().discardContent(); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func control(action string) { req, _ := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs/JOB_ID/"+action, nil) req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } func main() { control("stop") // /cancel is an identical alias control("pause") control("resume") } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new conn.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' # Stop (terminal); /cancel is an identical alias. conn.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/stop') # Pause and resume the current run. conn.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/pause') conn.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/resume') ``` ```bash cURL theme={"dark"} # Stop the current run (terminal). /cancel is an identical alias. curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/stop" -H "X-API-Key: YOUR_ZENROWS_API_KEY" # Pause the current run, then resume it later. curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/pause" -H "X-API-Key: YOUR_ZENROWS_API_KEY" curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/resume" -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` **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](#delete-a-job-or-a-run)). It is idempotent on an already-stopped run, returns `409` if the run is `completed` or `deleted` (use a rerun to start fresh), and `404` on a deleted job. `/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` 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. | Endpoint | Purpose | | - | - | | `DELETE /jobs/{id}/runs/{run_id}` | Delete one run's stored results, keeping the job and its other runs. | | `DELETE /jobs/{id}` | Delete the whole job, including every run and result. | You are not required to delete anything to manage storage. Results and their download links are retained for **14 days** after a run finishes, then removed automatically. These endpoints are for removing data sooner than that. ```python Python theme={"dark"} ## pip install requests import requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} # Delete one run's results, keeping the job. requests.delete('https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID', headers=headers) # Delete the whole job (all runs and results). requests.delete('https://async.api.zenrows.com/v1/jobs/JOB_ID', headers=headers) ``` ```javascript Node.js theme={"dark"} // Delete one run's results, keeping the job. await api("DELETE", "/jobs/JOB_ID/runs/RUN_ID"); // Delete the whole job (all runs and results). await api("DELETE", "/jobs/JOB_ID"); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class DeleteJob { public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String base = "https://async.api.zenrows.com/v1/jobs/JOB_ID"; // Delete one run's results, keeping the job. Request.delete(base + "/runs/RUN_ID").addHeader("X-API-Key", key).execute().discardContent(); // Delete the whole job (all runs and results). Request.delete(base).addHeader("X-API-Key", key).execute().discardContent(); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "log" "net/http" ) func del(path string) { req, _ := http.NewRequest("DELETE", "https://async.api.zenrows.com/v1"+path, nil) req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() log.Println(resp.Status) } func main() { del("/jobs/JOB_ID/runs/RUN_ID") // one run's results del("/jobs/JOB_ID") // the whole job } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new conn.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' # Delete one run's results, keeping the job. conn.delete('https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID') # Delete the whole job (all runs and results). conn.delete('https://async.api.zenrows.com/v1/jobs/JOB_ID') ``` ```bash cURL theme={"dark"} # Delete one run's results, keeping the job. curl -X DELETE "https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID" -H "X-API-Key: YOUR_ZENROWS_API_KEY" # Delete the whole job (all runs and results). curl -X DELETE "https://async.api.zenrows.com/v1/jobs/JOB_ID" -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` **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 is idempotent, so deleting an already-deleted run is safe. **Delete a job.** `DELETE /jobs/{id}` removes the entire job, including every run and result. Deletion of the stored contents happens in the background, so the request is 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`, or `all`) and paged with the `cursor` query parameter until `next_cursor` is null. Each row's `result_url` is a presigned link valid for 2 hours, so fetch it promptly. Results are retained for 14 days after a run finishes, then removed automatically; re-list the run to get fresh links within that window. To remove results sooner, see [Delete a job or a run](#delete-a-job-or-a-run). ```python Python theme={"dark"} ## pip install requests import requests response = requests.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/results', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}, params={'status': 'successful'}) page = response.json() for row in page['results']: print(row.get('external_id') or row['task_id'], '->', row['result_url']) print('next_cursor:', page['next_cursor']) ``` ```javascript Node.js theme={"dark"} const page = await api("GET", "/jobs/JOB_ID/results", { query: { status: "successful" } }); for (const row of page.results) { console.log(row.external_id ?? row.task_id, "->", row.result_url); } console.log("next_cursor:", page.next_cursor); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class GetResults { public static void main(final String... args) throws Exception { String response = Request.get("https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { req, err := http.NewRequest("GET", "https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful", nil) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new res = conn.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/results') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.params['status'] = 'successful' end print(res.body) ``` ```bash cURL theme={"dark"} curl "https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` To page through every result and download each body, loop over the pages until `next_cursor` is null, fetching each presigned `result_url` promptly. ```python Python theme={"dark"} ## pip install requests import os, requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} os.makedirs('out', exist_ok=True) cursor = None while True: page = requests.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/results', headers=headers, params={'status': 'successful', 'cursor': cursor}).json() for row in page['results']: # result_url is a presigned link, valid 2h, so fetch it promptly. content = requests.get(row['result_url']).content with open(f"out/{row.get('external_id') or row['task_id']}", 'wb') as f: f.write(content) cursor = page['next_cursor'] if not cursor: break ``` ```javascript Node.js theme={"dark"} async function* iterResults(jobId, status = "all") { let cursor; do { const page = await api("GET", `/jobs/${jobId}/results`, { query: { status, cursor } }); yield* page.results; cursor = page.next_cursor; } while (cursor); } import { writeFile } from "node:fs/promises"; for await (const row of iterResults("JOB_ID", "successful")) { // row.result_url is a presigned link, valid 2h, so fetch it promptly. const bytes = Buffer.from(await (await fetch(row.result_url)).arrayBuffer()); await writeFile(`./out/${row.external_id ?? row.task_id}`, bytes); } ``` ```java Java theme={"dark"} import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hc.client5.http.fluent.Request; import java.nio.file.Files; import java.nio.file.Path; public class DownloadResults { static final ObjectMapper M = new ObjectMapper(); public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String cursor = null; do { String url = "https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful" + (cursor != null ? "&cursor=" + cursor : ""); JsonNode page = M.readTree(Request.get(url).addHeader("X-API-Key", key) .execute().returnContent().asString()); for (JsonNode row : page.path("results")) { // result_url is a presigned link, valid 2h, so fetch it promptly. byte[] bytes = Request.get(row.path("result_url").asText()) .execute().returnContent().asBytes(); String name = row.path("external_id").asText(row.path("task_id").asText()); Files.write(Path.of("out", name), bytes); } cursor = page.path("next_cursor").asText(null); } while (cursor != null && !cursor.isEmpty()); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "encoding/json" "io" "log" "net/http" "os" "path/filepath" ) func get(url, key string) []byte { req, _ := http.NewRequest("GET", url, nil) if key != "" { req.Header.Set("X-API-Key", key) } resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return body } func main() { key := "YOUR_ZENROWS_API_KEY" os.MkdirAll("out", 0o755) cursor := "" for { url := "https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful" if cursor != "" { url += "&cursor=" + cursor } var page struct { Results []struct { TaskID string `json:"task_id"` ExternalID string `json:"external_id"` ResultURL string `json:"result_url"` } `json:"results"` NextCursor string `json:"next_cursor"` } json.Unmarshal(get(url, key), &page) for _, row := range page.Results { // result_url is a presigned link, valid 2h, so fetch it promptly. name := row.ExternalID if name == "" { name = row.TaskID } os.WriteFile(filepath.Join("out", name), get(row.ResultURL, ""), 0o644) } cursor = page.NextCursor if cursor == "" { break } } } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' key = 'YOUR_ZENROWS_API_KEY' Dir.mkdir('out') unless Dir.exist?('out') cursor = nil loop do res = Faraday.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/results') do |req| req.headers['X-API-Key'] = key req.params['status'] = 'successful' req.params['cursor'] = cursor if cursor end page = JSON.parse(res.body) page['results'].each do |row| # result_url is a presigned link, valid 2h, so fetch it promptly. name = row['external_id'] || row['task_id'] File.write("out/#{name}", Faraday.get(row['result_url']).body, mode: 'wb') end cursor = page['next_cursor'] break unless cursor end ``` ```bash cURL theme={"dark"} # Page with a shell loop, then download each presigned result_url. mkdir -p out cursor="" while :; do page=$(curl -s "https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful&cursor=$cursor" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY") echo "$page" | jq -r '.results[] | [(.external_id // .task_id), .result_url] | @tsv' \ | while IFS=$'\t' read -r name url; do curl -s "$url" -o "out/$name"; done cursor=$(echo "$page" | jq -r '.next_cursor // empty') [ -z "$cursor" ] && break done ``` You can also fetch one task's body directly with `GET /jobs/{job_id}/tasks/{task_id}/content`. * `200`: returns the content * `409`: means the task is still processing * `422`: means the task failed and the body holds the stored error. ```python Python theme={"dark"} ## pip install requests import requests # 200 = content; 409 = still pending/processing; 422 = failed (body is the error) response = requests.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/tasks/TASK_ID/content', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}) print(response.status_code) print(response.text) ``` ```javascript Node.js theme={"dark"} // 200 = content; 409 = still pending/processing; 422 = failed (body is the error) const content = await api("GET", "/jobs/JOB_ID/tasks/TASK_ID/content"); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class TaskContent { public static void main(final String... args) throws Exception { // 200 = content; 409 = still pending/processing; 422 = failed (body is the error) String response = Request.get("https://async.api.zenrows.com/v1/jobs/JOB_ID/tasks/TASK_ID/content") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { // 200 = content; 409 = still pending/processing; 422 = failed (body is the error) req, err := http.NewRequest("GET", "https://async.api.zenrows.com/v1/jobs/JOB_ID/tasks/TASK_ID/content", nil) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(resp.StatusCode, string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' # 200 = content; 409 = still pending/processing; 422 = failed (body is the error) conn = Faraday.new res = conn.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/tasks/TASK_ID/content') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' end print(res.status) print(res.body) ``` ```bash cURL theme={"dark"} # 200 = content; 409 = still pending/processing; 422 = failed (body is the error) curl -i "https://async.api.zenrows.com/v1/jobs/JOB_ID/tasks/TASK_ID/content" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` 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`. Exports expire 12 hours after creation, the API presigns `download_url` fresh on each poll, and a run whose combined results exceed 100 GiB fails with a clear error instead of producing a partial archive. ```python Python theme={"dark"} ## pip install requests import requests started = requests.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}).json() print(started['export_id'], started['status']) # Poll the export by id until status == 'completed', then download started['download_url']. ``` ```javascript Node.js theme={"dark"} const started = await api("POST", "/jobs/JOB_ID/runs/RUN_ID/exports"); console.log(started.export_id, started.status); // Poll the export by id until status === 'completed', then download download_url. ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class StartExport { public static void main(final String... args) throws Exception { String response = Request.post("https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString(); System.out.println(response); // Poll the export by id until status is "completed", then download download_url. } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports", nil) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) // Poll the export by id until status is "completed", then download download_url. } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new res = conn.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' end print(res.body) # Poll the export by id until status is "completed", then download download_url. ``` ```bash cURL theme={"dark"} # Start the export, then poll the returned export_id until status is "completed". curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" curl "https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports/EXPORT_ID" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` Start the export, poll until it is `completed`, then download the ZIP. ```python Python theme={"dark"} ## pip install requests import time, requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} base = 'https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports' exp = requests.post(base, headers=headers).json() while exp['status'] not in ('completed', 'failed'): time.sleep(3) exp = requests.get(f"{base}/{exp['export_id']}", headers=headers).json() if exp['status'] == 'failed': raise RuntimeError(exp.get('error')) with open('results.zip', 'wb') as f: f.write(requests.get(exp['download_url']).content) ``` ```javascript Node.js theme={"dark"} import { writeFile } from "node:fs/promises"; let exp = await api("POST", "/jobs/JOB_ID/runs/RUN_ID/exports"); while (exp.status !== "completed" && exp.status !== "failed") { await new Promise((r) => setTimeout(r, 3000)); exp = await api("GET", `/jobs/JOB_ID/runs/RUN_ID/exports/${exp.export_id}`); } if (exp.status === "failed") throw new Error(exp.error); const zip = Buffer.from(await (await fetch(exp.download_url)).arrayBuffer()); await writeFile("results.zip", zip); ``` ```java Java theme={"dark"} import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hc.client5.http.fluent.Request; import java.nio.file.Files; import java.nio.file.Path; public class ExportRun { static final ObjectMapper M = new ObjectMapper(); public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String base = "https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports"; JsonNode exp = M.readTree(Request.post(base).addHeader("X-API-Key", key) .execute().returnContent().asString()); String id = exp.path("export_id").asText(); while (!exp.path("status").asText().equals("completed") && !exp.path("status").asText().equals("failed")) { Thread.sleep(3000); exp = M.readTree(Request.get(base + "/" + id).addHeader("X-API-Key", key) .execute().returnContent().asString()); } if (exp.path("status").asText().equals("failed")) throw new RuntimeException(exp.path("error").asText()); byte[] zip = Request.get(exp.path("download_url").asText()).execute().returnContent().asBytes(); Files.write(Path.of("results.zip"), zip); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "encoding/json" "io" "log" "net/http" "os" "time" ) func call(method, url, key string) map[string]any { req, _ := http.NewRequest(method, url, nil) if key != "" { req.Header.Set("X-API-Key", key) } resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var out map[string]any json.Unmarshal(body, &out) return out } func main() { key := "YOUR_ZENROWS_API_KEY" base := "https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports" exp := call("POST", base, key) id, _ := exp["export_id"].(string) for exp["status"] != "completed" && exp["status"] != "failed" { time.Sleep(3 * time.Second) exp = call("GET", base+"/"+id, key) } if exp["status"] == "failed" { log.Fatalln(exp["error"]) } req, _ := http.NewRequest("GET", exp["download_url"].(string), nil) resp, _ := http.DefaultClient.Do(req) defer resp.Body.Close() out, _ := os.Create("results.zip") defer out.Close() io.Copy(out, resp.Body) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' key = 'YOUR_ZENROWS_API_KEY' base = 'https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports' exp = JSON.parse(Faraday.post(base) { |r| r.headers['X-API-Key'] = key }.body) until %w[completed failed].include?(exp['status']) sleep 3 exp = JSON.parse(Faraday.get("#{base}/#{exp['export_id']}") { |r| r.headers['X-API-Key'] = key }.body) end raise(exp['error'] || 'export failed') if exp['status'] == 'failed' File.write('results.zip', Faraday.get(exp['download_url']).body, mode: 'wb') ``` ```bash cURL theme={"dark"} base="https://async.api.zenrows.com/v1/jobs/JOB_ID/runs/RUN_ID/exports" started=$(curl -s -X POST "$base" -H "X-API-Key: YOUR_ZENROWS_API_KEY") id=$(echo "$started" | jq -r '.export_id') while :; do exp=$(curl -s "$base/$id" -H "X-API-Key: YOUR_ZENROWS_API_KEY") status=$(echo "$exp" | jq -r '.status') case "$status" in completed|failed) break ;; esac sleep 3 done [ "$status" = "failed" ] && { echo "export failed"; exit 1; } curl -s "$(echo "$exp" | jq -r '.download_url')" -o results.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 a `run.completed` event to your HTTPS URL when a run completes, or a `run.failed` event when it fails. Set `signature: true` for HMAC-signed deliveries (see [Manage HMAC keys](#manage-hmac-keys)). You can attach it on the submit body, replace it later with `PUT /jobs/{id}/webhook`, remove it with `DELETE /jobs/{id}/webhook`, and probe a receiver with `POST /webhook/test`. ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json'} webhook = {'url': 'https://your-app.com/webhooks/zenrows', 'signature': True} # Set or replace the webhook on an existing job: requests.put('https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook', headers=headers, data=json.dumps(webhook)) # Probe a receiver before wiring it up: probe = requests.post('https://async.api.zenrows.com/v1/webhook/test', headers=headers, data=json.dumps(webhook)).json() print(probe['delivered'], probe['status_code']) # Stop deliveries: requests.delete('https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}) ``` ```javascript Node.js theme={"dark"} const webhook = { url: "https://your-app.com/webhooks/zenrows", signature: true }; // At submit time, include it on the body: { ..., webhook }. // Or manage it on an existing job: await api("PUT", "/jobs/JOB_ID/webhook", { body: webhook }); await api("DELETE", "/jobs/JOB_ID/webhook"); // stop deliveries // Verify a receiver before wiring it to a job: const probe = await api("POST", "/webhook/test", { body: webhook }); console.log(probe.delivered, probe.status_code); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; public class Webhook { public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String webhook = "{\"url\":\"https://your-app.com/webhooks/zenrows\",\"signature\":true}"; // Set or replace the webhook on an existing job: Request.put("https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook") .addHeader("X-API-Key", key).bodyString(webhook, ContentType.APPLICATION_JSON) .execute().discardContent(); // Probe a receiver: String probe = Request.post("https://async.api.zenrows.com/v1/webhook/test") .addHeader("X-API-Key", key).bodyString(webhook, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(probe); // Stop deliveries: Request.delete("https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook") .addHeader("X-API-Key", key).execute().discardContent(); } } ``` ```php PHP theme={"dark"} 'https://your-app.com/webhooks/zenrows', 'signature' => true]); // Set or replace the webhook on an existing job: $ch = curl_init('https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook'); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); curl_setopt($ch, CURLOPT_POSTFIELDS, $webhook); curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: $key", 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_exec($ch); curl_close($ch); // Probe a receiver: $ch = curl_init('https://async.api.zenrows.com/v1/webhook/test'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $webhook); curl_setopt($ch, CURLOPT_HTTPHEADER, ["X-API-Key: $key", 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); echo curl_exec($ch) . PHP_EOL; curl_close($ch); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "io" "log" "net/http" ) func main() { webhook := `{ "url": "https://your-app.com/webhooks/zenrows", "signature": true }` // Set or replace the webhook on an existing job: putReq, _ := http.NewRequest("PUT", "https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook", bytes.NewBufferString(webhook)) putReq.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") putReq.Header.Set("Content-Type", "application/json") if _, err := http.DefaultClient.Do(putReq); err != nil { log.Fatalln(err) } // Probe a receiver: testReq, _ := http.NewRequest("POST", "https://async.api.zenrows.com/v1/webhook/test", bytes.NewBufferString(webhook)) testReq.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") testReq.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(testReq) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' conn = Faraday.new conn.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' conn.headers['Content-Type'] = 'application/json' webhook = { url: 'https://your-app.com/webhooks/zenrows', signature: true }.to_json # Set or replace the webhook on an existing job: conn.put('https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook', webhook) # Probe a receiver: puts conn.post('https://async.api.zenrows.com/v1/webhook/test', webhook).body # Stop deliveries: conn.delete('https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook') ``` ```bash cURL theme={"dark"} # Set or replace the webhook on an existing job: curl -X PUT "https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/zenrows", "signature": true }' # Probe a receiver: curl -X POST "https://async.api.zenrows.com/v1/webhook/test" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.com/webhooks/zenrows", "signature": false }' # Stop deliveries: curl -X DELETE "https://async.api.zenrows.com/v1/jobs/JOB_ID/webhook" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` The API delivers each event at least once, so deduplicate on the `X-ZenRows-Event-Id` header. Webhooks are a convenience; the run state and result URLs remain the reliable source of truth. 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](#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 ```python Python theme={"dark"} ## pip install requests import requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} created = requests.post('https://async.api.zenrows.com/v1/hmac/keys/rotate', headers=headers).json() print(created) # store created['secret'] now; it is returned only once requests.post('https://async.api.zenrows.com/v1/hmac/keys/rotate/finalize', headers=headers) # requests.delete('https://async.api.zenrows.com/v1/hmac/keys/rotate', headers=headers) # discard candidate print(requests.get('https://async.api.zenrows.com/v1/hmac/keys', headers=headers).json()) ``` ```javascript Node.js theme={"dark"} const created = await api("POST", "/hmac/keys/rotate"); console.log(created.kid, created.secret); // the secret is returned ONCE, so store it now await api("POST", "/hmac/keys/rotate/finalize"); // promote candidate -> active, retire the old // await api("DELETE", "/hmac/keys/rotate"); // or discard the candidate console.log(await api("GET", "/hmac/keys")); // metadata only; never returns secrets ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class HmacKeys { public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; String created = Request.post("https://async.api.zenrows.com/v1/hmac/keys/rotate") .addHeader("X-API-Key", key).execute().returnContent().asString(); System.out.println(created); // store the secret now; it is returned only once Request.post("https://async.api.zenrows.com/v1/hmac/keys/rotate/finalize") .addHeader("X-API-Key", key).execute().discardContent(); // Request.delete("https://async.api.zenrows.com/v1/hmac/keys/rotate") // .addHeader("X-API-Key", key).execute().discardContent(); // discard candidate String list = Request.get("https://async.api.zenrows.com/v1/hmac/keys") .addHeader("X-API-Key", key).execute().returnContent().asString(); System.out.println(list); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func call(method, path string) string { req, _ := http.NewRequest(method, "https://async.api.zenrows.com/v1"+path, nil) req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return string(body) } func main() { log.Println(call("POST", "/hmac/keys/rotate")) // store the secret now call("POST", "/hmac/keys/rotate/finalize") // promote the candidate // call("DELETE", "/hmac/keys/rotate") // or discard the candidate log.Println(call("GET", "/hmac/keys")) // metadata only } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new conn.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' puts conn.post('https://async.api.zenrows.com/v1/hmac/keys/rotate').body # store the secret now conn.post('https://async.api.zenrows.com/v1/hmac/keys/rotate/finalize') # promote the candidate # conn.delete('https://async.api.zenrows.com/v1/hmac/keys/rotate') # or discard the candidate puts conn.get('https://async.api.zenrows.com/v1/hmac/keys').body # metadata only ``` ```bash cURL theme={"dark"} # Create the active key or stage a candidate (the secret is returned once): curl -X POST "https://async.api.zenrows.com/v1/hmac/keys/rotate" -H "X-API-Key: YOUR_ZENROWS_API_KEY" # Promote the candidate to active and retire the old key: curl -X POST "https://async.api.zenrows.com/v1/hmac/keys/rotate/finalize" -H "X-API-Key: YOUR_ZENROWS_API_KEY" # Or discard the staged candidate: curl -X DELETE "https://async.api.zenrows.com/v1/hmac/keys/rotate" -H "X-API-Key: YOUR_ZENROWS_API_KEY" # List key metadata (never returns secrets): curl "https://async.api.zenrows.com/v1/hmac/keys" -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` ### Verify signed webhooks When `signature: true`, each delivery includes an `X-Signature` header in the form `t=,v1=,kid=`, where `t` is the timestamp, `v1` is the signature, and `kid` identifies the key used. To verify a delivery: 1. Look up the base64 secret that matches the delivery's `kid`. 2. Compute `HMAC-SHA256(secret, ".")`, signing the timestamp, a literal dot, and the raw request body. 3. Compare the result to `v1`. If they match, the delivery is authentic. 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 compute the expected signature so you can compare it to `v1` in your receiver. ```python Python theme={"dark"} import hmac, hashlib, base64 def verify(raw_body: bytes, t: str, v1: str, secret_b64: str) -> bool: secret = base64.b64decode(secret_b64) expected = hmac.new(secret, f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) ``` ```javascript Node.js theme={"dark"} import crypto from "node:crypto"; // SECRETS maps kid -> the base64 secret you captured at /hmac/keys/rotate. function verifyWebhook(rawBody, signatureHeader, SECRETS) { const parts = Object.fromEntries( signatureHeader.split(",").map((kv) => kv.split("=")) ); // { t, v1, kid } const secret = SECRETS[parts.kid]; if (!secret) return false; const expected = crypto .createHmac("sha256", Buffer.from(secret, "base64")) .update(`${parts.t}.${rawBody}`) .digest("hex"); // constant-time compare const a = Buffer.from(expected), b = Buffer.from(parts.v1); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express-style receiver (capture the raw body!): // app.post("/zr", express.raw({ type: "*/*" }), (req, res) => { // const eventId = req.header("X-ZenRows-Event-Id"); // dedup on this // const sig = req.header("X-Signature"); // absent => unsigned // if (sig && !verifyWebhook(req.body.toString(), sig, SECRETS)) return res.sendStatus(401); // if (alreadyHandled(eventId)) return res.sendStatus(200); // handle(JSON.parse(req.body.toString())); // res.sendStatus(200); // }); ``` ```java Java theme={"dark"} import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Base64; public class VerifyWebhook { static boolean verify(String rawBody, String t, String v1, String secretB64) throws Exception { byte[] secret = Base64.getDecoder().decode(secretB64); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret, "HmacSHA256")); byte[] digest = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(); for (byte x : digest) hex.append(String.format("%02x", x)); return MessageDigest.isEqual( hex.toString().getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8)); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" ) func verify(rawBody, t, v1, secretB64 string) bool { secret, _ := base64.StdEncoding.DecodeString(secretB64) mac := hmac.New(sha256.New, secret) mac.Write([]byte(t + "." + rawBody)) expected := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(expected), []byte(v1)) } ``` ```ruby Ruby theme={"dark"} require 'openssl' require 'base64' def verify(raw_body, t, v1, secret_b64) secret = Base64.decode64(secret_b64) expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{t}.#{raw_body}") OpenSSL.fixed_length_secure_compare(expected, v1) rescue StandardError expected == v1 end ``` ```bash cURL theme={"dark"} # Verification happens in your receiver, not in a request. With openssl: # T and BODY come from the delivery; SECRET_B64 is your base64 key. key_hex=$(printf '%s' "$SECRET_B64" | base64 -d | xxd -p -c 256) printf '%s' "${T}.${BODY}" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$key_hex" # Compare the printed hex digest against the v1 value from X-Signature. ``` ## 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`. Partial reruns (filtering by `?status=failed` or `?status=failed,pending`) must be triggered within **10 days** of the original run completing. After that, the API returns `409 partial_rerun_window_expired`; start a full rerun instead, which replays every task regardless of previous status. ```python Python theme={"dark"} ## pip install requests import requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} # Full rerun: requests.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun', headers=headers) # Retry only the failures: response = requests.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun', headers=headers, params={'status': 'failed'}) data = response.json() print('new run', data['latest_run']['run_id'], 'retried', data['retried_tasks'], 'inherited', data['inherited_tasks']) ``` ```javascript Node.js theme={"dark"} // Full rerun: replay every task as a new run: const full = await api("POST", "/jobs/JOB_ID/rerun"); // Retry only failures (add ",pending" to also re-run tasks that never started): const retry = await api("POST", "/jobs/JOB_ID/rerun", { query: { status: "failed" } }); console.log("new run", retry.latest_run.run_id, "retried", retry.retried_tasks, "inherited", retry.inherited_tasks); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class Rerun { public static void main(final String... args) throws Exception { String key = "YOUR_ZENROWS_API_KEY"; // Full rerun: Request.post("https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun") .addHeader("X-API-Key", key).execute().discardContent(); // Retry only the failures: String response = Request.post("https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun?status=failed") .addHeader("X-API-Key", key).execute().returnContent().asString(); System.out.println(response); // includes retried_tasks and inherited_tasks } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { // Retry only the failures (drop the query string for a full rerun). req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun?status=failed", nil) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) // includes retried_tasks and inherited_tasks } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' conn = Faraday.new conn.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' # Full rerun: conn.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun') # Retry only the failures: res = conn.post('https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun') do |req| req.params['status'] = 'failed' end print(res.body) # includes retried_tasks and inherited_tasks ``` ```bash cURL theme={"dark"} # Full rerun: curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" # Retry only the failures: curl -X POST "https://async.api.zenrows.com/v1/jobs/JOB_ID/rerun?status=failed" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` ## 6. Cost Estimate spend before you submit, and read actual spend after a run. ### Estimate cost Pricing is per successful request by tier, 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](/first-steps/pricing#cost-multipliers-for-fetch). ```python Python theme={"dark"} def task_cost(params): if str(params.get('mode')) == 'auto': return (1, 25) js = str(params.get('js_render')) == 'true' proxy = str(params.get('premium_proxy')) == 'true' c = 25 if js and proxy else 10 if proxy else 5 if js else 1 return (c, c) def estimate(body): job = body.get('zenrows_params', {}) lo = hi = 0 for t in body.get('tasks', []): a, b = task_cost({**job, **t.get('zenrows_params', {})}) lo += a hi += b return {'tasks': len(body.get('tasks', [])), 'min': lo, 'max': hi, 'exact': lo == hi} ``` ```javascript Node.js theme={"dark"} const TIERS = { base: 1, js: 5, proxy: 10, both: 25 }; // credits per successful request function taskCost(params = {}) { const v = (x) => String(x) === "true"; if (String(params.mode) === "auto") return { min: 1, max: 25 }; // dynamic const js = v(params.js_render), proxy = v(params.premium_proxy); const c = js && proxy ? TIERS.both : proxy ? TIERS.proxy : js ? TIERS.js : TIERS.base; return { min: c, max: c }; } function estimate(submitBody) { const job = submitBody.zenrows_params ?? {}; let min = 0, max = 0; for (const t of submitBody.tasks ?? []) { const { min: a, max: b } = taskCost({ ...job, ...(t.zenrows_params ?? {}) }); min += a; max += b; } return { tasks: (submitBody.tasks ?? []).length, min, max, exact: min === max }; } ``` ```java Java theme={"dark"} import java.util.List; import java.util.Map; public class Estimate { static int[] taskCost(Map p) { if ("auto".equals(p.get("mode"))) return new int[]{1, 25}; boolean js = "true".equals(p.get("js_render")); boolean proxy = "true".equals(p.get("premium_proxy")); int c = js && proxy ? 25 : proxy ? 10 : js ? 5 : 1; return new int[]{c, c}; } // Merge job-level and per-task params before calling taskCost for each task, // then sum the min and max across all tasks. static int[] estimate(List> mergedTaskParams) { int min = 0, max = 0; for (Map p : mergedTaskParams) { int[] c = taskCost(p); min += c[0]; max += c[1]; } return new int[]{min, max}; } } ``` ```php PHP theme={"dark"} count($body['tasks'] ?? []), 'min' => $min, 'max' => $max, 'exact' => $min === $max]; } ?> ``` ```go Go theme={"dark"} package main func taskCost(p map[string]string) (int, int) { if p["mode"] == "auto" { return 1, 25 } js := p["js_render"] == "true" proxy := p["premium_proxy"] == "true" switch { case js && proxy: return 25, 25 case proxy: return 10, 10 case js: return 5, 5 default: return 1, 1 } } // Merge job-level and per-task params before calling taskCost for each task, // then sum the min and max across all tasks. func estimate(mergedTaskParams []map[string]string) (int, int) { min, max := 0, 0 for _, p := range mergedTaskParams { lo, hi := taskCost(p) min += lo max += hi } return min, max } ``` ```ruby Ruby theme={"dark"} def task_cost(params) return [1, 25] if params['mode'].to_s == 'auto' js = params['js_render'].to_s == 'true' proxy = params['premium_proxy'].to_s == 'true' c = js && proxy ? 25 : proxy ? 10 : js ? 5 : 1 [c, c] end def estimate(body) job = body['zenrows_params'] || {} min = 0; max = 0 (body['tasks'] || []).each do |t| a, b = task_cost(job.merge(t['zenrows_params'] || {})) min += a; max += b end { tasks: (body['tasks'] || []).length, min: min, max: max, exact: min == max } end ``` ```bash cURL theme={"dark"} # Cost estimation is client-side arithmetic on the rate card, not an API call. # Tiers (credits per successful request): base=1, js_render=5, premium_proxy=10, both=25, mode=auto=1..25. # Sum the tier for each task. Example with jq over a submit body in body.json: jq '[.tasks[] | (.zenrows_params // {}) as $p | (if ($p.mode? == "auto") then 25 elif ($p.js_render? == "true" and $p.premium_proxy? == "true") then 25 elif ($p.premium_proxy? == "true") then 10 elif ($p.js_render? == "true") then 5 else 1 end)] | add' body.json ``` ### 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:** the `spend` object on each result row, where `spend.total.credits` is the cost across all attempts and `spend.last_attempt.credits` is the cost of the most recent attempt. Read these while paging results, as shown in [Download content](#download-content). Only successful (status 200, 404 and 410) requests cost credits, so a run with failures costs less than its estimate. Treat this figure as an indicative signal. Your Zenrows account statement remains the authoritative record for billing. ```python Python theme={"dark"} ## pip install requests import requests job = requests.get('https://async.api.zenrows.com/v1/jobs/JOB_ID', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}).json() spend = job['latest_run']['stats'].get('spend') if spend: print('credits:', spend['credits'], 'cost:', spend['cost']) ``` ```javascript Node.js theme={"dark"} const job = await api("GET", "/jobs/JOB_ID"); const spend = job.latest_run.stats.spend; // { credits, cost }, absent until there's spend if (spend) console.log("credits:", spend.credits, "cost:", spend.cost); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class ActualCost { public static void main(final String... args) throws Exception { String response = Request.get("https://async.api.zenrows.com/v1/jobs/JOB_ID") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString(); System.out.println(response); // read latest_run.stats.spend from this JSON } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { req, err := http.NewRequest("GET", "https://async.api.zenrows.com/v1/jobs/JOB_ID", nil) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) // read latest_run.stats.spend from this JSON } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' conn = Faraday.new job = JSON.parse(conn.get('https://async.api.zenrows.com/v1/jobs/JOB_ID') { |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' }.body) spend = job.dig('latest_run', 'stats', 'spend') puts "credits: #{spend['credits']} cost: #{spend['cost']}" if spend ``` ```bash cURL theme={"dark"} # Read latest_run.stats.spend from the job response: curl "https://async.api.zenrows.com/v1/jobs/JOB_ID" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" ``` ## 7. Reference Behaviors and error details that apply across the API. ### Idempotency Send an `Idempotency-Key` header on a submit to guard against accidentally creating duplicate jobs. Each key can be used once: the first request with a given key creates the job, and any later request that reuses the same key returns `409 idempotency_key_conflict`, whether or not the body is identical. The API does not store or replay the original response. ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json', 'Idempotency-Key': 'nightly-2026-06-08'} payload = {'type': 'regular', 'status': 'closed', 'tasks': [{'url': 'https://www.scrapingcourse.com/ecommerce/'}]} response = requests.post('https://async.api.zenrows.com/v1/jobs', headers=headers, data=json.dumps(payload)) print(response.status_code, response.json()) ``` ```javascript Node.js theme={"dark"} const job = await api("POST", "/jobs", { headers: { "Idempotency-Key": "nightly-2026-06-08" }, body: { type: "regular", status: "closed", tasks: [{ url: "https://www.scrapingcourse.com/ecommerce/" }] }, }); console.log(job.job_id); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; public class Idempotent { public static void main(final String... args) throws Exception { String body = "{\"type\":\"regular\",\"status\":\"closed\",\"tasks\":[{\"url\":\"https://www.scrapingcourse.com/ecommerce/\"}]}"; String response = Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .addHeader("Idempotency-Key", "nightly-2026-06-08") .bodyString(body, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} 'regular', 'status' => 'closed', 'tasks' => [['url' => 'https://www.scrapingcourse.com/ecommerce/']], ])); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'X-API-Key: YOUR_ZENROWS_API_KEY', 'Idempotency-Key: nightly-2026-06-08', 'Content-Type: application/json', ]); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); echo curl_exec($ch) . PHP_EOL; curl_close($ch); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "io" "log" "net/http" ) func main() { payload := []byte(`{"type":"regular","status":"closed","tasks":[{"url":"https://www.scrapingcourse.com/ecommerce/"}]}`) req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs", bytes.NewBuffer(payload)) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") req.Header.Set("Idempotency-Key", "nightly-2026-06-08") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(resp.StatusCode, string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' conn = Faraday.new res = conn.post('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.headers['Idempotency-Key'] = 'nightly-2026-06-08' req.headers['Content-Type'] = 'application/json' req.body = { type: 'regular', status: 'closed', tasks: [{ url: 'https://www.scrapingcourse.com/ecommerce/' }] }.to_json end print(res.status) print(res.body) ``` ```bash cURL theme={"dark"} curl -X POST "https://async.api.zenrows.com/v1/jobs" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" \ -H "Idempotency-Key: nightly-2026-06-08" \ -H "Content-Type: application/json" \ -d '{ "type": "regular", "status": "closed", "tasks": [{ "url": "https://www.scrapingcourse.com/ecommerce/" }] }' ``` An idempotency key is not a safe-retry token, because reusing it always returns `409`. If a submit fails midway, such as a dropped connection or a `503`, do not resend with the same key. First 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 7807 problem JSON with a stable `code` you can branch on. Task-level failures are not HTTP errors; they appear as `failed` rows in the results, each with its own `error` object. A successful `POST /jobs` returns `201 Created` (synchronous) or `202 Accepted` (asynchronous, for 10,000+ tasks or a CSV input); both carry the same response body. | HTTP | `code` | Meaning | | - | - | - | | 400 | `invalid_argument` | Validation failed; `invalid_tasks[]` lists `{index, reason}`. | | 401 | `unauthenticated` | Missing or invalid API key. | | 402 | `payment_required` / `api_key_cap_reached` | `payment_required`: no credits available. `api_key_cap_reached`: the API key reached one of its [credit caps](#api-key-credit-caps-and-batch); `detail` names the cap and when it resets. | | 404 | `not_found` | Job, run, or task missing or not owned by you. | | 409 | `conflict` / `run_not_terminal` / `idempotency_key_conflict` | State conflict; see `detail`. | | 422 | (on `/content`) | Task failed; the body is the stored error. | | 429 | `quota_exceeded` | Account limit reached, such as the maximum of 3 concurrent active jobs. | | 503 | `internal` | Transient; safe to retry with a backoff. | On a non-2xx response, read the `code` and, for a `400`, the `invalid_tasks` array from the problem body. ```python Python theme={"dark"} ## pip install requests import requests r = requests.post('https://async.api.zenrows.com/v1/jobs', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json'}, json={'type': 'regular', 'status': 'closed', 'tasks': [{'url': 'not-a-url'}]}) if not r.ok: problem = r.json() # RFC 7807: { type, title, status, detail, code, invalid_tasks? } print(r.status_code, problem.get('code'), problem.get('invalid_tasks')) ``` ```javascript Node.js theme={"dark"} // The api() helper throws an object carrying the structured problem details. try { await api("POST", "/jobs", { body: { type: "regular", status: "closed", tasks: [{ url: "not-a-url" }] } }); } catch (e) { console.error(e.status, e.code, e.problem?.invalid_tasks); } ``` ```java Java theme={"dark"} import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ClassicHttpResponse; import org.apache.hc.core5.http.ContentType; import org.apache.hc.core5.http.io.entity.EntityUtils; public class ErrorHandling { static final ObjectMapper M = new ObjectMapper(); public static void main(final String... args) throws Exception { String body = "{\"type\":\"regular\",\"status\":\"closed\",\"tasks\":[{\"url\":\"not-a-url\"}]}"; ClassicHttpResponse resp = (ClassicHttpResponse) Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .bodyString(body, ContentType.APPLICATION_JSON) .execute().returnResponse(); if (resp.getCode() >= 400) { JsonNode problem = M.readTree(EntityUtils.toString(resp.getEntity())); // RFC 7807 System.out.println(resp.getCode() + " " + problem.path("code").asText() + " " + problem.path("invalid_tasks")); } } } ``` ```php PHP theme={"dark"} 'regular', 'status' => 'closed', 'tasks' => [['url' => 'not-a-url']]])); curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: YOUR_ZENROWS_API_KEY', 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $res = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($status >= 400) { $problem = json_decode($res, true); // RFC 7807 echo $status, ' ', $problem['code'] ?? '', PHP_EOL; print_r($problem['invalid_tasks'] ?? []); } ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "encoding/json" "io" "log" "net/http" ) func main() { payload := []byte(`{"type":"regular","status":"closed","tasks":[{"url":"not-a-url"}]}`) req, _ := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs", bytes.NewBuffer(payload)) req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 400 { var problem struct { Code string `json:"code"` InvalidTasks []any `json:"invalid_tasks"` } json.Unmarshal(body, &problem) // RFC 7807 log.Println(resp.StatusCode, problem.Code, problem.InvalidTasks) } } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' res = Faraday.post('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.headers['Content-Type'] = 'application/json' req.body = { type: 'regular', status: 'closed', tasks: [{ url: 'not-a-url' }] }.to_json end unless res.success? problem = JSON.parse(res.body) # RFC 7807 puts "#{res.status} #{problem['code']}" p problem['invalid_tasks'] end ``` ```bash cURL theme={"dark"} # -i shows the status line; the body is the RFC 7807 problem with code and invalid_tasks. curl -i -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", "tasks": [{ "url": "not-a-url" }] }' ``` ## Troubleshooting Common issues you may hit while integrating. See the full [Troubleshooting](/batch/troubleshooting) page for the complete list. * **`401 unauthenticated`:** send your key in the `X-API-Key` header (never in the URL); a `401` means the key is missing or invalid. * **`400 invalid_argument`:** the body failed validation. Read the `invalid_tasks` array to see which URL failed and why. * **`429 quota_exceeded`:** you have 3 jobs running already. Wait for one to finish, since 3 concurrent jobs is the limit. * **`503` when opening a job:** if `POST /jobs` with `status: "open"` returns a `503`, retry after a moment or contact Zenrows support. * **Download link returns an error:** each `result_url` is valid for 2 hours. Re-list the results to get a fresh link rather than reusing a stored one. * **Webhook never arrives:** use an HTTPS receiver, test it with `POST /webhook/test`, and deduplicate on the `X-ZenRows-Event-Id` header, since deliveries are at-least-once. ## FAQ (Frequently Asked Questions) Quick answers for developers. See the full [FAQ](/batch/faq) for more. All requests go to `https://async.api.zenrows.com/v1`, authenticated with the `X-API-Key` header. Batch uses the same API key as Fetch. Any language with an HTTP client. The examples in this guide cover Python, Node.js, Java, PHP, Go, Ruby, and cURL, and the same REST calls work anywhere. Up to 100,000 URLs per job submit, 10,000 per add-tasks call on an open job, and 3 active jobs concurrently. CSV uploads support 50 MB / 100,000 rows. No. Each key can be used once, and reusing it always returns `409 idempotency_key_conflict`. If a submit fails midway, check whether the job was created before resending with a new key. In credits, per successful request, at the same rates as Fetch. Failed requests are never charged. # FAQ (Frequently Asked Questions) Source: https://docs.zenrows.com/batch/faq Frequently asked questions about the Zenrows Batch: access, pricing, limits, scraping options, results, scheduling, and how it relates to Fetch. Quick answers to the questions teams ask most when they start with Batch. For step-by-step code, see the [developer guide](/batch/developer-guide-restapi). For error-specific help, see [Troubleshooting](/batch/troubleshooting). ## Getting Started Yes. Batch is available to every Zenrows account. You can start submitting jobs right away with your existing API key, with no extra setup. No. Batch uses the **same API key as your Fetch**. There's nothing extra to generate. All REST requests go to `https://async.api.zenrows.com/v1`. Send your key in the `X-API-Key` header on every request. Batch is a REST API, so you can call it from any language with an HTTP client. The [developer guide](/batch/developer-guide-restapi) shows a dependency-free pattern using Node's `fetch` that translates directly to Python, Go, PHP, Java, and more. ## How It Works A **job** is the template: your URLs and configuration. A **run** is one execution of that job, where each rerun or scheduled fire creates a new run. A **task** is a single URL within a run, with its own status and result. A **rerun** replays every task in the job as a fresh run. **Retry failed** re-executes only the tasks that failed (and optionally those that never started), carrying over the successful results so you only pay to re-scrape what failed. Both create a new run on the same job. Two ways: attach a **webhook** and the API sends a `run.completed` event when the run completes (or `run.failed` if it fails), or **poll** the job until `latest_run.status` is terminal (`completed`, `failed`, `stopped`, or `deleted`) and read the `stats` counters (`completed === successful + failed`). Webhooks are a convenience; the run state remains the source of truth. Yes. `POST /jobs/{id}/pause` reversibly suspends the current run and `POST /jobs/{id}/resume` continues it, while `POST /jobs/{id}/stop` (alias `POST /jobs/{id}/cancel`) ends it for good. These act on the job's current run, which is different from pausing a scheduled job's recurring schedule. See [Control the current run](/batch/developer-guide-restapi#control-the-current-run). No. `POST /jobs` returns `201 Created` for submissions under 10,000 tasks and `202 Accepted` for larger ones (10,000+ tasks or a CSV input). The response body is identical and you track completion the same way, so treat both the same. For a large `202`, `GET /jobs/{id}/results` may return partial pages for a few seconds while the task list finishes writing, but `stats.total` is correct from the start. Yes. Open a job, add tasks in batches over time (up to 10,000 per add-tasks call), then close it with `last_batch: true`. See [open jobs](/batch/developer-guide-restapi#open-jobs-adding-tasks-in-batches). An account can have **3 active jobs concurrently**. Submitting a 4th returns `429 quota_exceeded`. Wait for a job to finish before submitting the next, or contact Zenrows if you need a higher limit. ## Scraping Options The same `zenrows_params` as Fetch, including `js_render`, `premium_proxy`, `proxy_country`, `wait_for`, `wait`, `css_extractor`, `extract`, `json_response`, `custom_headers`, and more. Set them once on the job, and override any of them per URL. Yes. Options set on the job apply to every URL; options set on an individual URL override the job-level ones for that URL. This lets you configure the common case once and tune the exceptions (for example, a different `proxy_country` for a handful of URLs). Yes. A job is just a list of URLs plus the options to apply to them, so you can mix targets in a single job and use per-URL overrides where a specific site needs different handling. Raw HTML, parsed **JSON**, **Markdown**, **plain text**, and **PDF**. The API derives the output format from your options (for example `response_type: markdown`) and records it on each result. ## Results and Limits Each result's `result_url` is a presigned link valid for **2 hours**. If it expires, re-list the results to get a fresh one rather than storing it. You can also stream a task's content directly, or export an entire run as a ZIP. Results and their download links are retained for **14 days** after a run finishes, then removed automatically. You don't need to delete anything to stay within storage limits. If you want to remove results sooner, you can delete a single run's results or an entire job yourself. See [Delete a job or a run](/batch/developer-guide-restapi#delete-a-job-or-a-run). Yes. Use `DELETE /jobs/{id}/runs/{run_id}` to delete one run's stored results while keeping the job, or `DELETE /jobs/{id}` to delete the whole job (all runs and results). Stopping a run only ends it and keeps the results, so use delete when you want the data removed. See [Delete a job or a run](/batch/developer-guide-restapi#delete-a-job-or-a-run). Up to **100,000 URLs per job submit**. On an open job, each add-tasks call accepts up to **10,000 URLs**, and CSV uploads support up to **50 MB / 100,000 rows**. Results page at \~1,000 per page. Attach an `external_id` to each URL. The API returns it unchanged on every result, so you can correlate results to your records without tracking the API's task IDs. It doesn't need to be unique. Yes. Start a bulk export to bundle a run's result bodies into a single ZIP. Exports expire 12 hours after creation, and a run whose combined results exceed 100 GiB can't be exported as one archive. Page through the results instead. ## Scheduling Yes. Schedule a job as **one-shot** (a single future time), **interval** (every N minutes, hours, or days, minimum 60 minutes between runs), or **calendar** (specific times on specific days). Schedules are timezone-aware using IANA names. You can pause and resume any time without losing the schedule. Times of day must be **full hours**: use `"09:00"`, not `"09:30"`. Also provide exactly one schedule type (`at`, `rate`, or `calendar`) and a valid IANA timezone for `at` and `calendar`. ## Pricing Pricing is **identical to Fetch**: you're billed in **credits, per successful request**. The credit cost depends on the scraping tier each URL uses. | Configuration | Credits per successful request | | - | - | | Base (no special options) | 1 | | JavaScript rendering | 5 | | Premium proxy | 10 | | JavaScript rendering + premium proxy | 25 | | Adaptive Stealth Mode (`mode=auto`) | Dynamic, 1–25 | No. **Only successful requests cost credits.** A run with failures costs less than its up-front estimate. Yes. Because the rate card is small and stable, you can estimate credits client-side with no API call, then show your users an accurate range up front. After a run finishes, you can also read the actual credits it consumed. See [Estimate cost](/batch/developer-guide-restapi#estimate-cost) and [Check actual cost](/batch/developer-guide-restapi#check-actual-cost). Use **Adaptive Stealth Mode** (`mode=auto`). It starts each request with the cheapest viable setup and escalates to JavaScript rendering or premium proxies only when a site actually needs them, so you pay for the lightest configuration that succeeds. ## Reliability and Security The API retries transient failures automatically, so a page that's briefly unavailable still has a strong chance of being fetched. Requests are also paced to stay polite to the sites you scrape. An **idempotency key** guards against accidentally creating duplicate jobs. Each key can be used once: the first submit with a given key creates the job, and any later submit reusing that key returns `409 idempotency_key_conflict`, whether or not the body is identical. The API does not replay the original response. Because of this, an idempotency key is **not** a safe-retry token. If a submit fails midway, don't resend with the same key. First check whether the job was created (list your recent jobs), then reuse its `job_id` or submit again with a new key. Set `signature: true` when you attach the webhook. The API then HMAC-signs each delivery, and your receiver verifies the `X-Signature` header against the raw request body using your organization's active key. See [Manage HMAC keys](/batch/developer-guide-restapi#manage-hmac-keys). ## Didn't Find Your Answer? Check the [Troubleshooting](/batch/troubleshooting) page for error-specific guidance, or reach out to support with your `job_id`, the request body (API key redacted), and the exact error `code` and `detail`. # Introduction to Batch Source: https://docs.zenrows.com/batch/introduction Scrape large lists of URLs reliably with Zenrows Batch. Submit a job, track its progress, and collect the results without building your own orchestration. Batch is available to every Zenrows account. You just need an active Zenrows API key; your existing Fetch key works without any extra setup. Zenrows Batch is a managed service that scrapes large lists of URLs for you. You hand it the URLs and the scraping configuration you want, and the API runs the work reliably: it queues each request, retries transient failures, stores every result, and notifies you when the batch finishes. Whether you scrape a few hundred pages or a few hundred thousand, Batch gives you one consistent way to submit work, track progress, and collect results, without operating the orchestration yourself. Confirm access, find your API key, and submit your first job in minutes. Every feature against the REST API, with runnable examples in any language. ## Use It from Any Language Every Batch feature is available over a plain HTTPS REST API, so you can integrate from any language with nothing more than an HTTP client. The [developer guide](/batch/developer-guide-restapi) is a complete, dependency-free `fetch` walkthrough whose patterns apply equally to Python, Go, PHP, Java, or anything else. ## Why Use It Scraping a single page is easy. Scraping tens of thousands of pages reliably is not: you have to spread the load so you don't overwhelm target sites, retry the requests that fail for transient reasons, store and serve results durably, and know when everything finished and what didn't work. Batch is the managed layer for exactly this. You integrate once, submit jobs, and it handles the operational concerns for you: * **Scale without orchestration.** Submit up to 100,000 URLs in a single job and let the API pace the work. * **Reliable by default.** The API retries transient failures automatically, so a page that's briefly unavailable still gets fetched. * **Results you can trust.** The API stores every successful page and serves it back through time-limited download links. * **Know when it's done.** Receive a webhook on completion, or poll for status, whichever fits your pipeline. ## How It Works 1. **Submit a job:** a list of URLs plus the scraping options to apply to them. 2. **The API queues and processes each URL,** calling the Zenrows scraping engine and retrying transient failures automatically. 3. **The API stores results as they complete,** so you can watch progress at any time. 4. **Collect the results when the batch finishes.** Fetch them directly, or receive a webhook. ## Core Concepts Three terms map cleanly onto how teams work: | Concept | What it is | | - | - | | **Job** | The template: your reusable submission and its configuration (URLs, scraping options, schedule, webhook). | | **Run** | One execution of a job. A job has one or more runs over its lifetime, since each rerun or scheduled fire creates a new run. | | **Task** | One URL within a run, with its own status and result. | A job carries a snapshot of its latest run, so a single request tells you where things stand without extra lookups. ## Key Features ### Flexible submission Submit up to **100,000 URLs in a single job**. An **open job** accepts URLs in batches over time (up to **10,000 per add-tasks call**) before you close it. For very large lists, **upload a CSV** (up to 50 MB / 100,000 rows) and reference it instead of inlining URLs. Use an optional **idempotency key** to guard against accidental duplicate submissions: each key can be used once, and reusing it returns a conflict rather than creating a second job. ### Full control over scraping Configure each job, and override per URL, with the scraping options you need: * **JavaScript rendering** for dynamic pages. * **Premium proxies** and **geo-targeting** by country. * **Output formats:** raw HTML, parsed **JSON**, **Markdown**, **plain text**, or **PDF**. * **Structured extraction** with CSS selectors or automatic parsing, plus custom headers, waits, and more. Options set on the job apply to every URL; options set on an individual URL override them. Configure the common case once, and tune the exceptions per URL. ### Results, your way * Each successful task yields a **presigned download URL** (a temporary, signed link) valid for 2 hours, or you can stream the content directly. * **Page through results** efficiently and filter by status: successful, failed, or in-progress. * Correlate every result back to your own records with a caller-supplied `external_id`. * **Archive export:** download an entire run's results as a single ZIP archive in one request. * Each task ends as `successful` or `failed`, and a failed task carries a **structured error** with a `code` and `detail`. These codes don't yet reliably distinguish blocked, not-found, and timeout, so treat them as a hint rather than a definitive reason. ### Completion notifications * **Webhooks:** the API sends an HTTPS callback when a run completes, optionally **HMAC-signed** so your receiver can verify it genuinely came from Zenrows. * **Polling:** status and progress counters are always available on demand. ### Reruns and run control * **Full rerun:** replay an entire job as a fresh run. * **Retry failed only:** re-execute just the tasks that failed (and optionally those that never started). Successful results carry over, so you only pay to re-scrape what failed. * **Pause, resume, or stop a run:** reversibly suspend an expensive batch and resume it later, or stop it entirely, without waiting for it to finish. ### Scheduled jobs Run recurring work on a **one-shot**, **interval** (every N minutes, hours, or days, minimum 60 minutes between runs), or **calendar** (specific times on specific days) schedule. Schedules are **timezone-aware** using IANA names, so daylight-saving transitions stay correct. Pause and resume any time without losing the schedule. ### Cost transparency Pricing is **per successful request**, and the rate card is small and stable, so you can **estimate the cost before you submit** with no extra round-trip and show your users an accurate credit range up front. Once a job finishes, you can also see the **total credits it actually consumed**, which makes it easy to reconcile the estimate against realized spend. ## Reliability * **Pages still come back.** The API retries transient failures for you, so a page that's temporarily unavailable still has a strong chance of being fetched. * **Easy on your targets.** The API paces requests so a large job stays polite to the sites you scrape. * **Durable results.** The API stores every successful page and serves it back through secure, time-limited download links. * **Built for big batches.** Submit tens of thousands of URLs and collect the results without managing any of the moving parts. ## Security * **API-key authentication** on every request. Keys travel in a header, never in the URL. * **HTTPS everywhere,** including a requirement that webhook receivers use HTTPS. * **Optional HMAC-signed webhooks** with per-organization keys and phased key rotation, so you can verify that a callback genuinely came from Zenrows. ## Limits | Limit | Value | | - | - | | URLs per job submit | 100,000 | | URLs per add-tasks call (open jobs) | 10,000 | | Concurrent active jobs per account | 3 | | CSV upload | 50 MB / 100,000 rows | | Results page size | \~1,000 per page | | Presigned result URL lifetime | 2 hours | | Result retention | 14 days | | Bulk export lifetime | 12 hours | Submitting a job while 3 are already active returns `429 quota_exceeded`. Wait for a job to finish, or contact Zenrows if you need a higher concurrency limit. ## Pricing Pricing is **identical to the Zenrows Fetch**. You're billed in **credits, per successful request**, and the API never charges for failed requests. The credit cost depends on the scraping tier each URL uses: | Configuration | Credits per successful request | | - | - | | Base (no special options) | 1 | | JavaScript rendering | 5 | | Premium proxy | 10 | | JavaScript rendering + premium proxy | 25 | | Adaptive Stealth Mode (`mode=auto`) | Dynamic, 1–25 | **Optimize cost with Adaptive Stealth Mode.** Batch supports Adaptive Stealth Mode (`mode=auto`): it starts each request with the cheapest viable setup and escalates to JavaScript rendering or premium proxies only when a site actually needs them. You pay for the lightest configuration that succeeds rather than over-provisioning every URL. Because the rate card is small and stable, you can present an accurate cost range to your own users before a job is submitted. See the Estimating Cost section in the developer guide. ## Next Steps Get access, authenticate, and submit your first job. Every feature against the REST API, applicable to any language. Submit, wait, and download in a few lines with the official Python SDK. Submit, wait, and download in a few lines with the official Node.js SDK. Diagnose common issues fast. Quick answers on access, pricing, and limits. # Node.js SDK Source: https://docs.zenrows.com/batch/sdk/nodejs Submit batch scraping jobs, track them, and collect results with the official Zenrows Node.js SDK. Covers every Batch feature with runnable examples. Batch is 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`. 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`). Prefer raw HTTP, or work in another language? The [REST API guide](/batch/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). ## Install and Authenticate While the batch client is being published to npm, install the SDK from GitHub: ```bash theme={"dark"} npm install "github:ZenRows/zenrows-node-sdk#feature/batch-api/rc" ``` This GitHub install is temporary. Once the batch client is published to npm, `npm install zenrows` will include it. ```typescript theme={"dark"} 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={"dark"} 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); } ``` 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. ## 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={"dark"} 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`, `extract`, `outputs`, `original_status`, `allowed_status_codes`, and `session_id`. The `autoparse` parameter is still accepted but [deprecated](/fetch/features/autoparse). Zenrows derives the output format from these and records it on each result. `custom_headers` takes a map of header name to value. This SDK's params are typed as scalars, so pass the map as a JSON string, for example `{ custom_headers: '{"Referer":"https://www.google.com/"}' }`. It is also how you set the `Content-Type` for a [POST target fetch](/batch/developer-guide-restapi#post-target-fetches). Up to 32 headers, 8 KB serialised. ## 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={"dark"} 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" }, }); ``` 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. ## 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={"dark"} // 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); } ``` `downloadAllResults` is a server-side zip capped at **100 GiB** per run. For larger runs, or one file per task, use `downloadToDir`, which fetches bodies client-side with no size limit. ## 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={"dark"} 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={"dark"} 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={"dark"} 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={"dark"} // 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={"dark"} 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={"dark"} 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 completes, or a `run.failed` event when it fails. 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={"dark"} 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. No webhook receiver? Just poll for completion with `job.run.wait()` or `run.wait()`. ## 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={"dark"} await client.job(jobId).run.downloadAllResults("results.zip"); ``` The server-side zip is capped at **100 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. ## Estimating Cost Estimate credits **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={"dark"} 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 credits it consumed** from `run.data.stats.spend` (`{ credits, cost }`). ```typescript theme={"dark"} const run = await client.job(jobId).run.wait(); const spend = run.data.stats.spend; if (spend) console.log(`this run used ${spend.credits} credits (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={"dark"} 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=,v1=,kid=` header by recomputing `HMAC-SHA256(secret, ".")` 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={"dark"} 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={"dark"} 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={"dark"} 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). # Python SDK Source: https://docs.zenrows.com/batch/sdk/python Submit batch scraping jobs, track them, and collect results with the official Zenrows Python SDK. Covers every Batch feature with runnable examples. Batch is available to every Zenrows account. You need an active Zenrows API key. The `ZenRowsBatchClient` shown here is separate from the SDK's synchronous scraping client, `ZenRowsClient`. Use this guide to submit batch scraping jobs, track them, and collect results with the official Zenrows Python SDK. It walks through every Batch feature, and each section runs on its own. Prefer raw HTTP, or work in another language? The [REST API guide](/batch/developer-guide-restapi) covers the same use cases against the plain REST API. For the complete method reference, see the [SDK repository on GitHub](https://github.com/ZenRows/zenrows-python-sdk). ## Install and Authenticate While the batch client is being published to PyPI, install the SDK from GitHub: ```bash theme={"dark"} pip install "git+https://github.com/ZenRows/zenrows-python-sdk.git@main" ``` This GitHub install is temporary. Once the batch client is published to PyPI, `pip install zenrows` will include it. ```python Python theme={"dark"} import os from zenrows import ZenRowsBatchClient client = ZenRowsBatchClient(api_key=os.environ["ZENROWS_API_KEY"]) ``` The client also works as a context manager (`with ZenRowsBatchClient(...) as client: ...`) when you want connections to close automatically. By default it targets the production Batch; pass `base_url=...` (or set `ZENROWS_BATCH_BASE_URL`) to point at a different environment. ## Quickstart: Submit, Wait, Get Results This is the standard end-to-end flow. `submit_regular` returns a `JobRef` (no GET yet), `job.run.wait()` blocks until the current run reaches a **terminal** state and returns a `RunHandle`, and `run.results(...)` iterates the per-task results. ```python Python theme={"dark"} job = client.submit_regular( [ "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie", "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket", "https://www.scrapingcourse.com/ecommerce/product/aero-daily-fitness-tee", ], zenrows_params={"js_render": "true", "premium_proxy": "true"}, ) run = job.run.wait(timeout=600.0) # blocks until the current run is terminal s = run.stats print(f"{s.successful}/{s.total} successful, {s.failed} failed") for task in run.results(status="successful"): print(task.external_id or task.task_id, "->", task.result_url) ``` `job.run.wait()` returns a `RunHandle` whose `.data` snapshot is ready, so `run.results(...)` and `run.download_to_dir(...)` work without re-passing IDs. Pass `progress=True` to the waiter for a live progress bar on long batches. For very large submissions that return `202 Accepted`, pass `wait_for_ingest=True` to `submit_regular` so results pages are complete before you read them. ## Configuring the Scrape `zenrows_params` sets scraping options for every URL in the job. Values can be strings, booleans, or integers; the SDK converts them to strings before sending. ```python Python theme={"dark"} job = client.submit_regular( ["https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie"], zenrows_params={ "js_render": "true", # render JavaScript in a headless browser "premium_proxy": "true", # route through premium residential proxies "proxy_country": "us", # geo-target the request "wait_for": ".price", # wait for this CSS selector before capturing "response_type": "markdown", # html | markdown | plaintext | pdf }, ) ``` Zenrows derives the job's **output format** from these options: `response_type: markdown` produces Markdown; `extract`, `json_response`, or a CSS extractor produce JSON; otherwise the result is HTML. Zenrows records this format on each result. Other common keys include `js_instructions`, `wait`, `block_resources`, `json_response`, `css_extractor`, `extract`, `outputs`, `original_status`, `allowed_status_codes`, and `session_id`. The `autoparse` parameter is still accepted but [deprecated](/fetch/features/autoparse). `custom_headers` accepts a dict of header name to value, for example `{"custom_headers": {"Referer": "https://www.google.com/"}}`, and is also how you set the `Content-Type` for a [POST target fetch](/batch/developer-guide-restapi#post-target-fetches). Up to 32 headers, 8 KB serialised. ## Per-URL Options and Correlating Results To set a per-URL `external_id`, `metadata`, or `zenrows_params`, pass dictionaries instead of plain strings. Per-URL `zenrows_params` override the job-level ones when a key appears in both. ```python Python theme={"dark"} job = client.submit_regular( [ {"url": "https://www.scrapingcourse.com/ecommerce/product/artemis-running-short", "external_id": "order-42", "zenrows_params": {"proxy_country": "de"}}, {"url": "https://www.scrapingcourse.com/ecommerce/product/atomic-endurance-tee", "external_id": "order-43", "metadata": {"sku": "ABC-1"}}, ], zenrows_params={"js_render": "true"}, # applies to both unless overridden ) ``` Zenrows 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 must match `^[A-Za-z0-9._-]+$` and be 128 characters or fewer. ## Downloading Content `results()` gives you metadata plus a presigned `result_url` per task; the SDK downloads bodies straight from that URL with no extra API round-trip. Four options, on the current run (`job.run.*`) or any specific run (`client.get_run(job_id, run_id=...)`): | You want | Use | Where it lands | | - | - | - | | Every body, one file per task | `run.download_to_dir(dir)` | files on disk | | Every body, kept in memory | `run.download_to_memory()` | `list[DownloadedResult]` | | The whole run as one artifact | `run.download_all_results("out.zip")` | one `.zip` | | One task's body (inside a loop) | `run.download_task_to_file(task, path)` / `run.download_task_to_memory(task)` | file / `bytes` | ```python Python theme={"dark"} # Bulk to disk, named by external_id, 8 downloads in parallel: n = run.download_to_dir( "./out", status="successful", # default; pass status=None for everything use_external_id=True, # name files . instead of . concurrency=8, progress=True, max_files=20_000, # safety caps that raise DownloadLimitExceeded max_bytes_per_file=10 * 1024 * 1024, ) print(f"downloaded {n} files") # One task at a time, when you want a Python-side filter: for task in run.results(status="successful"): body = run.download_task_to_memory(task) # bytes; or download_task_to_file(task, path) ``` Each `result_url` is a plain presigned URL (no auth header) valid for a short window, so fetch it promptly or re-list the results to get a fresh one. ## Open Jobs: Streaming URLs in Batches When you don't have every URL up front, open a job, add tasks in batches, and close it. The run stays active and completes once it finishes the final batch. ```python Python theme={"dark"} job = client.submit_open(zenrows_params={"js_render": "true"}) job.add_tasks({"tasks": [{"url": "https://www.scrapingcourse.com/ecommerce/page/1"}]}) job.add_tasks({"tasks": [{"url": "https://www.scrapingcourse.com/ecommerce/page/2"}], "last_batch": True}) # closes the job run = job.run.wait(timeout=900.0) ``` Setting `last_batch=True` (or calling `job.close()`) locks the job, and the API rejects further adds. File-input (CSV) upload 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. `upload_csv` allocates the slot, PUTs your file to the presigned URL, and returns a `file_input_id` that `submit_regular(file_input_id=...)` consumes. ```python Python theme={"dark"} file_input_id = client.upload_csv( "urls.csv", fields={"url": "URL", "external_id": "Customer Ref"}, # column name (header=True) or 0-based index header=True, ) job = client.submit_regular(file_input_id=file_input_id, zenrows_params={"js_render": "true"}) ``` Each CSV row becomes a task. Zenrows reads only the `url` and optional `external_id` columns, and applies the job-level `zenrows_params` to all of them. Limits: 50 MB / 100,000 rows. ## Listing and Pagination The SDK offers both single-page calls (`list_jobs`, `list_runs`, `list_results`) and lazy iterators that page for you. The iterators yield loaded handles. ```python Python theme={"dark"} for job in client.iter_jobs(job_type="regular", status="closed"): print(job.job_id, job.data.status) for run in client.iter_runs("01J...", page_size=50): print(run.run_id, run.data.status) for task in client.iter_results("01J...", status="failed"): print(task.task_id, task.error.code if task.error else None) ``` ## Reruns and Retrying Failures Each rerun creates a brand-new run on the same job. ```python Python theme={"dark"} # Re-run every task: new_run = job.rerun() # Re-run only the failures (optionally also tasks that never started). # Successful results carry over, so you don't pay to scrape them again: new_run = job.retry_failed(include_pending=False) new_run.wait(timeout=600.0) ``` `retry_failed()` is the usual post-run cleanup, a thin shortcut for `rerun(status="failed")`. You can act on an id you kept without a preceding GET: `client.job(job_id).retry_failed()`. ## Scheduled Jobs Build a schedule with the typed builders and submit a scheduled job. Zenrows stores its URLs as a template, and each fire produces a fresh run. ```python Python theme={"dark"} from datetime import datetime from zenrows.batch import At, Calendar, Rate, Weekly # Every 6 hours: client.submit_scheduled(Rate(every=6, unit="hour"), ["https://www.scrapingcourse.com/ecommerce/"]) # 09:00 and 18:00 on Mon/Wed/Fri, Berlin time: client.submit_scheduled( Calendar(times_of_day=["09:00", "18:00"], cadence=Weekly(days=["mon", "wed", "fri"]), timezone="Europe/Berlin"), ["https://www.scrapingcourse.com/ecommerce/"], ) # One-shot at a wall-clock time. A naive datetime plus an IANA zone # keeps daylight-saving transitions correct: client.submit_scheduled(At(datetime(2026, 9, 1, 9, 0), timezone="Europe/Berlin"), ["https://www.scrapingcourse.com/ecommerce/"]) ``` A job carries two facets, matching 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: ```python Python theme={"dark"} job.schedule.pause() # stop firing new runs job.schedule.resume() job.schedule.update(Rate(every=1, unit="day")) # replace the schedule job.run.pause() # reversibly suspend the current run (a different endpoint) job.run.resume() job.run.stop() # end the current run ``` ## Completion Notifications: Webhooks Attach a webhook at submit time, and Zenrows sends a `run.completed` event to your HTTPS URL when a run completes, or a `run.failed` event when it fails. Set `signature: True` to receive HMAC-signed deliveries (see [Signed Webhooks](#signed-webhooks-hmac-keys)); it defaults to unsigned. You can also manage the webhook later with `client.put_job_webhook(...)` / `client.delete_job_webhook(...)`, and probe a receiver with `client.test_webhook(...)`. ```python Python theme={"dark"} job = client.submit_regular( ["https://www.scrapingcourse.com/ecommerce/"], webhook={"url": "https://your-app.com/webhooks/zenrows", "signature": True}, ) ``` Zenrows delivers each event **at least once**, so your receiver must deduplicate on the `X-ZenRows-Event-Id` header. No webhook receiver? Just poll for completion with `job.run.wait()` or `run.wait()`. ## Bulk Export (ZIP) Bundle an entire run's result bodies into a single server-side zip: `download_all_results` starts the export, waits for it, and streams the file to disk. ```python Python theme={"dark"} run.download_all_results("results.zip") ``` The server-side zip is capped at **100 GiB** per run. For larger runs, or one file per task, use `download_to_dir`, which fetches bodies client-side with no size limit (slower; tune with `concurrency`). For manual control, `client.start_results_export(...)` and `client.wait_for_export(...)` expose the two steps separately. ## Estimating Cost Estimate credits **before** submitting with `client.estimate_cost`. It takes the same body you would submit 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`. ```python Python theme={"dark"} est = client.estimate_cost({ "type": "regular", "tasks": [{"url": f"https://www.scrapingcourse.com/ecommerce/page/{i}"} for i in range(1000)], "zenrows_params": {"js_render": "true"}, }) print(est) # "5000 credits (1000 tasks)" print(est.min, est.max, est.exact) print(est.format()) # per-tier breakdown table ``` An estimate assumes each URL succeeds once. `min == max` (`est.exact`) unless the job uses `mode=auto`, which yields a per-task range. ## Checking Actual Cost Once a run finishes, read the **actual credits it consumed** from `run.stats.spend` (`{credits, cost}`). ```python Python theme={"dark"} run = job.run.wait(timeout=600.0) spend = run.stats.spend if spend: print(f"this run used {spend.credits} credits (cost {spend.cost})") ``` Only successful requests are charged, so realized spend is at most the estimate. Per-task `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. ```python Python theme={"dark"} created = client.rotate_hmac_key() # the first call creates the active key print(created.kid, created.secret) # Zenrows returns the secret ONCE, so store it now # Phased rotation: client.rotate_hmac_key() # stage a candidate (start accepting both) client.finalize_hmac_key() # promote the candidate to active, retire the old one # client.cancel_hmac_rotation() # or discard the candidate client.list_hmac_keys() # metadata only; never returns secrets ``` Your receiver verifies the `X-Signature: t=,v1=,kid=` header by recomputing `HMAC-SHA256(secret, f"{t}.{raw_body}")` and comparing the result to `v1`, selecting the secret by `kid`. ## Error Handling Every non-2xx raises `BatchAPIError`. Its `code` attribute carries the stable code from the RFC 7807 body, and `status_code` the HTTP status. ```python Python theme={"dark"} from zenrows.batch import BatchAPIError try: client.get_job("does-not-exist") except BatchAPIError as e: print(e.status_code, e.code) # e.g. 404 not_found raise ``` Task-level failures don't raise exceptions. They appear as `failed` results with an `error` object you can read through `task.error.code` and `task.error.detail`. ## Idempotency Pass `idempotency_key` to `submit_*` and to `rerun` / `retry_failed` 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. An idempotency key 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. ```python Python theme={"dark"} job = client.submit_regular( ["https://www.scrapingcourse.com/ecommerce/"], idempotency_key="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](https://github.com/ZenRows/zenrows-python-sdk) or run `help(ZenRowsBatchClient)`. # Setting Up Batch Source: https://docs.zenrows.com/batch/setup Get started with the Zenrows Batch: find your API key, authenticate, submit your first batch job, and retrieve the results. This guide walks you through setting up the Zenrows Batch. You'll confirm your access, locate your API key, authenticate, submit your first batch job, and download the results end to end. Batch is available to every Zenrows account. You just need an active Zenrows API key; your existing Fetch key works without any extra setup. ## Initial Setup Follow these steps to get ready: Visit the [Registration Page](https://app.zenrows.com/register) to create an account, or log in if you already have one. Batch uses the **same API key as Fetch**, which you'll find in your dashboard. All REST requests go to `https://async.api.zenrows.com/v1`. ## Authentication Authenticate every request with your API key in the `X-API-Key` header. For security, the key travels in a header and never in the URL. ```bash theme={"dark"} X-API-Key: YOUR_ZENROWS_API_KEY ``` Store the key in an environment variable so your code can read it instead of hard-coding it: ```bash theme={"dark"} export ZENROWS_API_KEY="YOUR_ZENROWS_API_KEY" ``` ## Submit Your First Job This minimal example submits a **closed job** (one where you know all URLs up front) with JavaScript rendering enabled. Replace `YOUR_ZENROWS_API_KEY` with your key. ```python Python theme={"dark"} ## pip install requests import requests, json headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Content-Type': 'application/json'} payload = { 'type': 'regular', 'status': 'closed', 'zenrows_params': {'js_render': 'true'}, 'tasks': [ {'url': 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie'}, {'url': 'https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket'}, ], } response = requests.post('https://async.api.zenrows.com/v1/jobs', headers=headers, data=json.dumps(payload)) job = response.json() print('submitted', job['job_id'], job['latest_run']['status']) ``` ```javascript Node.js theme={"dark"} // npm install axios const axios = require('axios'); axios({ url: 'https://async.api.zenrows.com/v1/jobs', method: 'POST', headers: { 'X-API-Key': 'YOUR_ZENROWS_API_KEY' }, data: { type: 'regular', status: 'closed', zenrows_params: { js_render: 'true' }, tasks: [ { url: 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie' }, { url: 'https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket' }, ], }, }) .then(response => console.log('submitted', response.data.job_id, response.data.latest_run.status)) .catch(error => console.log(error)); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; import org.apache.hc.core5.http.ContentType; public class FirstJob { public static void main(final String... args) throws Exception { String body = "{" + "\"type\":\"regular\",\"status\":\"closed\"," + "\"zenrows_params\":{\"js_render\":\"true\"}," + "\"tasks\":[" + "{\"url\":\"https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie\"}," + "{\"url\":\"https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket\"}" + "]}"; String response = Request.post("https://async.api.zenrows.com/v1/jobs") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .bodyString(body, ContentType.APPLICATION_JSON) .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} 'regular', 'status' => 'closed', 'zenrows_params' => ['js_render' => 'true'], 'tasks' => [ ['url' => 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie'], ['url' => 'https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket'], ], ]); $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, 'https://async.api.zenrows.com/v1/jobs'); curl_setopt($ch, CURLOPT_POST, 1); curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); curl_setopt($ch, CURLOPT_HTTPHEADER, ['X-API-Key: YOUR_ZENROWS_API_KEY', 'Content-Type: application/json']); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $response = curl_exec($ch); echo $response . PHP_EOL; curl_close($ch); ?> ``` ```go Go theme={"dark"} package main import ( "bytes" "io" "log" "net/http" ) func main() { payload := []byte(`{ "type": "regular", "status": "closed", "zenrows_params": { "js_render": "true" }, "tasks": [ { "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie" }, { "url": "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket" } ] }`) req, err := http.NewRequest("POST", "https://async.api.zenrows.com/v1/jobs", bytes.NewBuffer(payload)) if err != nil { log.Fatalln(err) } req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' payload = { type: 'regular', status: 'closed', zenrows_params: { js_render: 'true' }, tasks: [ { url: 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie' }, { url: 'https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket' } ] } conn = Faraday.new res = conn.post('https://async.api.zenrows.com/v1/jobs') do |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' req.headers['Content-Type'] = 'application/json' req.body = payload.to_json end print(res.body) ``` ```bash cURL theme={"dark"} curl -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", "zenrows_params": { "js_render": "true" }, "tasks": [ { "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie" }, { "url": "https://www.scrapingcourse.com/ecommerce/product/adrienne-trek-jacket" } ] }' ``` The response includes a `job_id` and the initial run status. Poll `GET /jobs/{id}` until the run reaches a **terminal** state (finished and no longer changing), then collect the results. ### Get Your Results Once the run is complete, list the successful tasks and download each one's scraped content. Every result row carries a `result_url`, a presigned link to the scraped page that is valid for 2 hours. Replace `JOB_ID` with the `job_id` from the submit response. ```python Python theme={"dark"} ## pip install requests import requests headers = {'X-API-Key': 'YOUR_ZENROWS_API_KEY'} page = requests.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/results', headers=headers, params={'status': 'successful'}).json() for row in page['results']: html = requests.get(row['result_url']).text # the scraped page print(row.get('external_id') or row['task_id'], len(html), 'chars') ``` ```javascript Node.js theme={"dark"} // npm install axios const axios = require('axios'); const page = (await axios.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/results', { headers: { 'X-API-Key': 'YOUR_ZENROWS_API_KEY' }, params: { status: 'successful' }, })).data; for (const row of page.results) { const html = (await axios.get(row.result_url)).data; // the scraped page console.log(row.external_id ?? row.task_id, String(html).length, 'chars'); } ``` ```java Java theme={"dark"} import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hc.client5.http.fluent.Request; public class GetResults { public static void main(final String... args) throws Exception { ObjectMapper mapper = new ObjectMapper(); JsonNode page = mapper.readTree( Request.get("https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful") .addHeader("X-API-Key", "YOUR_ZENROWS_API_KEY") .execute().returnContent().asString()); for (JsonNode row : page.path("results")) { String html = Request.get(row.path("result_url").asText()) .execute().returnContent().asString(); // the scraped page String id = row.path("external_id").asText(row.path("task_id").asText()); System.out.println(id + " " + html.length() + " chars"); } } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "encoding/json" "io" "log" "net/http" ) func main() { req, _ := http.NewRequest("GET", "https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful", nil) req.Header.Set("X-API-Key", "YOUR_ZENROWS_API_KEY") resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var page struct { Results []struct { TaskID string `json:"task_id"` ExternalID string `json:"external_id"` ResultURL string `json:"result_url"` } `json:"results"` } json.Unmarshal(body, &page) for _, row := range page.Results { r, _ := http.Get(row.ResultURL) // the scraped page html, _ := io.ReadAll(r.Body) r.Body.Close() name := row.ExternalID if name == "" { name = row.TaskID } log.Println(name, len(html), "chars") } } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' require 'json' page = JSON.parse(Faraday.get('https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful') { |req| req.headers['X-API-Key'] = 'YOUR_ZENROWS_API_KEY' }.body) page['results'].each do |row| html = Faraday.get(row['result_url']).body # the scraped page puts "#{row['external_id'] || row['task_id']} #{html.length} chars" end ``` ```bash cURL theme={"dark"} # 1) List the successful results; each row has a presigned result_url valid 2h. curl "https://async.api.zenrows.com/v1/jobs/JOB_ID/results?status=successful" \ -H "X-API-Key: YOUR_ZENROWS_API_KEY" # 2) Download one result's scraped HTML using its result_url from the response above. curl "RESULT_URL" ``` Want to see the cost before you run anything? You can estimate credits client-side with no API call. See [Estimate cost](/batch/developer-guide-restapi#6-cost). ## Next Steps Every feature against the REST API: open jobs, CSV upload, scheduling, webhooks, and more. Diagnose authentication, submission, download, and webhook issues fast. Quick answers on access, pricing, limits, and scraping options. # Troubleshooting Source: https://docs.zenrows.com/batch/troubleshooting Diagnose and fix the most common Batch issues: authentication, submission errors, job state conflicts, downloads, webhooks, scheduling, and unexpected cost. Batch is available to every Zenrows account. If requests fail with `401 unauthenticated`, check that your API key is present and valid before working through the steps below. Most Batch problems fall into a handful of categories. Find your symptom in the table below, then jump to the section that explains the cause and the fix. | Symptom | Likely cause | Go to | | - | - | - | | `401` on every request | Missing or wrong API key | [Authentication and access](#authentication-and-access) | | `400 invalid_argument` on submit | Malformed body, bad URL, or batch over the limit | [Submission errors](#submission-errors) | | `429 quota_exceeded` on submit | Too many active jobs (max 3) | [Account limits](#account-limits) | | `409 conflict` when adding tasks | The job is already closed | [Job and run state conflicts](#job-and-run-state-conflicts) | | Download link returns an error | The presigned URL expired | [Results and downloads](#results-and-downloads) | | Some URLs never returned data | Task-level failures, not request errors | [Tasks that fail](#tasks-that-fail) | | Webhook never arrived | Receiver not HTTPS, or signature setup | [Webhooks not arriving](#webhooks-not-arriving) | | Schedule didn't fire as expected | Unsupported time value or timezone | [Scheduled jobs](#scheduled-jobs) | | Spent more credits than estimated | Retries or `mode=auto` range | [Unexpected cost](#unexpected-cost) | | `503` responses | Transient server error | [Transient errors](#transient-errors) | ## How to Diagnose Any Request Every error response is JSON in the `application/problem+json` format and carries a stable `code` (for example `invalid_argument` or `conflict`). The `detail` field usually states the cause directly. See the full table in the [developer guide](/batch/developer-guide-restapi#error-handling-reference). A non-2xx response means the API rejected your request. A `2xx` response with `failed` results means individual URLs failed while the job itself succeeded. These are handled differently. See [Tasks that fail](#tasks-that-fail). Note the `job_id` (and `run_id` where relevant). If you need to contact support, include them so the team can trace your job in the logs. ## Authentication and Access **`401 unauthenticated`** means the API key is missing or invalid. * Send the key in the `X-API-Key` header on every request. Never put it in the URL. * Use the same key as your Fetch. No separate Batch key exists. **`402 payment_required`** means your account has no available credits. Top up your plan, then resubmit. **`402 api_key_cap_reached`** means the API key reached one of its [credit caps](/first-steps/api-key-credit-caps). The `detail` names the cap and when it resets. Raise or remove the cap, or wait for it to reset, then retry. See [API key credit caps and Batch](/batch/developer-guide-restapi#api-key-credit-caps-and-batch). ## Submission Errors **`400 invalid_argument`** means the request body failed validation. The response includes an `invalid_tasks` array listing each problem as `{index, reason}`, so you can see exactly which URL failed and why. Common causes and fixes: * **Malformed or unencoded URLs.** Make sure each `url` is a complete, valid address. Fix any entry flagged in `invalid_tasks`. * **Too many URLs.** A job submit accepts up to **100,000 URLs**, and each add-tasks call on an [open job](/batch/developer-guide-restapi#open-jobs-adding-tasks-in-batches) accepts up to **10,000**. For very large lists, use a [CSV upload](/batch/developer-guide-restapi#large-inputs-csv-upload). * **Wrong value types in `zenrows_params`.** Values can be strings, booleans, or numbers. Boolean options such as `js_render` are sent as `"true"` / `"false"`. **CSV uploads must use the exact content-type the upload slot requires.** When you PUT the file to the presigned upload URL, send the headers returned in `slot.upload.headers` (for example `Content-Type: text/csv`) unchanged. A mismatched content-type causes the upload to fail. Limits: 50 MB / 100,000 rows. ## Account Limits **`429 quota_exceeded`** means you've hit an account limit. An account can run **3 active jobs concurrently**; submitting a 4th returns this error. Wait for one to finish, or contact Zenrows to raise the limit for your account. These limits can be raised per account on request. ## Job and Run State Conflicts **`409 conflict`** means the job is in a state that doesn't allow your action. Check the `detail` and `code` to see which conflict it is. * **Adding tasks to a closed job.** Once you send `last_batch: true` or call `close`, the job locks and rejects further adds. Open a new job for more URLs. * **Ingestion still in progress.** A very large submission keeps writing its task rows for a few seconds after responding. `POST /jobs/{id}/tasks` on an open job can return `409` during this window; wait a moment and retry. * **`run_not_terminal`.** You tried an action that requires the run to be finished (for example, exporting) while it's still running. Wait for the run to reach a terminal state, then retry. * **Abandoning an in-flight run.** To stop a run that's still processing, call `POST /jobs/{id}/stop` (or its alias `POST /jobs/{id}/cancel`). To suspend it without ending it, use `POST /jobs/{id}/pause` and later `POST /jobs/{id}/resume`. See [Control the current run](/batch/developer-guide-restapi#control-the-current-run). ## Results and Downloads **A `result_url` returns an error.** Each `result_url` is a **presigned URL** (a temporary, signed link) valid for **2 hours**. If it expired, re-list the results to get a fresh link rather than reusing a stored one. Don't persist these URLs long-term. **Fetching a single task's content returns a non-200 status.** The `/tasks/{id}/content` endpoint uses status to signal task state: | Status | Meaning | | - | - | | `200` | Content is ready; the body is the scraped result. | | `409` | The task is still pending or processing. Poll again shortly. | | `422` | The task failed; the body is the stored error. | **A bulk export fails or its link stops working.** Exports expire **12 hours** after creation, and the API presigns the `download_url` fresh on each poll, so always use the latest one. If a run's combined results exceed **100 GiB**, the export fails with a clear error instead of producing a partial archive. Download results in pages, or split the work across smaller runs. ## Tasks That Fail When the job succeeds but some URLs don't return data, those are **task-level failures**, not request errors. They don't raise an HTTP error or an exception. * List results filtered by `status="failed"` and read each row's `error` object (`error.code`, `error.detail`) to see what went wrong. * A failed task's results row already carries the error, so you rarely need the `/content` endpoint just to find out why it failed. * To re-run only the failures, use [retry failed](/batch/developer-guide-restapi#5-reruns-and-retrying-failures). Successful results carry over, so you only pay to re-scrape what failed. * If a partial rerun returns `409 partial_rerun_window_expired`, the 10-day window for retrying individual statuses has closed. Start a full rerun instead. If many tasks fail on a protected site, the cause is usually the scrape configuration, not Batch. Enable `js_render` for dynamic pages, add `premium_proxy` and `proxy_country` for blocked or geo-restricted sites, or use `wait_for` to wait for a selector before capturing, the same options you would use with Fetch. ## Webhooks Not Arriving The API sends a `run.completed` event when a run completes, or a `run.failed` event when it fails. If you don't receive it: * **Use an HTTPS receiver.** Webhook URLs must use HTTPS. * **Test the endpoint first.** Call `POST /webhook/test` with your receiver URL to confirm the API can reach it. The response reports `delivered` and `status_code`. * **Deduplicate, don't assume once.** Deliveries are **at-least-once**, so the same event can arrive more than once. Deduplicate on the `X-ZenRows-Event-Id` header. * **Signature verification fails.** Signed deliveries (`signature: true`) require an active HMAC key for your organization. Verify the `X-Signature` header against the **raw request body**, not a re-serialized object. See [Verifying signed webhooks](/batch/developer-guide-restapi#verify-signed-webhooks). Webhooks are a convenience, not the source of truth. If you ever miss a delivery, poll the job. The run state and result URLs are always authoritative. ## Scheduled Jobs * **Times of day must be full hours.** Use `"09:00"`, not `"09:30"`. A non-hour value is rejected. * **Use IANA timezone names.** Names like `Europe/Berlin` keep daylight-saving transitions correct. Pair a timezone with `at` and `calendar` schedules. * **Provide exactly one schedule type.** Send one of `at`, `rate`, or `calendar` per scheduled job. * **Pausing and resuming.** Change state with `POST /jobs/{id}/schedule/state` using `{"schedule_state": "paused"}` or `{"schedule_state": "active"}`. ## Unexpected Cost * **Only successful requests cost credits.** A run with failures costs less than its up-front estimate, never more for the failed tasks. * **The estimate assumes one success per URL.** Compare it against the run's actual spend with [Checking actual cost](/batch/developer-guide-restapi#check-actual-cost). * **Adaptive Stealth Mode is a range.** With `mode=auto`, each task's credit cost is dynamic (1 to 25) depending on what the site requires, so the estimate is a range rather than a single number. ## Transient Errors **`503 internal`** is a transient server-side error and is safe to retry. Use an increasing delay (a **backoff**) between attempts rather than a tight retry loop, as shown in the [developer guide](/batch/developer-guide-restapi#check-job-status). On a `503` from a submit specifically, retry with a **fresh** idempotency key, since a key claimed by the failed attempt replays `409`. A late `503` can also leave a visible job with a `stopped` run, which is safe to `DELETE`. ## Still Stuck? If a problem persists after you've worked through the relevant section above, reach out to support with: * The `job_id` (and `run_id` if the issue is run-specific). * The exact request body, with your API key redacted. * The HTTP status and the full error `code` and `detail`. * Whether the issue is reproducible or intermittent, and when it started. This lets the team trace your job in the logs and respond without re-asking for the basics. # Browser Sessions FAQ: Sessions, CDP, and Automation Source: https://docs.zenrows.com/browser-sessions/faq Find answers to frequently asked questions about Browser Sessions sessions, CDP protocol, proxy management, and browser automation limits. Using Zenrows Browser Sessions (formerly, Scraping Browser) saves you from the hassle of managing proxies, rotating IPs, and dealing with browser automation setup. With just one line of code, you get access to a fully managed browser with residential proxies. At the moment, Zenrows Browser Sessions cannot solve CAPTCHAs. You will need to handle CAPTCHA solving through third-party services if required. You can also try our [Fetch](/fetch/api-reference), which comes with everything available on Browser Sessions, plus antibot capabilities, anti-CAPTCHAs and many more. The Zenrows Browser Sessions uses the Chrome DevTools Protocol (CDP) as its communication standard. CDP is a powerful protocol used for browser automation and debugging, allowing you to control a browser instance programmatically. ## Why CDP? CDP is widely adopted because it provides deep control over browser behavior, offering features like: * **Page interaction:** Automate navigation, clicks, form submissions, etc. * **Network monitoring:** Access detailed network activity, including headers, responses, and requests. * **DOM manipulation:** Direct interaction with and modification of the Document Object Model (DOM). * **JavaScript execution:** Run JavaScript code within the page context. * **Performance tracking:** Monitor performance metrics, including rendering and page load speeds. * **Screenshots & PDFs:** Capture visual representations of the page. By leveraging CDP, the Zenrows Browser Sessions allows you to interact with web pages just like a human user would, while also benefiting from additional features like IP rotation and proxy management. ## How is CDP different from other protocols? Compared to other protocols like WebDriver (used by Selenium), CDP is generally faster and more capable because it communicates directly with the browser's core without the intermediary of a WebDriver layer. This direct interaction gives CDP greater flexibility and control, making it the protocol of choice for Zenrows Browser Sessions. No, Zenrows Browser Sessions does not allow programmatically changing the viewport or window size. Methods like page.setViewportSize() or similar browser commands are blocked and will not take effect. The browser window and viewport are managed automatically to optimize for anti-bot protection and reliability. If you need to take screenshots at different sizes, this is currently not supported by resizing the window or viewport. The browser will always use a real, consistent window size that matches the fingerprint. For reference, Zenrows also restricts other browser-modifying actions, such as changing the user agent or certain device metrics. If you have specific requirements or questions about supported features, please contact our support team. # Country Targeting for Zenrows Browser Sessions Source: https://docs.zenrows.com/browser-sessions/features/country Learn how to configure country-level geo-targeting in Zenrows Browser Sessions to access localized content and geo-restricted websites. Zenrows Browser Sessions (formerly, Scraping Browser) enables precise country-level targeting for your web scraping operations, letting you access content as if you were browsing from a specific country. This is essential for geo-restricted content, localized pricing, and market research across nations. ## What is Country Targeting? Country-targeting routes your scraping requests through residential IP addresses from a specific country, providing the most precise geolocation control available. Unlike world region targeting, which covers broad areas, country targeting gives you IPs from individual nations. **Key benefits:** * Access country-specific content and services * Accurate localized pricing and product data * Compliance testing for legal jurisdictions * Ad verification and fraud prevention by country * Market research with country-level precision Auto-rotate and Residential IPs are pre-configured and enabled by default for all Zenrows Browser Sessions users. Country targeting works seamlessly with these features to provide optimal performance. ## How to Configure Country Targeting You can configure country targeting in two ways: * **Zenrows SDK:** Recommended for most users (simplest, most robust) * **Direct WebSocket URL:** For advanced or custom integrations For custom integrations or direct control, specify the country using the `proxy_country` parameter in the WebSocket URL. ```bash theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_country=es ``` ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const wsUrl = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_country=es'; (async () => { const browser = await puppeteer.connect({ browserWSEndpoint: wsUrl }); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` ```python Python theme={"dark"} import asyncio from pyppeteer import connect async def main(): ws_url = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_country=es' browser = await connect(browserWSEndpoint=ws_url) page = await browser.newPage() await page.goto('https://example.com') print(await page.title()) await browser.close() asyncio.get_event_loop().run_until_complete(main()) ``` Zenrows supports residential IPs from over 100 countries worldwide. For the complete list and codes, see our [Premium Proxy Countries List](/first-steps/faq#what-is-geolocation-and-what-are-all-the-premium-proxy-countries). **Most Popular:** * **us** - United States * **gb** - United Kingdom * **br** - Brazil * **de** - Germany * **ca** - Canada * **au** - Australia * **fr** - France * **in** - India * **es** - Spain * **it** - Italy * **nl** - Netherlands For custom integrations or direct control, specify the country using the `proxy_country` parameter in the WebSocket URL. ```bash theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_country=es ``` ```javascript Node.js theme={"dark"} const { ScrapingBrowser, ProxyCountry } = require('@zenrows/scraping-browser'); const { chromium } = require('playwright'); (async () => { const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); // Target Germany const connectionURL = scrapingBrowser.getConnectURL({ proxy: { location: ProxyCountry.DE }, }); const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` ```python Python theme={"dark"} import asyncio from playwright.async_api import async_playwright async def main(): ws_url = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_country=es' async with async_playwright() as p: browser = await p.chromium.connect_over_cdp(ws_url) page = await browser.new_page() await page.goto('https://example.com') print(await page.title()) await browser.close() asyncio.run(main()) ``` Zenrows supports residential IPs from over 100 countries worldwide. For the complete list and codes, see our [Premium Proxy Countries List](/first-steps/faq#what-is-geolocation-and-what-are-all-the-premium-proxy-countries). **Most Popular:** * **us** - United States * **gb** - United Kingdom * **br** - Brazil * **de** - Germany * **ca** - Canada * **au** - Australia * **fr** - France * **in** - India * **es** - Spain * **it** - Italy * **nl** - Netherlands The Zenrows SDK offers the easiest way to configure country targeting, with built-in error handling and connection management. ```javascript Node.js Puppeteer theme={"dark"} const { ScrapingBrowser, ProxyCountry } = require('@zenrows/scraping-browser'); const puppeteer = require('puppeteer-core'); (async () => { const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); // Target Spain const connectionURL = scrapingBrowser.getConnectURL({ proxy: { location: ProxyCountry.ES }, }); const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` ```javascript Node.js Playwright theme={"dark"} const { ScrapingBrowser, ProxyCountry } = require('@zenrows/scraping-browser'); const { chromium } = require('playwright'); (async () => { const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); // Target Germany const connectionURL = scrapingBrowser.getConnectURL({ proxy: { location: ProxyCountry.DE }, }); const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` **Popular SDK Country Options:** * `ProxyCountry.US` - United States * `ProxyCountry.GB` - United Kingdom * `ProxyCountry.BR` - Brazil * `ProxyCountry.DE` - Germany * `ProxyCountry.FR` - France * `ProxyCountry.IN` - India * `ProxyCountry.ES` - Spain * `ProxyCountry.CA` - Canada * `ProxyCountry.AU` - Australia * `ProxyCountry.JP` - Japan Country Targeting Configuration Interface ## Best Practices **Choosing the Right Country:** * Select countries based on your target market or content * Consider local business hours and time zones for time-sensitive scraping * Test with multiple countries to understand regional content differences **SDK vs Direct Connection:** * **Use the SDK** for most projects. It offers better error handling and automatic retries * **Direct WebSocket** is for custom frameworks or specific connection control **Performance Optimization:** * Some countries have larger IP pools and better performance * Monitor success rates and adjust the country selection accordingly * Use fallback countries if your primary choice has issues ## Troubleshooting **Common Issues and Solutions:** **Country Code Errors:** * Use the correct ISO country code (e.g., 'es' for Spain) * Check the country list to ensure support * Use lowercase codes in direct WebSocket connections **No Content Differences:** * Some sites may not vary by country. Test manually by accessing the website * Try different countries to verify geo-targeting * Some sites use detection beyond IP geolocation **Connection Issues:** * Some countries may have smaller IP pools. Try alternative countries * Monitor the response error for indications that may help * Use world region targeting if country-level access is problematic You cannot use both world region and country targeting at the same time. Select the level of precision you require, whether broad regional coverage or targeted to a specific country. ## Next Steps * Explore [Session TTL Configuration](/browser-sessions/features/session-ttl) to control browser session duration * Learn about [World Region Targeting](/browser-sessions/features/world-region) for broader coverage * See the Zenrows SDK Repository for advanced features and examples * Review [Premium Proxy Countries List](/first-steps/faq#what-is-geolocation-and-what-are-all-the-premium-proxy-countries) for all available countries * Check the [Practical Use Cases](/browser-sessions/help/practical-use-cases) for real world examples # Session TTL Configuration for Zenrows Browser Sessions Source: https://docs.zenrows.com/browser-sessions/features/session-ttl Set and customize Session TTL to manage how long your Zenrows Browser Sessions sessions stay active, from 1 to 15 minutes maximum. Zenrows Browser Sessions (formerly, Scraping Browser) lets you control the duration of your browser sessions with Session TTL (Time To Live) configuration. This is crucial for managing resource usage, maintaining session consistency, and optimizing scraping workflows. ## What is Session TTL? Session TTL (Time To Live) controls how long a browser session stays active before terminating. Think of it as a timer for your scraping session after the time expires, the session closes automatically, freeing up resources and ensuring clean session management. **Why Session TTL matters:** * **Resource Management:** Prevents sessions from running indefinitely * **Cost Control:** Helps manage usage costs by auto-terminating idle sessions * **Session Consistency:** Maintains stable connections for multi-step workflows * **Workflow Optimization:** Lets you match session duration to your scraping needs The default Session TTL is 3 minutes (180 seconds), which works well for most tasks. You can customize this between 1 minute (60 seconds) and 15 minutes (900 seconds). ## How to Configure Session TTL You can configure Session TTL in two ways: * **Zenrows SDK:** Recommended for most users (simple, robust) * **Direct WebSocket URL:** For advanced or custom integrations For advanced users or custom integrations, specify Session TTL in the WebSocket URL using the `session_ttl` parameter with duration strings. ```bash theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&session_ttl=2m ``` **Duration String Examples:** * `1m` - 1 minute (60 seconds) * `2m` - 2 minutes (120 seconds) * `5m` - 5 minutes (300 seconds) * `10m` - 10 minutes (600 seconds) * `15m` - 15 minutes (900 seconds - max) ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const wsUrl = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&session_ttl=5m'; (async () => { const browser = await puppeteer.connect({ browserWSEndpoint: wsUrl }); const page = await browser.newPage(); await page.goto('https://example.com'); await browser.close(); })(); ``` ```python Python theme={"dark"} import asyncio from pyppeteer import connect async def main(): ws_url = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&session_ttl=5m' browser = await connect(browserWSEndpoint=ws_url) page = await browser.newPage() await page.goto('https://example.com') await browser.close() asyncio.get_event_loop().run_until_complete(main()) ``` For advanced users or custom integrations, specify Session TTL in the WebSocket URL using the `session_ttl` parameter with duration strings. ```bash theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&session_ttl=2m ``` **Duration String Examples:** * `1m` - 1 minute (60 seconds) * `2m` - 2 minutes (120 seconds) * `5m` - 5 minutes (300 seconds) * `10m` - 10 minutes (600 seconds) * `15m` - 15 minutes (900 seconds - max) ```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); const wsUrl = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&session_ttl=5m'; (async () => { const browser = await chromium.connectOverCDP(wsUrl); const page = await browser.newPage(); await page.goto('https://example.com'); await browser.close(); })(); ``` ```python Python theme={"dark"} import asyncio from playwright.async_api import async_playwright async def main(): ws_url = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&session_ttl=5m' async with async_playwright() as p: browser = await p.chromium.connect_over_cdp(ws_url) page = await browser.new_page() await page.goto('https://example.com') await browser.close() asyncio.run(main()) ``` The Zenrows SDK offers the most user-friendly way to configure Session TTL, featuring automatic session management and error handling. ```javascript Node.js Puppeteer theme={"dark"} const { ScrapingBrowser } = require('@zenrows/scraping-browser'); const puppeteer = require('puppeteer-core'); (async () => { const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); // 2-minute session const connectionURL = scrapingBrowser.getConnectURL({ sessionTTL: 120 }); const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); await page.goto('https://example.com'); await browser.close(); })(); ``` ```javascript Node.js Playwright theme={"dark"} const { ScrapingBrowser } = require('@zenrows/scraping-browser'); const { chromium } = require('playwright'); (async () => { const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); // 5-minute session const connectionURL = scrapingBrowser.getConnectURL({ sessionTTL: 300 }); const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); await page.goto('https://example.com'); await browser.close(); })(); ``` **SDK Configuration Examples:** ```javascript theme={"dark"} // 1-minute session const quickSession = scrapingBrowser.getConnectURL({ sessionTTL: 60 }); // 5-minute session const mediumSession = scrapingBrowser.getConnectURL({ sessionTTL: 300 }); // 15-minute session (maximum) const longSession = scrapingBrowser.getConnectURL({ sessionTTL: 900 }); ``` ## Choosing the Right Session TTL **Session Duration Guidelines:** **1-2 Minutes (60-120 seconds):** * Quick data extraction from single pages * Simple form submissions * Basic content scraping * Testing and development **3-5 Minutes (180-300 seconds):** * Multi-page navigation * Workflows with several steps * Moderate data collection * Most general scraping **10-15 Minutes (600-900 seconds):** * Complex multi-step workflows * Large-scale extraction * User interaction simulation * Long-running automation ## Combining Session TTL with Other Features Session TTL works seamlessly with other Zenrows features for advanced scraping setups. ### Session TTL + Country Targeting ```javascript Node.js theme={"dark"} const connectionURL = scrapingBrowser.getConnectURL({ sessionTTL: 300, // 5 minutes proxy: { location: ProxyCountry.ES }, }); ``` ### Session TTL + World Region Targeting ```javascript Node.js theme={"dark"} const connectionURL = scrapingBrowser.getConnectURL({ sessionTTL: 600, // 10 minutes proxy: { location: ProxyRegion.Europe }, }); ``` ### Direct WebSocket with Multiple Parameters ```bash theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_API_KEY&session_ttl=8m&proxy_country=us ``` ## Best Practices **Optimize for Your Workflow:** * Match session duration to your scraping needs * Add a small buffer (30-60 seconds) for delays * Use shorter sessions for simple tasks **SDK vs Direct Connection:** * **SDK:** Use for most projects and handles sessions automatically * **Direct WebSocket:** Use for custom frameworks or specific control **Monitoring and Debugging:** * Monitor session usage in the Zenrows dashboard * Log session start/end times * Use proper durations to avoid premature expiry **Error Handling:** ```javascript Node.js theme={"dark"} try { const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); // Your scraping code } catch (error) { if (error.message.includes('session expired')) { console.log('Session TTL expired, consider increasing duration'); } // Other errors } ``` ## Troubleshooting **Common Issues and Solutions:** **Session Expires Too Quickly:** * Increase sessionTTL for more time * Monitor execution time and add buffer * Break long workflows into smaller sessions **Invalid TTL Values:** * TTL must be 60-900 seconds in SDK * Use valid duration strings (1m, 2m, etc.) for WebSocket * Match TTL to workflow **Session Management Issues:** * Always close browser connections properly * Handle session expiration errors * Monitor usage in the [Analytics dashboard](https://app.zenrows.com/overview?product=browser#performance) The sessionTTL parameter uses seconds with the SDK (e.g., 120 for 2 min) and duration strings for direct WebSocket (e.g., "2m"). ## Next Steps * Learn about [Country Targeting](/browser-sessions/features/country) * Explore [World Region Targeting](/browser-sessions/features/world-region) * See the Zenrows SDK Repository for advanced features and examples * Review the [Practical Use Cases](/browser-sessions/help/practical-use-cases) for real world examples # World Region Configuration for Zenrows Browser Sessions Source: https://docs.zenrows.com/browser-sessions/features/world-region Set up world region targeting to route your Zenrows Browser Sessions requests via residential IPs for location-specific web scraping. Zenrows Browser Sessions (formerly, Scraping Browser) empowers you to target specific regions of the world for your web scraping operations, unlocking geo-restricted content and enabling region-specific data collection. This guide shows you how to configure world region targeting using both the Zenrows SDK (recommended) and direct WebSocket connections. ## What is World Region Targeting? World region targeting routes your scraping requests through residential IPs from a chosen geographic area. This is crucial when websites display different content, pricing, or availability based on visitor location. **Typical use cases:** * Accessing geo-locked content or services * Price comparison across markets * Testing website localization and UX * Circumventing geo-blocks for data gathering * Ad verification in multiple regions Auto-rotate and Residential IPs are pre-configured and enabled by default for all Zenrows Browser Sessions users, ensuring optimal performance and reliability. ## How to Configure World Region You can configure world region targeting in two ways: * **Zenrows SDK:** Easiest and most robust option for most users * **Direct WebSocket URL:** For advanced or custom integrations Specify the region in the WebSocket URL with the `proxy_region` parameter: ```bash theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_region=eu ``` **Examples:** ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_region=eu'; (async () => { const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` ```python Python theme={"dark"} import asyncio from pyppeteer import connect async def main(): ws_url = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_region=eu' browser = await connect(browserWSEndpoint=ws_url) page = await browser.newPage() await page.goto('https://example.com') print(await page.title()) await browser.close() asyncio.get_event_loop().run_until_complete(main()) ``` **Available Region Codes:** * `eu` - Europe * `na` - North America * `ap` - Asia Pacific * `sa` - South America * `af` - Africa * `me` - Middle East Specify the region in the WebSocket URL with the `proxy_region` parameter: ```bash theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_region=eu ``` **Examples:** ```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); (async () => { const browser = await chromium.connectOverCDP('wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_region=eu'); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` ```python Python theme={"dark"} import asyncio from playwright.async_api import async_playwright async def main(): async with async_playwright() as p: browser = await p.chromium.connect_over_cdp('wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY&proxy_region=eu') page = await browser.new_page() await page.goto('https://example.com') print(await page.title()) await browser.close() asyncio.run(main()) ``` **Available Region Codes:** * `eu` - Europe * `na` - North America * `ap` - Asia Pacific * `sa` - South America * `af` - Africa * `me` - Middle East The Zenrows SDK makes region configuration simple and manages connections automatically. Recommended for most users. ```javascript Node.js Puppeteer theme={"dark"} const { ZenRowsBrowser, ProxyRegion } = require('zenrows'); (async () => { const browser = await ZenRowsBrowser.puppeteer({ apiKey: 'YOUR_ZENROWS_API_KEY', proxyRegion: ProxyRegion.Europe // or .NorthAmerica, .AsiaPacific, etc. }); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` ```javascript Node.js Playwright theme={"dark"} const { ZenRowsBrowser, ProxyRegion } = require('zenrows'); (async () => { const browser = await ZenRowsBrowser.playwright({ apiKey: 'YOUR_ZENROWS_API_KEY', proxyRegion: ProxyRegion.Europe // or .NorthAmerica, .AsiaPacific, etc. }); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })(); ``` **Available SDK Region Options:** * `ProxyRegion.Europe` - European residential IPs * `ProxyRegion.NorthAmerica` - North American residential IPs * `ProxyRegion.AsiaPacific` - Asia-Pacific residential IPs * `ProxyRegion.SouthAmerica` - South American residential IPs * `ProxyRegion.Africa` - African residential IPs * `ProxyRegion.MiddleEast` - Middle Eastern residential IPs World Region Configuration Interface ## Best Practices **Choosing the Right Region:** * Select the region closest to your target audience or where the content is most relevant * Consider local time zones and business hours for time-sensitive scraping **SDK vs Direct Connection:** * Use the SDK for most projects (better error handling and connection management) * Use direct WebSocket only for fine-grained control or custom framework integration **Testing and Validation:** * Test your region config with a simple request to verify IP location * Monitor scraping results to ensure you're seeing expected regional content ## Troubleshooting **Common Issues:** * **Invalid region code:** Double-check you're using the correct region codes * **No regional content difference:** Some sites may not vary by region. Verify with manual testing * **Connection timeouts:** Proxy pools may vary by region; try another if needed **Debugging Tips:** * Use browser dev tools to inspect the IP address * Test the same URL from different regions to confirm content variation * Check Zenrows dashboard for logs and error details For more granular targeting, see [Country Targeting](/browser-sessions/features/country). ## Next Steps * Learn about [Country Targeting](/browser-sessions/features/country) for precision * Explore [Session TTL Configuration](/browser-sessions/features/session-ttl) to manage session duration * See the Zenrows SDK Repository for advanced features and examples * Review the [Practical Use Cases](/browser-sessions/help/practical-use-cases) for real world examples # Integrating Zenrows Browser Sessions with Playwright Source: https://docs.zenrows.com/browser-sessions/get-started/playwright Step-by-step guide to connect Zenrows Browser Sessions with Playwright for Python and Node.js web scraping and browser automation. Learn to extract data from any website using Zenrows' Browser Sessions (formerly, Scraping Browser) with Playwright. This guide walks you through creating your first browser-based scraping request that can handle complex JavaScript-heavy sites with full browser automation. Zenrows' Browser Sessions provides cloud-based Chrome instances you can control using Playwright. Whether dealing with dynamic content, complex user interactions, or sophisticated anti-bot protection, you can get started in minutes with Playwright's powerful automation capabilities. ## 1. Set Up Your Project ### Set Up Your Development Environment Before diving in, ensure you have the proper development environment and Playwright installed. Browser Sessions works seamlessly with both Python and Node.js versions of Playwright. While previous versions may work, we recommend using the latest stable versions for optimal performance and security. Python 3+ installed (latest stable version recommended). Using an IDE like PyCharm or Visual Studio Code with the Python extension is recommended. ```bash theme={"dark"} # Install Python (if not already installed) # Visit https://www.python.org/downloads/ or use package managers: # macOS (using Homebrew) brew install python # Ubuntu/Debian sudo apt update && sudo apt install python3 python3-pip # Windows (using Chocolatey) choco install python # Install Playwright pip install playwright playwright install ``` If you need help setting up your environment, check out our detailed [Playwright web scraping guide](https://www.zenrows.com/blog/playwright-scraping) Node.js 18+ installed (latest LTS version recommended). Using an IDE like Visual Studio Code or IntelliJ IDEA will enhance your coding experience. ```bash theme={"dark"} # Install Node.js (if not already installed) # Visit https://nodejs.org/ or use package managers: # macOS (using Homebrew) brew install node # Ubuntu/Debian (using NodeSource) curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt-get install -y nodejs # Windows (using Chocolatey) choco install nodejs # Install Playwright npm install playwright npx playwright install ``` If you need help setting up your environment, check out our detailed [Playwright web scraping guide](https://www.zenrows.com/blog/playwright-scraping) ### Get Your API Key and Connection URL [Sign Up](https://app.zenrows.com/register?prod=scraping_browser) for a free Zenrows account and get your API key from the [Browser Sessions dashboard](https://app.zenrows.com/browser). You'll need this key to authenticate your WebSocket connection. ## 2. Make Your First Request Start with a simple request to understand how Browser Sessions works with Playwright. We'll use the E-commerce Challenge page to demonstrate how to connect to the browser and extract the page title. ```python Python theme={"dark"} # pip install playwright import asyncio from playwright.async_api import async_playwright # scraping browser connection URL connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def scraper(): async with async_playwright() as p: # connect to the scraping browser browser = await p.chromium.connect_over_cdp(connection_url) context = browser.contexts[0] if browser.contexts else await browser.new_context() page = await context.new_page() await page.goto('https://www.scrapingcourse.com/ecommerce/') print(await page.title()) await browser.close() if __name__ == "__main__": asyncio.run(scraper()) ``` ```javascript Node.js theme={"dark"} // npm install playwright const { chromium } = require('playwright'); // scraping browser connection URL const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const scraper = async () => { // connect to the scraping browser const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); await page.goto('https://www.scrapingcourse.com/ecommerce/'); console.log(await page.title()); await browser.close(); }; scraper(); ``` Replace `YOUR_ZENROWS_API_KEY` with your actual API key and run the script: ```bash Python theme={"dark"} python scraper.py ``` ```bash Node.js theme={"dark"} node scraper.js ``` ### Expected Output The script will print the page title: ```plaintext theme={"dark"} ScrapingCourse.com E-commerce Challenge ``` Perfect! You've just made your first web scraping request with the Zenrows Browser Sessions using Playwright. ## 3. Build a Real-World Scraping Scenario Let's scale up to a practical scraping scenario by extracting product information from the e-commerce site. Using Playwright's powerful selectors and data extraction methods, we'll modify our code to extract product names, prices, and URLs from the page. ```python Python theme={"dark"} # pip install playwright import asyncio from playwright.async_api import async_playwright # scraping browser connection URL connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def scraper(url): async with async_playwright() as p: # connect to the scraping browser browser = await p.chromium.connect_over_cdp(connection_url) page = await browser.new_page() try: await page.goto(url) # extract the desired data await page.wait_for_selector(".product") products = await page.query_selector_all(".product") data = [] for product in products: name = await product.query_selector(".product-name") price = await product.query_selector(".price") product_url = await product.query_selector(".woocommerce-LoopProduct-link") data.append({ "name": await name.text_content() or "", "price": await price.text_content() or "", "productURL": await product_url.get_attribute("href") or "", }) return data except Exception as error: return error finally: await page.close() await browser.close() if __name__ == "__main__": url = "https://www.scrapingcourse.com/ecommerce/" products = asyncio.run(scraper(url)) print(products) ``` ```javascript Node.js theme={"dark"} // npm install playwright const { chromium } = require('playwright'); // connection URL const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const scraper = async (url) => { // connect to the scraping browser const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); try { await page.goto(url); await page.waitForSelector('.product'); // extract the desired data const data = await page.$$eval('.product', (products) => products.map((product) => ({ name: product.querySelector('.product-name')?.textContent.trim() || '', price: product.querySelector('.price')?.textContent.trim() || '', productURL: product.querySelector('.woocommerce-LoopProduct-link')?.href || '', })) ); return data; } finally { await page.close(); await browser.close(); } }; // execute the scraper function (async () => { const url = 'https://www.scrapingcourse.com/ecommerce/'; const products = await scraper(url); console.log(products); })(); ``` ### Run Your Application Execute your script to test the scraping functionality: ```bash Python theme={"dark"} python scraper.py ``` ```bash Node.js theme={"dark"} node scraper.js ``` **Example Output** The script will extract and display product information: ```json theme={"dark"} [ { "name": "Abominable Hoodie", "price": "$69.00", "productURL": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/" }, { "name": "Artemis Running Short", "price": "$45.00", "productURL": "https://www.scrapingcourse.com/ecommerce/product/artemis-running-short/" } // ... more products ] ``` Congratulations! 🎉 You've successfully built a real-world scraping scenario with Playwright and the Zenrows Browser Sessions. ## 4. Alternative: Using the Zenrows Browser SDK For a more streamlined development experience, you can use the Zenrows Browser SDK instead of managing WebSocket URLs manually. The SDK simplifies connection management and provides additional utilities. The Zenrows Browser SDK is currently only available for JavaScript. For more details, see the GitHub Repository. ### Install the SDK ```bash Node.js theme={"dark"} npm install @zenrows/browser-sdk ``` ### Quick Migration from WebSocket URL If you have existing Playwright code using the WebSocket connection, migrating to the SDK requires minimal changes: **Before (WebSocket URL):** ```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const browser = await chromium.connectOverCDP(connectionURL); ``` **After (SDK):** ```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); const { ScrapingBrowser } = require('@zenrows/browser-sdk'); const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); const connectionURL = scrapingBrowser.getConnectURL(); const browser = await chromium.connectOverCDP(connectionURL); ``` ### Complete Example with SDK ```javascript Node.js theme={"dark"} // npm install @zenrows/browser-sdk playwright const { chromium } = require('playwright'); const { ScrapingBrowser } = require('@zenrows/browser-sdk'); const scraper = async () => { // Initialize SDK const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); const connectionURL = scrapingBrowser.getConnectURL(); const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); await page.goto('https://www.scrapingcourse.com/ecommerce/'); console.log(await page.title()); await browser.close(); }; scraper(); ``` ### SDK Benefits * **Simplified configuration:** No need to manually construct WebSocket URLs * **Better error handling:** Built-in error messages and debugging information * **Future-proof:** Automatic updates to connection protocols and endpoints * **Additional utilities:** Access to helper methods and advanced configuration options The SDK is particularly useful for production environments where you want cleaner code organization and better error handling. ## How Playwright with Browser Sessions Helps Combining Playwright with Zenrows' Browser Sessions provides powerful advantages for web scraping: ### Key Benefits * **Cloud-based browser instances:** Run Playwright scripts on remote Chrome instances, freeing up local resources for other tasks. * **Seamless integration:** Connect your existing Playwright code to Zenrows with just a WebSocket URL change - no complex setup required. * **Advanced automation:** Use Playwright's full feature set, which includes page interactions, form submissions, file uploads, and complex user workflows. * **Built-in anti-detection:** Benefit from residential proxy rotation and genuine browser fingerprints automatically. * **Cross-browser support:** While we use Chromium for optimal compatibility, Playwright's API remains consistent across different browser engines. * **High concurrency:** Scale your Playwright scripts with up to 400 concurrent browser instances on Scale, more on Enterprise. * **Reliable execution:** Cloud infrastructure ensures consistent performance without local browser management overhead. ## Troubleshooting Below are common issues you might encounter when using Playwright with Browser Sessions: If you receive a Connection Refused error, it might be due to: * **API Key Issues:** Verify that you're using the correct API key. * **Network Issues:** Check your internet connection and firewall settings. * **WebSocket Endpoint:** Ensure that the WebSocket URL (`wss://browser.zenrows.com`) is correct. * Use `page.waitForSelector()` to ensure elements load before extraction * Increase timeout values for slow-loading pages ```javascript scraper.js theme={"dark"} await page.goto('https://example.com', { timeout: 60000 }); // 60 seconds ``` * Verify CSS selectors are correct using browser developer tools * Add `page.waitForLoadState('networkidle')` for dynamic content * Use existing context when available: `browser.contexts[0] if browser.contexts else await browser.new_context()` * Properly close pages and browsers to prevent resource leaks * Handle exceptions properly to ensure cleanup occurs Although Zenrows rotates IPs, some websites may block them based on location. Try adjusting the region or country settings. For more information, check our Browser Sessions [Region Documentation](https://docs.zenrows.com/browser-sessions/features/world-region) and [Country Documentation](https://docs.zenrows.com/browser-sessions/features/country). Our support team is available to assist you if issues persist despite following these solutions. Use the [Browser Sessions dashboard](https://app.zenrows.com/browser) or email us for personalized help from Zenrows experts. ## Next Steps You now have a solid foundation for Playwright-based web scraping with Zenrows. Here are some recommended next steps: * **[Practical Use Cases](/browser-sessions/help/practical-use-cases)**:
Learn common scraping patterns, including screenshots, custom JavaScript execution, and form handling. * **[Complete Browser Sessions Documentation](/browser-sessions/introduction)**:
Explore all available features and advanced configuration options for Browser Sessions. * **[Playwright Web Scraping Guide](https://www.zenrows.com/blog?q=playwright)**:
Dive deeper into Playwright techniques for sophisticated scraping scenarios. * **[Pricing and Plans](/first-steps/pricing)**:
Understand how browser usage is calculated and choose the plan that fits your scraping volume. ## Frequently Asked Questions (FAQ) Yes, Zenrows Browser Sessions is compatible with Puppeteer as well. The integration is similar, requiring only a change in the connection method to utilize our WebSocket endpoint. For detailed instructions, refer to our [Puppeteer Integration article](/browser-sessions/get-started/puppeteer). No, Zenrows Browser Sessions handles proxy configuration and IP rotation automatically. You don't need to set up proxies manually. Currently, Zenrows Browser Sessions does not support CAPTCHA solving. For handling CAPTCHAs, you may need to use third-party services. Consider using our [Fetch](/fetch/api-reference) for additional features like CAPTCHA solving, advanced anti-bot bypass, and more. Yes! Browser Sessions supports the full Playwright API. You can use page interactions, screenshots, PDF generation, network interception, and all other Playwright features seamlessly. You can create multiple pages within the same browser context using `await browser.newPage()` or `await context.newPage()`. Each page operates independently while sharing the same browser session. Absolutely! Playwright's `expect()` assertions, `waitForSelector()`, and other built-in retry mechanisms work perfectly with Browser Sessions. These features help handle dynamic content and improve scraping reliability. Use Playwright's standard screenshot methods: ```python theme={"dark"} await page.screenshot(path='screenshot.png') ``` The screenshot will be saved locally while the browser runs in the cloud. Yes! You can intercept requests, modify responses, and monitor network traffic using Playwright's `page.route()` and `page.on('request')` methods with Browser Sessions. The main difference is that the browser runs in Zenrows' cloud infrastructure instead of locally. This provides better IP rotation, fingerprint management, and resource efficiency while maintaining the exact same Playwright API. File downloads work with Playwright's standard download handling. Use `page.waitForDownload()` and the download will be transferred from the cloud browser to your local environment automatically. # Integrating Zenrows Browser Sessions with Puppeteer Source: https://docs.zenrows.com/browser-sessions/get-started/puppeteer Step-by-step tutorial for integrating Zenrows Browser Sessions with Puppeteer in Node.js and Pyppeteer in Python for web scraping. Discover how to scrape data from any website using Zenrows' Browser Sessions (formerly, Scraping Browser) with Puppeteer. This comprehensive guide demonstrates how to create your first browser automation request capable of handling JavaScript-heavy sites and bypassing sophisticated anti-bot measures. Zenrows' Browser Sessions offers cloud-hosted Chrome instances that integrate seamlessly with Puppeteer's automation framework. From scraping dynamic content to performing complex browser interactions, you can build robust scraping solutions in minutes using Puppeteer's intuitive API. ## 1. Set Up Your Project ### Set Up Your Development Environment Ensure you have the necessary development tools and Puppeteer installed before starting. Browser Sessions supports both Node.js Puppeteer and Python Pyppeteer implementations. We recommend using the latest stable versions to ensure optimal compatibility and access to the newest features. Node.js 18+ installed (latest LTS version recommended). Consider using an IDE like Visual Studio Code or WebStorm for enhanced development experience. ```bash theme={"dark"} # Install Node.js (if not already installed) # Visit https://nodejs.org/ or use package managers: # macOS (using Homebrew) brew install node # Ubuntu/Debian (using NodeSource) curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - sudo apt-get install -y nodejs # Windows (using Chocolatey) choco install nodejs # Install Puppeteer Core npm install puppeteer-core ``` Need help with your setup? Check out our comprehensive [Puppeteer web scraping guide](https://www.zenrows.com/blog/puppeteer-web-scraping) Python 3+ installed (latest stable version recommended). IDEs like PyCharm or Visual Studio Code with Python extensions provide excellent development support. ```bash theme={"dark"} # Install Python (if not already installed) # Visit https://www.python.org/downloads/ or use package managers: # macOS (using Homebrew) brew install python # Ubuntu/Debian sudo apt update && sudo apt install python3 python3-pip # Windows (using Chocolatey) choco install python # Install Pyppeteer pip install pyppeteer ``` For detailed setup instructions, refer to our [Pyppeteer scraping tutorial](https://www.zenrows.com/blog/pyppeteer) ### Get Your API Key and Connection URL [Create a Free Account](https://app.zenrows.com/register?prod=scraping_browser) with Zenrows and retrieve your API key from the [Browser Sessions Dashboard](https://app.zenrows.com/browser). This key authenticates your WebSocket connection to our cloud browsers. ## 2. Make Your First Request Begin with a basic request to familiarize yourself with how Puppeteer connects to Browser Sessions. We'll target the E-commerce Challenge page to demonstrate browser connection and title extraction. ```javascript Node.js theme={"dark"} // npm install puppeteer-core const puppeteer = require('puppeteer-core'); // scraping browser connection URL const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const scraper = async () => { // connect to the scraping browser const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); await page.goto('https://www.scrapingcourse.com/ecommerce/'); console.log(await page.title()); await browser.close(); }; scraper(); ``` ```python Python theme={"dark"} # pip install pyppeteer import asyncio from pyppeteer import connect # scraping browser connection URL connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def scraper(): # connect to the scraping browser browser = await connect(browserWSEndpoint=connection_url) page = await browser.newPage() await page.goto('https://www.scrapingcourse.com/ecommerce/') print(await page.title()) await page.close() await browser.disconnect() if __name__ == "__main__": asyncio.run(scraper()) ``` Replace `YOUR_ZENROWS_API_KEY` with your actual API key and execute the script: ```bash Node.js theme={"dark"} node scraper.js ``` ```bash Python theme={"dark"} python scraper.py ``` **Expected Output:** Your script will display the page title: ```plaintext theme={"dark"} ScrapingCourse.com E-commerce Challenge ``` Excellent! You've successfully completed your first web scraping request using Zenrows Browser Sessions with Puppeteer. ## 3. Build a Real-World Scraping Scenario Now let's advance to a comprehensive scraping example by extracting product data from the e-commerce site. We'll enhance our code to collect product names, prices, and URLs using Puppeteer's robust element selection and data extraction capabilities. ```javascript Node.js theme={"dark"} // npm install puppeteer-core const puppeteer = require('puppeteer-core'); // scraping browser connection URL const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const scraper = async (url) => { // connect to the scraping browser const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL, }); const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'networkidle2' }); await page.waitForSelector('.product'); // extract the desired data const data = await page.$$eval('.product', (products) => products.map((product) => ({ name: product.querySelector('.product-name')?.textContent.trim() || '', price: product.querySelector('.price')?.textContent.trim() || '', productURL: product.querySelector('.woocommerce-LoopProduct-link')?.href || '', })) ); return data; } finally { await page.close(); await browser.close(); } }; // execute the scraper function (async () => { const url = 'https://www.scrapingcourse.com/ecommerce/'; const products = await scraper(url); console.log(products); })(); ``` ```python Python theme={"dark"} # pip install pyppeteer import asyncio from pyppeteer import connect # scraping browser connection URL connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def scraper(url): # connect to the scraping browser browser = await connect(browserWSEndpoint=connection_url) page = await browser.newPage() try: await page.goto(url, {"waitUntil": "networkidle2"}) await page.waitForSelector(".product") # extract the desired data product_elements = await page.querySelectorAll(".product") products = [] for product in product_elements: name_el = await product.querySelector(".product-name") name = await page.evaluate("(el) => el.textContent.trim()", name_el) if name_el else "" price_el = await product.querySelector(".price") price = await page.evaluate("(el) => el.textContent.trim()", price_el) if price_el else "" url_el = await product.querySelector(".woocommerce-LoopProduct-link") product_url = await page.evaluate("(el) => el.href", url_el) if url_el else "" products.append({ "name": name, "price": price, "productURL": product_url, }) return products finally: await page.close() await browser.disconnect() # execute the scraper function if __name__ == "__main__": url = "https://www.scrapingcourse.com/ecommerce/" products = asyncio.run(scraper(url)) print(products) ``` ### Run Your Application Launch your script to verify the scraping functionality: ```bash Node.js theme={"dark"} node scraper.js ``` ```bash Python theme={"dark"} python scraper.py ``` **Example Output:** Your script will collect and display the product information: ```json theme={"dark"} [ { "name": "Abominable Hoodie", "price": "$69.00", "productURL": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/" }, { "name": "Artemis Running Short", "price": "$45.00", "productURL": "https://www.scrapingcourse.com/ecommerce/product/artemis-running-short/" } // ... additional products ] ``` Outstanding! 🎉 You've successfully implemented a production-ready scraping solution using Puppeteer and the Zenrows Browser Sessions. ## 4. Alternative: Using the Zenrows Browser SDK For enhanced developer experience, consider using the Zenrows Browser SDK rather than manually managing WebSocket URLs. The SDK streamlines connection handling and offers additional development utilities. The Zenrows Browser SDK is currently only available for JavaScript. For more details, see the GitHub Repository. ### Install the SDK ```bash Node.js theme={"dark"} npm install @zenrows/browser-sdk ``` ### Quick Migration from WebSocket URL Transitioning from direct WebSocket connections to the SDK requires minimal code changes: **Before (WebSocket URL):** ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); ``` **After (SDK):** ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const { ScrapingBrowser } = require('@zenrows/browser-sdk'); const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); const connectionURL = scrapingBrowser.getConnectURL(); const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); ``` ### Complete Example with SDK ```javascript Node.js theme={"dark"} // npm install @zenrows/browser-sdk puppeteer-core const puppeteer = require('puppeteer-core'); const { ScrapingBrowser } = require('@zenrows/browser-sdk'); const scraper = async () => { // Initialize SDK const scrapingBrowser = new ScrapingBrowser({ apiKey: 'YOUR_ZENROWS_API_KEY' }); const connectionURL = scrapingBrowser.getConnectURL(); const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); await page.goto('https://www.scrapingcourse.com/ecommerce/'); console.log(await page.title()); await browser.close(); }; scraper(); ``` ### SDK Benefits * **Streamlined configuration:** Eliminates manual WebSocket URL construction * **Enhanced error handling:** Provides detailed error messages and debugging capabilities * **Future-proof architecture:** Automatic updates to connection protocols and endpoints * **Extended utilities:** Access to helper functions and advanced configuration options The SDK proves especially valuable in production environments where code maintainability and robust error handling are priorities. ## How Puppeteer with Browser Sessions Helps Integrating Puppeteer with Zenrows' Browser Sessions delivers significant advantages for web automation: ### Key Benefits * **Cloud-hosted browser instances:** Execute Puppeteer scripts on remote Chrome browsers, preserving local system resources for other applications. * **Drop-in replacement:** Transform existing Puppeteer code to use Zenrows by simply changing the connection method - no architectural changes required. * **Full automation capabilities:** Leverage Puppeteer's complete feature set including form interactions, file handling, network monitoring, and custom JavaScript execution. * **Automatic anti-detection:** Benefit from built-in residential proxy rotation and authentic browser fingerprints without additional configuration. * **Proven reliability:** Cloud infrastructure delivers consistent performance without the complexity of local browser management. * **Massive scalability:** Execute up to 400 concurrent browser instances on Scale, more on Enterprise. * **Network optimization:** Reduced latency and improved success rates through globally distributed infrastructure. ## Troubleshooting Common challenges when integrating Puppeteer with Browser Sessions and their solutions: If you encounter Connection Refused errors, verify these potential causes: * **API Key Validation:** Confirm you're using the correct API key from your dashboard. * **Network Connectivity:** Check your internet connection and firewall configurations. * **WebSocket Endpoint:** Ensure the WebSocket URL (`wss://browser.zenrows.com`) is properly formatted. * Use `page.waitForSelector()` to ensure elements are available before interaction * Extend timeout values for slow-loading websites ```javascript Node.js theme={"dark"} await page.goto('https://example.com', { timeout: 60000 }); // 60 seconds ``` * Validate CSS selectors using browser developer tools * Implement `waitUntil: 'networkidle2'` for dynamic content loading * Handle navigation exceptions with proper try-catch blocks * Ensure proper browser and page cleanup to prevent memory leaks * Use `page.waitForNavigation()` for multi-step workflows While Zenrows automatically rotates IP addresses, some websites implement location-based blocking. Consider adjusting regional settings for better access. Learn more about geographic targeting in our [Region Documentation](https://docs.zenrows.com/browser-sessions/features/world-region) and [Country Configuration](https://docs.zenrows.com/browser-sessions/features/country). If challenges persist after implementing these solutions, our technical support team is ready to assist. Access help through the [Browser Sessions dashboard](https://app.zenrows.com/browser) or contact our support team for expert guidance. ## Next Steps You've established a strong foundation for Puppeteer-based web scraping with Zenrows. Continue your journey with these resources: * **[Practical Use Cases](/browser-sessions/help/practical-use-cases)**:
Explore common automation patterns including screenshot capture, custom JavaScript execution, and form interactions. * **[Complete Browser Sessions Documentation](/browser-sessions/introduction)**:
Discover all available features and configuration options for Browser Sessions platform. * **[Puppeteer Web Scraping Guide](https://www.zenrows.com/blog?q=puppeteer)**:
Explore advanced Puppeteer techniques for complex scraping challenges. * **[Pricing and Plans](/first-steps/pricing)**:
Learn about browser usage calculations and select the optimal plan for your requirements. ## Frequently Asked Questions (FAQ) Absolutely! Zenrows Browser Sessions supports both Puppeteer and Playwright automation frameworks. The integration process is similar, requiring only connection method adjustments. For comprehensive instructions, see our [Playwright Integration guide](/browser-sessions/get-started/playwright). No manual proxy configuration is required. Zenrows Browser Sessions automatically handles proxy management and IP rotation behind the scenes. Currently, Zenrows Browser Sessions doesn't include built-in CAPTCHA solving capabilities. For CAPTCHA handling, consider integrating third-party CAPTCHA solving services. Explore our [Fetch](/fetch/api-reference) for additional features including CAPTCHA solving and advanced anti-bot bypass mechanisms. Yes! Browser Sessions provides full access to Puppeteer's API, including page manipulation, screenshot generation, PDF creation, network interception, and all other native features. Create additional pages using `await browser.newPage()` within the same browser instance. Each page operates independently while sharing the browser session and resources. Certainly! Puppeteer's `waitForSelector()`, `waitForNavigation()`, and other waiting functions work seamlessly with Browser Sessions, helping ensure reliable data extraction from dynamic content. Use Puppeteer's standard screenshot functionality: ```javascript theme={"dark"} await page.screenshot({ path: 'screenshot.png' }); ``` Screenshots are captured from the cloud browser and saved to your local environment automatically. Yes! Puppeteer's network monitoring capabilities, including `page.on('request')` and `page.on('response')` event handlers, function normally with Browser Sessions. The primary distinction is execution location: browsers run in Zenrows' cloud infrastructure rather than locally. This provides superior IP management, fingerprint diversity, and resource efficiency while maintaining identical Puppeteer API functionality. File downloads work through Puppeteer's standard download handling mechanisms. Files are downloaded to the cloud browser and then transferred to your local environment automatically. # Browser Sessions Practical Use Cases Source: https://docs.zenrows.com/browser-sessions/help/practical-use-cases Practical Browser Sessions examples including screenshots, form filling, infinite scroll, network interception, and JavaScript execution. Discover common automation patterns and real-world scenarios when using Zenrows' Browser Sessions (formerly, Scraping Browser) with Puppeteer and Playwright. These practical examples demonstrate how to leverage browser automation for various data extraction and interaction tasks. Browser Sessions excels at handling complex scenarios that traditional HTTP-based scraping cannot address. From capturing visual content to executing custom JavaScript, these use cases showcase the full potential of browser-based automation for your scraping projects. **Websites often change their structure or update CSS class names and HTML tags.** This means the selectors you use for scraping (like `.product`, `.products`, or specific element tags) might stop working if the site layout changes. To keep your scraper reliable, regularly check and update your selectors as needed. ## Navigation and Page Content Extraction Extract complete page content and metadata by navigating to target websites. This fundamental pattern forms the foundation for most scraping workflows and demonstrates how to retrieve both visible content and underlying HTML structure. ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const scraper = async () => { const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/', { waitUntil: 'domcontentloaded' }); // Extract page metadata const title = await page.title(); console.log('Page title:', title); // Get complete HTML content console.log('Extracting page content...'); const html = await page.content(); // Extract specific elements const productCount = await page.$$eval('.product', products => products.length); console.log(`Found ${productCount} products on the page`); // Extract text content from specific elements const headings = await page.$$eval('h1, h2, h3', elements => elements.map(el => el.textContent.trim()) ); console.log('Page headings:', headings); return { title, productCount, headings, htmlLength: html.length }; } finally { await browser.close(); } }; scraper().then(result => console.log('Extraction complete:', result)); ``` ```python Python theme={"dark"} # pip install pyppeteer import asyncio from pyppeteer import connect connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def scraper(): browser = await connect(browserWSEndpoint=connection_url) page = await browser.newPage() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/', { 'waitUntil': 'domcontentloaded' }) # Extract page metadata title = await page.title() print(f'Page title: {title}') # Get complete HTML content print('Extracting page content...') html = await page.content() # Extract specific elements product_count = await page.querySelectorAllEval('.product', 'products => products.length') print(f'Found {product_count} products on the page') # Extract text content from specific elements headings = await page.querySelectorAllEval('h1, h2, h3', '''elements => elements.map(el => el.textContent.trim()) ''') print(f'Page headings: {headings}') return { 'title': title, 'product_count': product_count, 'headings': headings, 'html_length': len(html) } finally: await page.close() await browser.disconnect() if __name__ == "__main__": result = asyncio.run(scraper()) print('Extraction complete:', result) ``` ```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const scraper = async () => { const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/'); // Extract page metadata const title = await page.title(); console.log('Page title:', title); // Get complete HTML content console.log('Extracting page content...'); const html = await page.content(); // Extract specific elements const products = await page.locator('.product').count(); console.log(`Found ${products} products on the page`); // Extract text content from headings const headings = await page.locator('h1, h2, h3').allTextContents(); console.log('Page headings:', headings); return { title, productCount: products, headings, htmlLength: html.length }; } finally { await browser.close(); } }; scraper().then(result => console.log('Extraction complete:', result)); ``` ```python Python theme={"dark"} import asyncio from playwright.async_api import async_playwright connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def scraper(): async with async_playwright() as p: browser = await p.chromium.connect_over_cdp(connection_url) page = await browser.new_page() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/') # Extract page metadata title = await page.title() print(f'Page title: {title}') # Get complete HTML content print('Extracting page content...') html = await page.content() # Extract specific elements products = await page.locator('.product').count() print(f'Found {products} products on the page') # Extract text content from headings headings = await page.locator('h1, h2, h3').all_text_contents() print(f'Page headings: {headings}') return { 'title': title, 'product_count': products, 'headings': headings, 'html_length': len(html) } finally: await browser.close() if __name__ == "__main__": result = asyncio.run(scraper()) print('Extraction complete:', result) ``` ### Key Benefits * **Complete content access:** Retrieve both rendered content and raw HTML source * **Metadata extraction:** Access page titles, descriptions, and other document properties * **Element counting:** Quickly assess page structure and content volume * **Structured data collection:** Extract specific elements using CSS selectors ## Taking Screenshots Capture visual representations of web pages for monitoring, documentation, or visual verification purposes. Screenshots prove invaluable for debugging scraping workflows and creating visual records of dynamic content. ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const screenshotScraper = async () => { const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/', { waitUntil: 'domcontentloaded' }); console.log('Page loaded:', await page.title()); // Take full page screenshot console.log('Capturing full page screenshot...'); await page.screenshot({ path: 'full-page-screenshot.png', fullPage: true }); // Take viewport screenshot (uses default 1920x1080 viewport) console.log('Capturing viewport screenshot...'); await page.screenshot({ path: 'viewport-screenshot.png' }); // Take screenshot of specific element console.log('Capturing product grid screenshot...'); const productGrid = await page.$('.products'); if (productGrid) { await productGrid.screenshot({ path: 'product-grid-screenshot.png' }); } // Take screenshot with custom clipping (alternative to viewport resizing) console.log('Capturing custom-sized screenshot...'); await page.screenshot({ path: 'custom-size-screenshot.png', type: 'jpeg', quality: 100, clip: { x: 0, y: 0, width: 1200, height: 800 } }); console.log('All screenshots saved successfully'); } finally { await browser.close(); } }; screenshotScraper(); ``` ```python Python theme={"dark"} # pip install pyppeteer import asyncio from pyppeteer import connect connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def screenshot_scraper(): browser = await connect(browserWSEndpoint=connection_url) page = await browser.newPage() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/', { 'waitUntil': 'domcontentloaded' }) print(f'Page loaded: {await page.title()}') # Take full page screenshot print('Capturing full page screenshot...') await page.screenshot({ 'path': 'full-page-screenshot.png', 'fullPage': True }) # Take viewport screenshot (uses default 1920x1080 viewport) print('Capturing viewport screenshot...') await page.screenshot({ 'path': 'viewport-screenshot.png' }) # Take screenshot of specific element print('Capturing product grid screenshot...') product_grid = await page.querySelector('.products') if product_grid: await product_grid.screenshot({ 'path': 'product-grid-screenshot.png' }) # Take high-quality JPEG screenshot print('Capturing high-quality JPEG screenshot...') await page.screenshot({ 'path': 'high-quality-screenshot.jpg', 'type': 'jpeg', 'quality': 90, 'fullPage': True }) # Take screenshot with custom clipping (alternative to viewport resizing) print('Capturing custom-sized screenshot...') await page.screenshot({ 'path': 'custom-size-screenshot.png', 'type': 'png', 'fullPage': True, 'clip': {'x': 0, 'y': 0, 'width': 1200, 'height': 800} }) print('All screenshots saved successfully') finally: await page.close() await browser.disconnect() if __name__ == "__main__": asyncio.run(screenshot_scraper()) ``` ```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const screenshotScraper = async () => { const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/', { waitUntil: 'domcontentloaded' }); // Wait for page to be fully loaded await page.waitForSelector('body', { timeout: 10000 }); console.log('Page loaded:', await page.title()); // Take full page screenshot console.log('Capturing full page screenshot...'); await page.screenshot({ path: 'full-page-screenshot.png', fullPage: true }); // Take viewport screenshot (uses default 1920x1080 viewport) console.log('Capturing viewport screenshot...'); await page.screenshot({ path: 'viewport-screenshot.png' }); // Take screenshot of specific element (with error handling) console.log('Capturing product grid screenshot...'); try { await page.waitForSelector('.products', { timeout: 5000 }); await page.locator('.products').screenshot({ path: 'product-grid-screenshot.png' }); } catch (error) { console.log('Product grid not found, skipping element screenshot'); } // Take high-quality JPEG screenshot console.log('Capturing high-quality JPEG screenshot...'); await page.screenshot({ path: 'high-quality-screenshot.jpg', type: 'jpeg', quality: 90, fullPage: true }); // Take screenshot with custom clipping (alternative to viewport resizing) console.log('Capturing custom-sized screenshot...'); await page.screenshot({ path: 'custom-size-screenshot.png', type: 'png', clip: { x: 0, y: 0, width: 1200, height: 800 } }); console.log('All screenshots saved successfully'); } catch (error) { console.error('Screenshot scraper error:', error.message); // Take emergency screenshot if navigation fails try { await page.screenshot({ path: 'error-screenshot.png' }); console.log('Emergency screenshot saved'); } catch (screenshotError) { console.error('Could not take emergency screenshot:', screenshotError.message); } } finally { await browser.close(); } }; screenshotScraper(); ``` ```python Python theme={"dark"} import asyncio from playwright.async_api import async_playwright connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def screenshot_scraper(): async with async_playwright() as p: browser = await p.chromium.connect_over_cdp(connection_url) page = await browser.new_page() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/', wait_until='domcontentloaded' ) # Wait for page to be fully loaded await page.wait_for_selector('body', timeout=10000) print(f'Page loaded: {await page.title()}') # Take full page screenshot print('Capturing full page screenshot...') await page.screenshot(path='full-page-screenshot.png', full_page=True) # Take viewport screenshot (uses default 1920x1080 viewport) print('Capturing viewport screenshot...') await page.screenshot(path='viewport-screenshot.png') # Take screenshot of specific element (with error handling) print('Capturing product grid screenshot...') try: await page.wait_for_selector('.products', timeout=5000) await page.locator('.products').screenshot(path='product-grid-screenshot.png') except Exception: print('Product grid not found, skipping element screenshot') # Take high-quality JPEG screenshot print('Capturing high-quality JPEG screenshot...') await page.screenshot( path='high-quality-screenshot.jpg', type='jpeg', quality=90, full_page=True ) # Take screenshot with custom clipping (alternative to viewport resizing) print('Capturing custom-sized screenshot...') await page.screenshot( path='custom-size-screenshot.png', type='png', clip={'x': 0, 'y': 0, 'width': 1200, 'height': 800} ) print('All screenshots saved successfully') except Exception as error: print(f'Screenshot scraper error: {error}') # Take emergency screenshot if navigation fails try: await page.screenshot(path='error-screenshot.png') print('Emergency screenshot saved') except Exception as screenshot_error: print(f'Could not take emergency screenshot: {screenshot_error}') finally: await browser.close() if __name__ == "__main__": asyncio.run(screenshot_scraper()) ``` ### Screenshot Options * **Full page capture:** Include content below the fold with `fullPage: true` * **Element-specific screenshots:** Target individual components or sections * **Custom clipping:** Focus on specific page areas using coordinate-based clipping * **Format options:** PNG (lossless) or JPEG (with quality control from 0-100) * **Default viewport:** Screenshots use the standard 1920x1080 viewport size Screenshots are captured from the cloud browser and automatically transferred to your local environment. Large full-page screenshots may take additional time to process and download. ## Running Custom JavaScript Code Execute custom JavaScript within the browser context to manipulate pages, extract computed values, or perform complex data transformations. This powerful capability enables sophisticated automation scenarios beyond standard element selection. ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const customJavaScriptScraper = async () => { const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/', { waitUntil: 'domcontentloaded' }); // Extract page title using custom JavaScript const pageTitle = await page.evaluate(() => { return document.title; }); console.log('Page title via JavaScript:', pageTitle); // Get page statistics const pageStats = await page.evaluate(() => { return { totalLinks: document.querySelectorAll('a').length, totalImages: document.querySelectorAll('img').length, totalForms: document.querySelectorAll('form').length, pageHeight: document.body.scrollHeight, viewportHeight: window.innerHeight, currentURL: window.location.href, userAgent: navigator.userAgent }; }); console.log('Page statistics:', pageStats); // Extract product data with custom processing const productData = await page.evaluate(() => { const products = Array.from(document.querySelectorAll('.product')); return products.map((product, index) => { const name = product.querySelector('.product-name')?.textContent?.trim(); const priceText = product.querySelector('.price')?.textContent?.trim(); // Custom price processing const priceMatch = priceText?.match(/\$(\d+(?:\.\d{2})?)/); const priceNumber = priceMatch ? parseFloat(priceMatch[1]) : null; return { id: index + 1, name: name || 'Unknown Product', originalPrice: priceText || 'Price not available', numericPrice: priceNumber, isOnSale: product.querySelector('.sale-badge') !== null, position: index + 1 }; }); }); console.log('Processed product data:', productData); // Scroll and capture dynamic content const scrollResults = await page.evaluate(async () => { // Scroll to bottom of page window.scrollTo(0, document.body.scrollHeight); // Wait for any lazy-loaded content await new Promise(resolve => setTimeout(resolve, 2000)); return { finalScrollPosition: window.pageYOffset, totalHeight: document.body.scrollHeight, lazyImagesLoaded: document.querySelectorAll('img[data-src]').length }; }); console.log('Scroll results:', scrollResults); // Inject custom CSS and modify page appearance await page.evaluate(() => { const style = document.createElement('style'); style.textContent = ` .product { border: 2px solid red !important; } .price { background-color: yellow !important; } `; document.head.appendChild(style); }); console.log('Custom styles applied'); } finally { await browser.close(); } }; customJavaScriptScraper(); ``` ```python Python theme={"dark"} # pip install pyppeteer import asyncio from pyppeteer import connect connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def custom_javascript_scraper(): browser = await connect(browserWSEndpoint=connection_url) page = await browser.newPage() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/', { 'waitUntil': 'domcontentloaded', 'timeout': 60000 }) # Extract page title using custom JavaScript page_title = await page.evaluate('() => document.title') print(f'Page title via JavaScript: {page_title}') # Get page statistics page_stats = await page.evaluate('''() => ({ totalLinks: document.querySelectorAll('a').length, totalImages: document.querySelectorAll('img').length, totalForms: document.querySelectorAll('form').length, pageHeight: document.body.scrollHeight, viewportHeight: window.innerHeight, currentURL: window.location.href, userAgent: navigator.userAgent })''') print(f'Page statistics: {page_stats}') # Extract and process product data product_data = await page.evaluate('''() => { const products = Array.from(document.querySelectorAll('.product')); return products.map((product, index) => { const name = product.querySelector('.product-name')?.textContent?.trim(); const priceText = product.querySelector('.price')?.textContent?.trim(); // Custom price processing const priceMatch = priceText?.match(/\\$([0-9]+(?:\\.[0-9]{2})?)/); const priceNumber = priceMatch ? parseFloat(priceMatch[1]) : null; return { id: index + 1, name: name || 'Unknown Product', originalPrice: priceText || 'Price not available', numericPrice: priceNumber, isOnSale: product.querySelector('.sale-badge') !== null, position: index + 1 }; }); }''') print(f'Processed product data: {product_data}') # Scroll and capture dynamic content scroll_results = await page.evaluate('''async () => { // Scroll to bottom of page window.scrollTo(0, document.body.scrollHeight); // Wait for any lazy-loaded content await new Promise(resolve => setTimeout(resolve, 2000)); return { finalScrollPosition: window.pageYOffset, totalHeight: document.body.scrollHeight, lazyImagesLoaded: document.querySelectorAll('img[data-src]').length }; }''') print(f'Scroll results: {scroll_results}') # Inject custom CSS and modify page appearance await page.evaluate('''() => { const style = document.createElement('style'); style.textContent = ` .product { border: 2px solid red !important; } .price { background-color: yellow !important; } `; document.head.appendChild(style); }''') print('Custom styles applied') except Exception as error: print(f'Custom JavaScript scraper error: {error}') finally: await page.close() await browser.disconnect() if __name__ == "__main__": asyncio.run(custom_javascript_scraper()) ``` ```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const customJavaScriptScraper = async () => { const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/'); // Extract page title using custom JavaScript const pageTitle = await page.evaluate(() => document.title); console.log('Page title via JavaScript:', pageTitle); // Get page statistics const pageStats = await page.evaluate(() => ({ totalLinks: document.querySelectorAll('a').length, totalImages: document.querySelectorAll('img').length, totalForms: document.querySelectorAll('form').length, pageHeight: document.body.scrollHeight, viewportHeight: window.innerHeight, currentURL: window.location.href, userAgent: navigator.userAgent })); console.log('Page statistics:', pageStats); // Extract and process product data const productData = await page.evaluate(() => { const products = Array.from(document.querySelectorAll('.product')); return products.map((product, index) => { const name = product.querySelector('.product-name')?.textContent?.trim(); const priceText = product.querySelector('.price')?.textContent?.trim(); // Custom price processing const priceMatch = priceText?.match(/\$(\d+(?:\.\d{2})?)/); const priceNumber = priceMatch ? parseFloat(priceMatch[1]) : null; return { id: index + 1, name: name || 'Unknown Product', originalPrice: priceText || 'Price not available', numericPrice: priceNumber, isOnSale: product.querySelector('.sale-badge') !== null, position: index + 1 }; }); }); console.log('Processed product data:', productData); // Scroll and capture dynamic content await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(2000); const scrollResults = await page.evaluate(() => ({ finalScrollPosition: window.pageYOffset, totalHeight: document.body.scrollHeight, lazyImagesLoaded: document.querySelectorAll('img[data-src]').length })); console.log('Scroll results:', scrollResults); // Inject custom CSS await page.addStyleTag({ content: ` .product { border: 2px solid red !important; } .price { background-color: yellow !important; } ` }); console.log('Custom styles applied'); } finally { await browser.close(); } }; customJavaScriptScraper(); ``` ```python Python theme={"dark"} import asyncio from playwright.async_api import async_playwright connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def custom_javascript_scraper(): async with async_playwright() as p: browser = await p.chromium.connect_over_cdp(connection_url) page = await browser.new_page() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/') # Extract page title using custom JavaScript page_title = await page.evaluate('() => document.title') print(f'Page title via JavaScript: {page_title}') # Get page statistics page_stats = await page.evaluate('''() => ({ totalLinks: document.querySelectorAll('a').length, totalImages: document.querySelectorAll('img').length, totalForms: document.querySelectorAll('form').length, pageHeight: document.body.scrollHeight, viewportHeight: window.innerHeight, currentURL: window.location.href, userAgent: navigator.userAgent })''') print(f'Page statistics: {page_stats}') # Extract and process product data product_data = await page.evaluate('''() => { const products = Array.from(document.querySelectorAll('.product')); return products.map((product, index) => { const name = product.querySelector('.product-name')?.textContent?.trim(); const priceText = product.querySelector('.price')?.textContent?.trim(); // Custom price processing const priceMatch = priceText?.match(/\\$([0-9]+(?:\\.[0-9]{2})?)/); const priceNumber = priceMatch ? parseFloat(priceMatch[1]) : null; return { id: index + 1, name: name || 'Unknown Product', originalPrice: priceText || 'Price not available', numericPrice: priceNumber, isOnSale: product.querySelector('.sale-badge') !== null, position: index + 1 }; }); }''') print(f'Processed product data: {product_data}') # Scroll and capture dynamic content await page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)') await page.wait_for_timeout(2000) scroll_results = await page.evaluate('''() => ({ finalScrollPosition: window.pageYOffset, totalHeight: document.body.scrollHeight, lazyImagesLoaded: document.querySelectorAll('img[data-src]').length })''') print(f'Scroll results: {scroll_results}') # Inject custom CSS await page.add_style_tag(content=''' .product { border: 2px solid red !important; } .price { background-color: yellow !important; } ''') print('Custom styles applied') finally: await browser.close() if __name__ == "__main__": asyncio.run(custom_javascript_scraper()) ``` ### JavaScript Execution Capabilities * **Data extraction and processing:** Transform raw data within the browser context * **Page statistics collection:** Gather comprehensive page metrics and analytics * **Dynamic content interaction:** Trigger JavaScript events and handle dynamic updates * **Custom styling injection:** Modify page appearance for testing or visual enhancement * **Scroll automation:** Navigate through infinite scroll or lazy-loaded content * **Complex calculations:** Perform mathematical operations on extracted data Custom JavaScript execution runs within the browser's security context, providing access to all DOM APIs and browser features available to the target website. ## PDF Generation and Document Export Generate PDF documents from web pages for archival, reporting, or documentation purposes. This capability proves valuable for creating snapshots of dynamic content or generating reports from scraped data. ```javascript Node.js theme={"dark"} const puppeteer = require('puppeteer-core'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const pdfGenerationScraper = async () => { const browser = await puppeteer.connect({ browserWSEndpoint: connectionURL }); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/', { waitUntil: 'domcontentloaded' }); console.log('Page loaded:', await page.title()); // Generate basic PDF console.log('Generating basic PDF...'); await page.pdf({ path: 'basic-page.pdf', format: 'A4', printBackground: true }); // Generate custom PDF with options console.log('Generating custom PDF...'); await page.pdf({ path: 'custom-page.pdf', format: 'A4', printBackground: true, margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' }, displayHeaderFooter: true, headerTemplate: '
E-commerce Scraping Report
', footerTemplate: '
Page of
' }); // Generate PDF of specific content area console.log('Generating product grid PDF...'); const productGrid = await page.$('.products'); if (productGrid) { const boundingBox = await productGrid.boundingBox(); await page.pdf({ path: 'product-grid.pdf', format: 'A4', printBackground: true, clip: boundingBox }); } // Generate landscape PDF console.log('Generating landscape PDF...'); await page.pdf({ path: 'landscape-page.pdf', format: 'A4', landscape: true, printBackground: true, scale: 0.8 }); console.log('All PDFs generated successfully'); } finally { await browser.close(); } }; pdfGenerationScraper(); ``` ```python Python theme={"dark"} # pip install pyppeteer import asyncio from pyppeteer import connect connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def pdf_generation_scraper(): browser = await connect(browserWSEndpoint=connection_url) page = await browser.newPage() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/', {'waitUntil': 'domcontentloaded'}) print(f'Page loaded: {await page.title()}') # Generate basic PDF print('Generating basic PDF...') await page.pdf({ 'path': 'basic-page.pdf', 'format': 'A4', 'printBackground': True }) # Generate custom PDF with options print('Generating custom PDF...') await page.pdf({ 'path': 'custom-page.pdf', 'format': 'A4', 'printBackground': True, 'margin': { 'top': '20mm', 'bottom': '20mm', 'left': '20mm', 'right': '20mm' }, 'displayHeaderFooter': True, 'headerTemplate': '
E-commerce Scraping Report
', 'footerTemplate': '
Page of
' }) # Generate landscape PDF print('Generating landscape PDF...') await page.pdf({ 'path': 'landscape-page.pdf', 'format': 'A4', 'landscape': True, 'printBackground': True, 'scale': 0.8 }) print('All PDFs generated successfully') finally: await page.close() await browser.disconnect() if __name__ == "__main__": asyncio.run(pdf_generation_scraper()) ```
```javascript Node.js theme={"dark"} const { chromium } = require('playwright'); const connectionURL = 'wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY'; const pdfGenerationScraper = async () => { const browser = await chromium.connectOverCDP(connectionURL); const page = await browser.newPage(); try { console.log('Navigating to target page...'); await page.goto('https://www.scrapingcourse.com/ecommerce/'); console.log('Page loaded:', await page.title()); // Generate basic PDF console.log('Generating basic PDF...'); await page.pdf({ path: 'basic-page.pdf', format: 'A4', printBackground: true }); // Generate custom PDF with options console.log('Generating custom PDF...'); await page.pdf({ path: 'custom-page.pdf', format: 'A4', printBackground: true, margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' }, displayHeaderFooter: true, headerTemplate: '
E-commerce Scraping Report
', footerTemplate: '
Page of
' }); // Generate landscape PDF console.log('Generating landscape PDF...'); await page.pdf({ path: 'landscape-page.pdf', format: 'A4', landscape: true, printBackground: true, scale: 0.8 }); console.log('All PDFs generated successfully'); } finally { await browser.close(); } }; pdfGenerationScraper(); ``` ```python Python theme={"dark"} # pip install playwright import asyncio from playwright.async_api import async_playwright connection_url = "wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY" async def pdf_generation_scraper(): async with async_playwright() as p: browser = await p.chromium.connect_over_cdp(connection_url) page = await browser.new_page() try: print('Navigating to target page...') await page.goto('https://www.scrapingcourse.com/ecommerce/') print(f'Page loaded: {await page.title()}') # Generate basic PDF print('Generating basic PDF...') await page.pdf( path='basic-page.pdf', format='A4', print_background=True ) # Generate custom PDF with options print('Generating custom PDF...') await page.pdf( path='custom-page.pdf', format='A4', print_background=True, margin={ 'top': '20mm', 'bottom': '20mm', 'left': '20mm', 'right': '20mm' }, display_header_footer=True, header_template='
E-commerce Scraping Report
', footer_template='
Page of
' ) # Generate landscape PDF print('Generating landscape PDF...') await page.pdf( path='landscape-page.pdf', format='A4', landscape=True, print_background=True, scale=0.8 ) print('All PDFs generated successfully') finally: await browser.close() if __name__ == "__main__": asyncio.run(pdf_generation_scraper()) ```
### PDF Generation Features * **Multiple format support:** Generate A4, Letter, Legal, and custom page sizes * **Custom headers and footers:** Add branding, page numbers, and metadata * **Background preservation:** Include CSS backgrounds and styling in PDFs * **Margin control:** Configure precise spacing and layout * **Orientation options:** Create portrait or landscape documents * **Scale adjustment:** Optimize content size for better readability PDF generation works seamlessly with the cloud browser, automatically transferring generated files to your local environment while maintaining high quality and formatting. ## Conclusion Zenrows' Browser Sessions transforms complex web automation challenges into straightforward solutions. These practical use cases demonstrate the platform's versatility in handling everything from basic content extraction to sophisticated browser automation workflows. ## Next Steps for Implementation Start with the basic navigation and content extraction patterns to establish your foundation, then progressively incorporate advanced features like form interactions and network monitoring as your requirements evolve. The modular nature of these examples allows you to combine techniques for sophisticated automation workflows. Consider implementing error handling and retry logic around these patterns for production deployments. Browser Sessions's consistent cloud environment reduces many common failure points, but robust error handling ensures reliable operation at scale. ## Frequently Asked Questions (FAQ) Absolutely! These use cases are designed to work together. For example, you can navigate to a page, take screenshots, extract data, and generate PDFs all within the same browser session. This approach is more efficient and maintains session state across operations. ```javascript Node.js theme={"dark"} // Example combining multiple use cases await page.goto('https://example.com'); await page.screenshot({ path: 'before.png' }); await page.fill('input[name="search"]', 'query'); await page.click('button[type="submit"]'); await page.screenshot({ path: 'after.png' }); const data = await page.$$eval('.result', elements => /* extract data */); await page.pdf({ path: 'results.pdf' }); ``` Use explicit waiting mechanisms to ensure content is fully loaded before interaction: ```javascript Node.js theme={"dark"} // Wait for specific elements await page.waitForSelector('.dynamic-content'); // Wait for network to be idle await page.goto(url, { waitUntil: 'domcontentloaded' }); // Wait for custom conditions await page.waitForFunction(() => document.querySelectorAll('.product').length > 0); ``` Browser Sessions handles JavaScript rendering automatically, making these waiting strategies highly effective. Several strategies can significantly improve performance: * **Block unnecessary resources:** Use request interception to block images, fonts, and other non-essential content * **Reuse browser instances:** Keep browsers open for multiple operations instead of creating new connections * **Implement concurrent processing:** Use multiple browser instances for parallel scraping * **Optimize waiting strategies:** Use specific selectors instead of generic timeouts The network monitoring examples demonstrate resource blocking techniques that can improve scraping speed. Both approaches provide identical functionality, but the SDK offers several advantages: * **Simplified configuration:** No need to manually construct WebSocket URLs * **Better error handling:** Built-in error messages and debugging information * **Future compatibility:** Automatic updates to connection protocols * **Additional utilities:** Helper methods for common tasks For production applications, the SDK is recommended for better maintainability and error handling, while direct WebSocket connections work well for simple scripts and testing. Follow this systematic troubleshooting approach: 1. **Verify API key:** Ensure your Zenrows API key is correct and active 2. **Check element selectors:** Use browser developer tools to verify CSS selectors 3. **Add debugging output:** Include console.log statements to track execution flow 4. **Implement error handling:** Wrap operations in try-catch blocks 5. **Test with simpler examples:** Start with basic navigation before adding complexity The network monitoring examples are particularly valuable for debugging, as they reveal exactly what requests are being made and their responses. # Introduction to Browser Sessions Source: https://docs.zenrows.com/browser-sessions/introduction Learn what Zenrows Browser Sessions offers for dynamic website scraping, including residential proxy rotation, geo-targeting, and CDP support. Zenrows' Browser Sessions (formerly, Scraping Browser) is the perfect solution for effortlessly extracting data from dynamic websites, especially if you're already using tools like Puppeteer or Playwright. **With just one line of code, you can integrate Browser Sessions into your existing setup** and immediately benefit from our vast network of over 55 million residential IPs across 190+ countries, offering 99.9% uptime for uninterrupted scraping. Whether you're dealing with complex JavaScript content or interacting with web elements, Zenrows makes it easy to scrape without worrying about managing proxies or handling dynamic user interactions. ## Ideal for Puppeteer and Playwright Users If you already have web scraping code using [Puppeteer](https://www.zenrows.com/blog/puppeteer-web-scraping) or [Playwright](https://www.zenrows.com/blog/playwright-scraping), integrating Zenrows' Browser Sessions is incredibly simple. By adding just one line, you can unlock the power of our residential proxy network and browser simulation, without needing to rewrite your entire scraping logic. Zenrows enhances your scraping capabilities with minimal effort: ```bash Browser URL theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY ``` This integration ensures that your existing scraping operations become more robust, efficient, and scalable. ## Key Advantages Browser Sessions goes beyond traditional scraping techniques, offering solutions for the most challenging web scraping tasks: * **One Line of Code:** Start scraping instantly with Zenrows' Browser Sessions. No complicated setup. Just a simple API call that does all the heavy lifting for you. * **Dynamic Web Content Extraction:** Many websites rely on JavaScript to display content dynamically. Browser Sessions simulates a real user session, allowing you to extract data even from single-page applications (SPAs) or websites that load content asynchronously. * **Handling User Interactions:** Simulate user actions like clicking, scrolling, or waiting for elements to load, all while avoiding IP blocks and ensuring the data is extracted seamlessly. * **Geolocation Targeting:** Access localized content by selecting from millions of IPs across 190+ countries, bypassing geo-restrictions to scrape the data you need. * **IP Auto-Rotation:** Rotate IPs with every request to avoid bans and blocks, ensuring your scraping remains undetected. ## Parameter Overview Customize your Browser Sessions requests using the following parameters: | PARAMETER | TYPE | DEFAULT | DESCRIPTION | | - | - | - | - | | **apikey** `required` | `string` | [**Get Your Free API Key**](https://app.zenrows.com/register?p=free) | Your unique API key for authentication | | [**proxy\_region**](https://docs.zenrows.com/browser-sessions/features/world-region) | `string` | `global` | Focus your scraping on a specific geographic region (e.g., eu for Europe). **When set to global, no parameter is needed**. | | [**proxy\_country**](https://docs.zenrows.com/browser-sessions/features/country) | `string` | | Target a specific country for geo-restricted data (e.g., `es` for Spain). Available only when `proxy_region` is set to global. | | [**session\_ttl**](https://docs.zenrows.com/browser-sessions/features/session-ttl) | `number` | `180` | The time to live for the browser session, in seconds. Minimum: 60, Maximum: 900 (1-15 minutes). Defaults to 3 minutes. | ## Pricing Browser Sessions bill in credits, on the same shared balance as every other Zenrows product. Bandwidth is metered at a flat **25,000 credits per GB** on every plan, plus a flat **5 credits per minute** of session time. **With Zenrows, you only pay for the data and time you use**, and the Free plan (5,000 credits/month, no credit card required) lets you try it at no cost. Explore our flexible plans and find more details on our [pricing page](/first-steps/pricing). ## Tips for Optimal Performance 1. **Manage Session Duration**: Keep session durations as short as possible since session time is billed by the minute. 2. **Reuse Browser Sessions**: For multiple operations on the same site, reuse the browser session rather than creating a new one each time. 3. **Use Proper Page Closure**: Always close pages when done to free up resources: ```javascript theme={"dark"} // Close individual pages when done await page.close(); ``` 4. **Set Reasonable Timeouts**: Use appropriate timeouts for your operations to prevent long-running sessions: ```javascript theme={"dark"} // Set navigation timeout await page.goto(url, { timeout: 30000 }); // Set selector timeout await page.waitForSelector('.element', { timeout: 15000 }); ``` 5. **Optimize Resource Usage**: Block unnecessary resources when they're not needed: ```javascript theme={"dark"} // Block images, fonts, etc. await page.setRequestInterception(true); page.on('request', (request) => { if (request.resourceType() === 'image' || request.resourceType() === 'font') { request.abort(); } else { request.continue(); } }); ``` 6. **Target Specific Countries Wisely**: Only specify a country when needed for your use case, as more specific targeting might have different success rates. # Browser Sessions Source: https://docs.zenrows.com/browser-sessions/sdk-overview Browser Sessions doesn't need a separate SDK. It integrates into your existing browser automation setup with one line of code. Browser Sessions (formerly, Scraping Browser) doesn't need a separate SDK. It integrates into your existing browser automation setup with one line: point your existing browser connection at the Zenrows Browser Sessions endpoint instead of a local browser. It works with Puppeteer, Playwright, or any CDP-compatible tool. ```bash Browser URL theme={"dark"} wss://browser.zenrows.com?apikey=YOUR_ZENROWS_API_KEY ``` Everything else, your scraping logic, selectors, and page interactions, stays exactly as it already is. See the [Browser Sessions introduction](/browser-sessions/introduction) for the full picture: residential proxy rotation, geo-targeting, and CDP support. ## Get Started by Language Connect Puppeteer (Node.js) or Pyppeteer (Python) to Browser Sessions. Connect Playwright, for Python or Node.js, to Browser Sessions. # Setting Up Browser Sessions Source: https://docs.zenrows.com/browser-sessions/setup Quick setup guide for Zenrows Browser Sessions with Puppeteer or Playwright, including API key configuration and connection parameters. Zenrows Browser Sessions (formerly, Scraping Browser) works seamlessly with your existing Puppeteer or Playwright code. Just one line of code is needed to integrate, making it ideal for developers looking for fast and easy solutions. ## Initial Setup To start using Browser Sessions, follow these steps: If you haven't already, create a Zenrows account by visiting the [Registration Page](https://app.zenrows.com/register). Once registered, log in to your dashboard to access your [Browser Sessions page](https://app.zenrows.com/browser). scraping-browser-dashboard ## Browser Sessions Parameters Zenrows Browser Sessions is designed for ease of use with minimal configuration. However, you can still customize some options to suit your needs, including World Region, Country and Session TTL. Auto-rotate and Residential IPs are pre-configured and enabled by default for all users. ### Available Parameter Options Browser Sessions offers several customizable parameters to enhance your scraping experience: * [World Region](/browser-sessions/features/world-region) - Target specific geographic regions for your scraping operations * [Country Selection](/browser-sessions/features/country) - Choose from our extensive list of premium proxy countries for geo-restricted content * [Session TTL](/browser-sessions/features/session-ttl) - Control browser session duration from 1 to 15 minutes Each parameter page provides both SDK and direct WebSocket URL examples to help you implement the settings that best fit your scraping requirements. # Agent Toolkit Source: https://docs.zenrows.com/cli/agent-toolkit-overview MCP, Skills, Templates, Workflows, Recipes, and Evals: the pieces that make Zenrows installable and usable by an AI agent, not just callable by one. A tool being callable over an API isn't the same as a tool being usable by an agent. An agent that can call `fetch` still has to work out, on its own, whether `fetch` is even the right call for this page, how to start a real project instead of a one-off snippet, how to script something that runs the same way every time, and how to tell whether a change quietly broke something. The Agent Toolkit is the part of the Zenrows CLI that answers those questions directly, so an agent (or the developer directing one) doesn't have to reconstruct them from scratch on every project. Each piece solves one specific part of that problem: ```bash Connect and install everything theme={"dark"} zenrows init --all ``` ```bash Just wire up an agent client theme={"dark"} zenrows plugin install claude-code ``` `init --all` sets up a full workspace: every skill, template, workflow, recipe, and eval, plus MCP configured for your agent clients, in one command. `plugin install ` is the narrower path when you only need an agent client wired up: it configures MCP for that client and installs every skill, nothing else. Check what's already in place at any point with `zenrows status`. ## The Shape of the Problem Six pieces, each independent: Connect, Decide, Start, Automate, Verify, Benchmark, mapping to MCP, Skills, Templates, Workflows, Recipes, and Evals. Each piece answers a different question, and none of them require the others. The CLI authenticates and runs `fetch`, `extract`, `batch`, and `browser` entirely on its own, no agent client, no MCP: * **Connect** an MCP-native agent client, Claude Desktop, Cursor, and similar, so the model can discover and call tools directly instead of a human typing CLI commands. [MCP](/cli/mcp) sets this up. Only relevant if you're wiring in that kind of client, an agent that already runs shell commands (like one driving the CLI directly) doesn't need it at all. * **Decide** which primitive fits the task, without guessing. [Skills](/cli/skills) are short, agent-readable playbooks, a master decision tree plus one playbook per primitive, so an agent picks `fetch`, `extract`, `batch`, or a browser session correctly, without extra prompting from you. * **Start** a real project instead of an empty file. [Templates](/cli/templates) scaffold a minimal, working project for a specific job, like calling Fetch from Node.js or building a Batch job spec. * **Automate** something that has to run the same way every time. [Workflows](/cli/workflows) chain multiple Zenrows commands into one named, repeatable process, like keeping a competitor's pricing data fresh on a schedule. * **Verify** that a single command actually works, right now, against a known-good target. [Recipes](/cli/recipes) are small, runnable examples that double as a smoke test before you build anything on top of them. * **Benchmark** that it keeps working. [Evals](/cli/evals) run the same check reproducibly and write a scored report, so a regression shows up as a failed eval instead of a silent surprise in production. ## The Pieces | Piece | Answers | Catalog today | Primary command | | - | - | - | - | | [MCP](/cli/mcp) | How does an agent client connect at all? | 8 supported clients | `zenrows plugin install ` | | [Skills](/cli/skills) | Which primitive should I use, and why? | 8 skills | `zenrows skill install --all` | | [Templates](/cli/templates) | How do I start this kind of project? | 3 templates | `zenrows template create ` | | [Workflows](/cli/workflows) | How do I automate a multi-step process? | 1 workflow | `zenrows workflow run ` | | [Recipes](/cli/recipes) | Does this one command actually work? | 2 recipes | `zenrows recipe run ` | | [Evals](/cli/evals) | Does it still work, with a written report? | 3 evals | `zenrows eval run ` | Every piece follows the same shape: `list` to see what's available, `install` (or `create`, for templates) to bring it into your project, `explain` to read its full docs, and `run` (for workflows, recipes, and evals) to actually execute it. Learn one, and you already know the other five. ## Next Steps Connect Claude Code, Cursor, and other clients. See the full catalog of agent playbooks. Scaffold a working project. Automate a multi-step process. Run one example end to end. Benchmark a command with a written report. # Batch Source: https://docs.zenrows.com/cli/batch/introduction Process thousands of URLs reliably, in one job, and check the cost before you spend a single credit. `zenrows batch` runs the same fetch or extract job across a list of URLs as a single, trackable job: submit once, then check status or collect results whenever you want. ```bash theme={"dark"} zenrows batch [flags] ``` `jobs.jsonl` is one JSON object per line, each with a `url` and optional per-line overrides (`js_render`, `premium_proxy`, `proxy_country`). ## Local, no key needed ```bash theme={"dark"} zenrows batch estimate jobs.jsonl ``` Validates the spec and estimates credit cost with no cloud call. Run this before submitting a real job. ## Cloud ```bash theme={"dark"} # Submit a job and wait for it to finish zenrows batch create jobs.jsonl --wait # Check status zenrows batch status # Collect results (paginated) zenrows batch results --out results.jsonl # Rerun only the failed tasks zenrows batch retry-failed # Stop an in-flight run zenrows batch cancel ``` ## Flags `--json` (print structured output) works on every subcommand. The rest are subcommand-specific: | Subcommand | Flags | | - | - | | `create ` | `--js-render`, `--premium-proxy`, `--proxy-country ` (needs `--premium-proxy`), `--output ` (`markdown`\|`plaintext`\|`pdf`), `--wait` | | `results ` | `--status `, `--out `, `--download ` (fetches each result body, plus a `_manifest.jsonl`) | | `wait ` | `--timeout ` | # Browser Sessions Source: https://docs.zenrows.com/cli/browser/introduction Click, log in, navigate, and interact like a real user, only when simpler methods can't do the job. `zenrows browser` gives you a real, remote browser session for the things Fetch and Extract can't do: logging in, clicking through a flow, or waiting on content that only appears after interaction. ```bash theme={"dark"} zenrows browser info ``` Disabled by default. Enable it first: ```bash theme={"dark"} zenrows policy set allow_browser true ``` Escalation-only. Use [`zenrows fetch`](/cli/fetch/introduction) or [`zenrows extract`](/cli/extract/introduction) first, reach for a browser only when they genuinely can't do the job (logins, clicks, multi-step flows, persistent state). Two ways to drive a session: * Connect Playwright or Puppeteer directly to the Browser Sessions API * Drive it through [`zenrows mcp`](/cli/mcp)'s browser tools (navigate, click, fill, screenshot, and more) # Evals Source: https://docs.zenrows.com/cli/evals A reproducible benchmark bundled with the Zenrows CLI, with explicit pass or fail criteria. An eval is a scripted benchmark against a known target. Run it to check that a command still behaves as expected, and get a written report back. ```bash theme={"dark"} zenrows eval list zenrows eval run ``` | Command | Does | | - | - | | `zenrows eval list` | List all evals | | `zenrows eval run ` | Run an eval and write a report | | `zenrows eval report ` | Print a previous eval's report | | `zenrows eval explain ` | Show an eval's full docs | | `zenrows eval install ` | Copy an eval into `.zenrows/` | | `zenrows eval update [name]` | Refresh installed eval(s) | | `zenrows eval remove ` | Remove an installed eval | ## Catalog | Eval | Description | | - | - | | `protected-fetch-smoke` | `fetch` against a known-good URL, expects HTTP 2xx and content | | `extract-smoke` | `extract --autoparse` against a known-good ecommerce demo page | | `batch-spec-validation` | A JSONL spec parses cleanly and estimates cost, no cloud call | Each run writes `input.json`, `results.json`, `report.md`, `failures.jsonl`, `cost.json`, and `traces/` under `.zenrows/evals//`. # Extract Source: https://docs.zenrows.com/cli/extract/introduction Turn any messy web page into structured JSON, exact fields you pick, or Markdown, from the terminal. `zenrows extract` fetches a page and converts it into structured data: automatic JSON, fields you choose with CSS selectors, or Markdown. ```bash theme={"dark"} zenrows extract [flags] ``` ## Examples ```bash theme={"dark"} # Automatic structured JSON (default method) zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse # CSS selector map zenrows extract --css '{"title":"h1","price":".price"}' --validate # Built-in output filters zenrows extract --outputs emails,links # Convert to Markdown zenrows extract --output markdown ``` ## Flags Pick exactly one method per call: `--autoparse`, `--css`, `--outputs`, or `--output`. | Flag | Description | | - | - | | `--autoparse` | Automatic structured JSON (default method) | | `--css ` | CSS selector map, e.g. `'{"title":"h1","price":".price"}'` | | `--outputs ` | Built-in output filters as JSON: `emails`, `phone_numbers`, `headings`, `images`, `audios`, `videos`, `links`, `menus`, `hashtags`, `metadata`, `tables`, `favicon`, comma-separated, or `*` for all | | `--output ` | `markdown` or `text` conversion instead of extraction | | `--validate` | Fail if the result is not valid JSON | | `--manual` / `--js-render` / `--premium-proxy` | Same manual fetch controls as [`zenrows fetch`](/cli/fetch/introduction) | | `--proxy-country ` | Geo-target | | `--wait ` / `--wait-for ` | Same wait controls as `zenrows fetch` | | `--out ` | Write output to a file | | `--no-signup` | Do not auto-create a Free-plan account if no key exists | | `--json` | Print a structured result | # Fetch Source: https://docs.zenrows.com/cli/fetch/introduction Get any page as clean, usable content from the terminal, reliably, even if it's dynamic, JavaScript-heavy, or protected. `zenrows fetch` retrieves a single page and returns its HTML, Markdown, or plain text. It handles JavaScript rendering and anti-bot protection automatically, so most calls need no extra flags. ```bash theme={"dark"} zenrows fetch [flags] ``` ## Examples ```bash theme={"dark"} # Just works, no flags needed zenrows fetch https://httpbin.io/html # Markdown output zenrows fetch https://example.com --output markdown # Take manual control of rendering and proxies zenrows fetch https://example.com --manual --js-render --premium-proxy # Geo-target and wait for an element zenrows fetch https://example.com --proxy-country us --wait-for ".price" # Save the response to a file zenrows fetch https://example.com --out page.html ``` Start with the defaults. Only add `--manual --js-render --premium-proxy` after a run's trace (`zenrows trace explain `) shows it's actually needed, each flag raises cost. ## Flags | Flag | Description | | - | - | | `--manual` | Take manual control of rendering and proxies instead of the automatic default | | `--js-render` | Render JavaScript (manual mode only) | | `--premium-proxy` | Use residential IPs (manual mode only) | | `--proxy-country ` | Geo-target. Works alone by default; in manual mode requires `--premium-proxy` | | `--wait ` | Fixed wait after load | | `--wait-for ` | Wait for a CSS selector | | `--js-instructions ` | JSON instructions to run on the page | | `--session-id ` | Keep the same IP across requests | | `--original-status` | Return the target's original HTTP status | | `--output ` | `html` (default), `markdown`, `text`, or `pdf` | | `--screenshot` | Capture an above-the-fold screenshot | | `--out ` | Write the response body to a file | | `--no-signup` | Do not auto-create a Free-plan account if no key exists | | `--json` | Print a structured result instead of the raw body | # Zenrows CLI Source: https://docs.zenrows.com/cli/introduction Install the Zenrows CLI, authenticate, and get your first result from the terminal in under five minutes. Zenrows gives your terminal, and your AI agents, reliable access to the protected web: sites that are dynamic, JavaScript-heavy, or built to block ordinary tools. The `zenrows` CLI puts fetching, extracting, and running jobs at scale behind one command, plus the skills, templates, and MCP wiring that make it work naturally with an agent, not just a script. It's [open source](https://github.com/ZenRows/cli). ## Installation ```bash theme={"dark"} npx -y @zenrows/cli init ``` No API key needed to start. The first cloud call auto-provisions a Free-plan account and stores the key in `.zenrows/secrets.json`. Claim it later to keep your usage history, the same key keeps working: ```bash theme={"dark"} zenrows account claim ``` To use an existing key instead of the auto-provisioning flow: ```bash theme={"dark"} zenrows login --api-key # or export ZENROWS_API_KEY= zenrows login --env ``` ## Quickstart ```bash theme={"dark"} npx -y @zenrows/cli init zenrows fetch https://httpbin.io/html zenrows extract https://www.scrapingcourse.com/ecommerce/ --autoparse ``` ## Full setup One command instead of many: creates your workspace, installs every skill, template, workflow, recipe, and eval, configures MCP for your agent clients, and runs a health check. ```bash theme={"dark"} zenrows init --all ``` Target specific agent clients instead of the defaults: ```bash theme={"dark"} zenrows init --agents claude-code,cursor,windsurf ``` If a client can't be auto-configured, `init` prints the manual steps instead of skipping it silently. ## Commands ### Primitives The actual scraping work. | Command | Does | | - | - | | [`fetch`](/cli/fetch/introduction) | Get any page as clean, usable content, even if it's protected | | [`extract`](/cli/extract/introduction) | Turn messy pages into clean, structured JSON | | [`batch`](/cli/batch/introduction) | Process thousands of URLs reliably, in one job | | [`browser`](/cli/browser/introduction) | Click, log in, navigate, and interact, like a real user | ### Agent Toolkit What makes Zenrows easy for an agent, or a developer, to actually use. | Command | Does | | - | - | | [`mcp`](/cli/mcp) | Connect an AI agent, Claude Code, Cursor, and more, to Zenrows | | [`skill`](/cli/skills) | Install agent-readable playbooks for choosing the right tool | | [`template`](/cli/templates) | Scaffold a minimal, working project | | [`workflow`](/cli/workflows) | Run a multi-step, end-to-end process | | [`recipe`](/cli/recipes) | Run a single, ready-made example | | [`eval`](/cli/evals) | Run a reproducible benchmark | Check your setup, and what's already installed: ```bash theme={"dark"} zenrows status ``` ## Next steps Wire Claude Code, Cursor, Windsurf, and more into Zenrows with one command. Read a failed run's trace and find the specific next action. # MCP Source: https://docs.zenrows.com/cli/mcp Connect Zenrows to Claude Code, Cursor, and other AI agents through MCP, using the Zenrows CLI. `zenrows mcp` and `zenrows plugin` are Zenrows CLI commands that wire the Zenrows MCP server into an agent client for you. They generate the config your client needs, they don't run a server themselves. This page covers the CLI commands only. For the MCP server itself, its tools, and authentication, see [MCP](/mcp/overview). ```bash theme={"dark"} zenrows mcp status zenrows mcp config --client ``` ## Clients `claude-code`, `cursor`, `vscode`, `windsurf`, `gemini`, `codex`, `opencode`, `generic`. Codex and OpenCode need manual configuration. The other six auto-configure. ## Examples ```bash theme={"dark"} # Print the exact config snippet for a client zenrows mcp config --client claude-code # Target the hosted server instead of the local one zenrows mcp config --client cursor --remote # Remove a configured server zenrows mcp uninstall --client vscode ``` ## `zenrows plugin`: MCP config + skills in one command `zenrows plugin install ` wraps `mcp config` and also installs every skill, one command instead of two. ```bash theme={"dark"} zenrows plugin list zenrows plugin install claude-code zenrows plugin status ``` # Recipes Source: https://docs.zenrows.com/cli/recipes A single, ready-to-run example bundled with the Zenrows CLI that doubles as a smoke test. A recipe is one small, runnable example with a known-good target. Run it to confirm a Zenrows command works before you build on it. ```bash theme={"dark"} zenrows recipe list zenrows recipe run ``` | Command | Does | | - | - | | `zenrows recipe list` | List all recipes | | `zenrows recipe run ` | Run a recipe end to end | | `zenrows recipe explain ` | Show a recipe's full docs | | `zenrows recipe install ` | Copy a recipe into `.zenrows/` | | `zenrows recipe update [name]` | Refresh installed recipe(s) | | `zenrows recipe remove ` | Remove an installed recipe | ## Catalog | Recipe | Description | | - | - | | `fetch-protected-page` | Fetch a single anti-bot-protected page | | `batch-estimate` | Validate a JSONL batch spec and estimate its credit cost, locally, no key needed | # Skills Source: https://docs.zenrows.com/cli/skills List, install, and manage playbooks that teach an AI agent which tool to use, bundled with the Zenrows CLI. A skill is a short, agent-readable playbook. Install one and an AI agent knows when and how to use a Zenrows tool, no extra prompting needed. ```bash theme={"dark"} zenrows skill list zenrows skill install --all ``` | Command | Does | | - | - | | `zenrows skill list` | List all skills, installed or not | | `zenrows skill install ` | Install a skill | | `zenrows skill install --all` | Install every skill | | `zenrows skill explain ` | Show a skill's full docs | | `zenrows skill validate ` | Check an installed skill is well-formed | | `zenrows skill generate --from-run ` | Scaffold a new skill from a past run | | `zenrows skill update [name]` | Refresh installed skill(s) | | `zenrows skill remove ` | Remove an installed skill | ## Catalog | Skill | Description | | - | - | | `zenrows` | Master playbook: the decision tree, credit costs, escalation rules | | `protected-fetch` | Use Protected Fetch for anti-bot-protected page retrieval | | `extract` | Turn protected pages into structured data | | `batch-jobs` | Scale fetch/extract over many URLs via Batch | | `interact-browser` | Escalate to a browser only when fetch/extract can't do the job | | `cost-control` | Pick the cheapest reliable option; understand the credit costs | | `trace-debug` | Read run traces and choose the next action after a failure | | `compliance-policy` | Domain allow/deny rules, credit limits, secret handling | # Templates Source: https://docs.zenrows.com/cli/templates Scaffold a minimal, working project for Zenrows in one command. A template is a ready-made project scaffold. Instantiate one into a folder and you get a minimal, working starting point instead of an empty file. ```bash theme={"dark"} zenrows template list zenrows template create --output ``` | Command | Does | | - | - | | `zenrows template list` | List all templates | | `zenrows template create --output ` | Instantiate a template into `` | | `zenrows template install ` | Copy a template into `.zenrows/` | | `zenrows template explain ` | Show a template's full docs | | `zenrows template update [name]` | Refresh installed template(s) | | `zenrows template remove ` | Remove an installed template | ## Catalog | Template | Description | | - | - | | `protected-fetch-node` | Minimal Node.js project calling Fetch directly | | `extract-pricing-pages` | Extract structured pricing fields from product pages | | `batch-jsonl-pipeline` | JSONL job-spec scaffold for Batch | # Troubleshooting Source: https://docs.zenrows.com/cli/troubleshooting Fix a failed run: read its trace and find the exact next step for every error code. Every `fetch`/`extract`/`batch` run writes a secret-free record under `.zenrows/runs//`. Read it before reflexively adding more flags. ```bash theme={"dark"} zenrows trace inspect # raw run record zenrows trace explain # what happened and why zenrows trace replay # print the equivalent command to re-run zenrows trace export # JSON, for piping elsewhere ``` ## Error codes | Code | When it happens | Next action | | - | - | - | | `AUTH_MISSING` | No API key is configured | `zenrows login --api-key ` | | `AUTH_INVALID` | Zenrows rejected the key | Re-check the key in the dashboard, then `zenrows login --api-key ` again | | `DOMAIN_FORBIDDEN` | The target domain is blocked by Zenrows policy | Permanent, the same request fails again with any flags. Use a different source | | `FETCH_EMPTY_RESPONSE` | The page returned no usable content | Retry with `--js-render` and a `--wait-for ` or `--wait ` | | `PARAM_CONFLICT_AUTO_MANUAL` | `--js-render` / `--premium-proxy` passed without `--manual` | Drop those flags to keep the automatic default, or add `--manual` | | `PARAM_PROXY_COUNTRY_REQUIRES_PREMIUM` | `--proxy-country` used in manual mode without `--premium-proxy` | Add `--premium-proxy` (10 credits), or drop `--manual` to use auto mode | | `POLICY_MAX_CREDITS_EXCEEDED` | The **account** has spent its credit allowance | Credits renew at the end of the billing period — run `zenrows usage` for the date. To carry on now, claim the Free-plan account (if unclaimed) or add credits/upgrade in the dashboard | | `POLICY_LIMIT_EXCEEDED` | The run exceeds a cap **you** set in `policy.json` (`max_credits_per_run`, `max_pages_per_run`) | Split the run, drop `--premium-proxy` / `--js-render` to lower the estimate, or raise the cap with `zenrows policy set` | | `POLICY_EXPERIMENTAL_DISABLED` | `zenrows browser` used before enabling it | `zenrows policy set allow_browser true`, only once Fetch/Extract genuinely can't do the job | | `MCP_CLIENT_UNSUPPORTED` | `--client` isn't a known MCP client id | Use one of the supported client ids, or generate a generic config snippet | | `CAPABILITY_UNAVAILABLE` | The command's backend capability isn't enabled yet | Use a local spec/validation path, or escalate to Protected Fetch/Extract | # Workflows Source: https://docs.zenrows.com/cli/workflows A named, multi-step process bundled with the Zenrows CLI, like keeping competitor data fresh automatically. A workflow is a multi-step, end-to-end process built from Zenrows commands. Install one and run it as a single command instead of scripting each step yourself. ```bash theme={"dark"} zenrows workflow list zenrows workflow run ``` | Command | Does | | - | - | | `zenrows workflow list` | List all workflows | | `zenrows workflow run ` | Run a workflow end to end | | `zenrows workflow explain ` | Show a workflow's full docs | | `zenrows workflow install ` | Copy a workflow into `.zenrows/` | | `zenrows workflow update [name]` | Refresh installed workflow(s) | | `zenrows workflow remove ` | Remove an installed workflow | ## Catalog | Workflow | Description | | - | - | | `competitor-intelligence` | Gather competitor pages, extract structured data, and keep it fresh | # Extract Endpoints Source: https://docs.zenrows.com/extract/endpoints Reference for the Zenrows Extract endpoints: read which domains Extract serves today, ask for a new domain to be prepared, and track a preparation through to ready. Extract's own endpoints live under `https://api.zenrows.com/v1/extract/`. They answer two questions that `extract=auto` cannot: **which domains work today**, and **how to get one that doesn't yet**. None of them fetches a page, so none of them costs credits. Authenticate the same way as any Zenrows request, with either the `apikey` query parameter or the `X-API-Key` header. Both work on every endpoint below; the examples alternate to show each. ## Which domains Extract serves ### `GET /v1/extract/domains` Every domain Extract can handle right now. This is the list behind [Domain coverage](/extract/setup#domain-coverage), read live instead of from a page, so an integration can check coverage before sending a request rather than handling an error after one. ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/extract/domains?apikey=YOUR_ZENROWS_API_KEY" ``` ```python Python theme={"dark"} # pip install requests import requests response = requests.get( 'https://api.zenrows.com/v1/extract/domains', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}, ) supported = {entry['domain'] for entry in response.json()['domains']} print('amazon.com' in supported) ``` ```javascript Node.js theme={"dark"} const response = await fetch('https://api.zenrows.com/v1/extract/domains', { headers: { 'X-API-Key': 'YOUR_ZENROWS_API_KEY' }, }); const { domains } = await response.json(); const supported = new Set(domains.map((entry) => entry.domain)); console.log(supported.has('amazon.com')); ``` ```json Response theme={"dark"} { "domains": [ { "domain": "amazon.com", "verticals": [ "Automotive/Auto Parts", "Books and Literature/Fiction", "Shopping", "Technology & Computing/Consumer Electronics" ], "warm": true }, { "domain": "americanas.com.br", "verticals": ["Shopping", "Technology & Computing/Consumer Electronics"], "warm": true } ] } ``` | Field | Description | | - | - | | `domain` | The registrable domain. It covers every subdomain: `amazon.com` also means `www.amazon.com`. | | `verticals` | The kinds of page Extract has prepared for this domain. A domain serving several verticals is one entry, not several. Roughly a quarter of entries have none, which does not mean the domain works any less. | | `warm` | Always `true` today. The field exists for a future "declared but not prepared yet" state. | A domain in this list is a domain you can send `extract=auto` to. A domain missing from it returns [`REQS007`](/api-error-codes#REQS007), naming the domain and pointing at the endpoint below. ## Getting a domain prepared Extract works on a domain once Zenrows has prepared it: learned its page shapes and verified that extraction actually returns the right data. You can ask for a domain to be prepared yourself, within a monthly allowance. Preparing a domain is not instant. A submission is accepted immediately and then works through discovery, preparation and verification, typically minutes rather than seconds. Poll the status endpoint rather than blocking on the submit call. ### `POST /v1/extract/prepared-domains` Ask for a domain to be prepared. The body takes the domain and, optionally, example URLs. **Send example URLs when you can.** They are authoritative: Zenrows prepares exactly the page kinds you point at, and skips the discovery step that guesses them. One URL per page kind you care about is enough: a product page, a search results page. | Field | Required | Description | | - | - | - | | `domain` | yes | The domain to prepare, without scheme. | | `example_urls` | no | URLs on that domain, one per page kind. When present, they replace discovery. | Send an `Idempotency-Key` header to make a retry safe: the same key returns the original submission instead of spending a second allowance slot. ```bash cURL theme={"dark"} curl -X POST "https://api.zenrows.com/v1/extract/prepared-domains?apikey=YOUR_ZENROWS_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8f14e45f-ea2c-4f2b-9c1a-7d3b6a0e5c91" \ -d '{ "domain": "scrapingcourse.com", "example_urls": [ "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie", "https://www.scrapingcourse.com/ecommerce/" ] }' ``` ```python Python theme={"dark"} # pip install requests import requests response = requests.post( 'https://api.zenrows.com/v1/extract/prepared-domains', headers={ 'X-API-Key': 'YOUR_ZENROWS_API_KEY', 'Idempotency-Key': '8f14e45f-ea2c-4f2b-9c1a-7d3b6a0e5c91', }, json={ 'domain': 'scrapingcourse.com', 'example_urls': [ 'https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie', 'https://www.scrapingcourse.com/ecommerce/', ], }, ) print(response.status_code, response.json()['state']) ``` A submission returns **`202 Accepted`** and its starting state, in the same shape the status endpoint returns: ```json Response theme={"dark"} { "domain": "scrapingcourse.com", "status": "preparing", "state": "received", "submitted_at": "2026-09-14T08:02:38.686126Z", "updated_at": "2026-09-14T08:02:38.686126Z" } ``` Submitting a domain that is already prepared returns `202` with its current record rather than starting again, so a retry is never wasted. ### `GET /v1/extract/prepared-domains/{domain}` How far a submission has got. ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/extract/prepared-domains/scrapingcourse.com?apikey=YOUR_ZENROWS_API_KEY" ``` ```python Python theme={"dark"} # pip install requests import requests, time while True: response = requests.get( 'https://api.zenrows.com/v1/extract/prepared-domains/scrapingcourse.com', headers={'X-API-Key': 'YOUR_ZENROWS_API_KEY'}, ) body = response.json() if body['status'] != 'preparing': break time.sleep(30) print(body['status']) ``` A domain that was never submitted returns `404`: ```json Response theme={"dark"} { "code": "not_found", "detail": "This domain has never been submitted.", "status": 404, "title": "Not found (not_found)", "type": "https://docs.zenrows.com/api-error-codes#not_found" } ``` #### Reading the response Two fields describe progress, at two levels of detail. **Branch on `status`; show `state` if you are drawing a progress bar.** `status` is the answer to "can I use this domain yet": | `status` | Meaning | | - | - | | `preparing` | Still working. Keep polling. | | `ready` | Done. `extract=auto` works on this domain now. | | `failed` | Stopped. See `failure`. | `state` is the current step, and will gain steps over time: | `state` | Meaning | | - | - | | `received` | Accepted, not started. | | `discovering` | Finding the domain's page kinds. Skipped when you sent `example_urls`. | | `preparing` | Building the extraction for each page kind. | | `verifying` | Checking that extraction returns the right data. | | `ready` / `failed` | Terminal, matching `status`. | `roles` breaks the same progress down per page kind, each with its own `status`, the `url` it is working from, and a `detail` sentence if it stopped. It is only present for the account that submitted the domain, because example URLs disclose what you scrape and are not shown to other accounts polling it. #### When a preparation fails A `failed` submission carries a `failure` block with a `code`, a `detail` sentence, and `slot_consumed`: ```json Response theme={"dark"} { "domain": "scrapingcourse.com", "status": "failed", "state": "failed", "roles": [ { "role": "", "status": "failed", "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie", "detail": "the target could not be fetched; the gateway rejected this request" } ], "failure": { "code": "unpreparable", "detail": "we could not prepare this domain: 1 of 1 pages could not be prepared: https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie (the target could not be fetched; the gateway rejected this request)", "slot_consumed": false }, "submitted_at": "2026-09-14T08:02:38.686126Z", "updated_at": "2026-09-14T08:02:39.927245Z" } ``` `slot_consumed` tells you whether the attempt cost you a slot, but **it does not tell you what to do next** on its own. Two failures hand the slot back and still need different responses: one is ours to fix and a plain retry works, the other will fail again with the same URLs. Read the code, and use `slot_consumed` for the billing half of the sentence: | `code` | What happened | What to do | `slot_consumed` | | - | - | - | - | | `interrupted` | Processing stopped on our side. | Resubmit exactly as you sent it. | `false` | | `preparation_exhausted` | We could not produce a verifiable extraction in two independent attempts. Ours, not yours. | Resubmit exactly as you sent it. | `false` | | `discovery_failed` | We could not work out the domain's page kinds on our own. | Resubmit with `example_urls`. | `false` | | `unpreparable` | None of the pages you sent gave us usable HTML: blocked, a challenge page, or not HTML at all. | Try different example URLs from the same site. The same ones will fail again. | `false` | | `preparation_failed` | Extraction could not be built for a page kind. | Try other example URLs, or contact support. | `true` | | `verification_failed` | Extraction was built but did not meet the quality bar. | Contact support. | `true` | | `unsupported` | The domain cannot be handled. | Contact support before spending more slots on it. | `true` | ### `GET /v1/extract/prepared-domains` Every domain your account has submitted, with your allowance alongside it. ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/extract/prepared-domains?apikey=YOUR_ZENROWS_API_KEY" ``` ```json Response theme={"dark"} { "allowance": { "used": 0, "limit": 10, "remaining": 10, "month": "2026-09" }, "domains": [] } ``` This lists what **you** submitted, which is a different question from [which domains Extract serves](#which-domains-extract-serves). That one lists every prepared domain, however it got there. ### `GET /v1/extract/prepared-domains/allowance` How many domains you can still ask for this month, on its own. ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/extract/prepared-domains/allowance?apikey=YOUR_ZENROWS_API_KEY" ``` ```json Response theme={"dark"} { "used": 0, "limit": 10, "remaining": 10, "month": "2026-09" } ``` `month` is the UTC calendar month the count applies to; the allowance resets when it rolls over. A failure that returns your slot (`slot_consumed: false`) is not counted in `used`. ## Errors Alongside the [standard Zenrows error codes](/api-error-codes), these endpoints return: | Code | Status | Meaning | | - | - | - | | `invalid_argument` | `400` | The request body is missing or malformed. `detail` says what, and `reason` carries a machine-readable tag such as `invalid_domain`. | | `not_found` | `404` | This account has never submitted that domain. | | `in_progress` | `409` | That domain already has a preparation running, possibly someone else's. Poll its status instead of resubmitting. | | `idempotency_conflict` | `409` | The same `Idempotency-Key` was reused with a different body. | | `out_of_allowance` | `429` | No preparation slots left this month. Check `GET /v1/extract/prepared-domains/allowance`. | An invalid submission is rejected before it costs anything: ```json Response theme={"dark"} { "code": "invalid_argument", "detail": "Domain is required.", "reason": "invalid_domain", "status": 400, "title": "Invalid argument (invalid_argument)", "type": "https://docs.zenrows.com/api-error-codes#invalid_argument" } ``` # Introduction to Extract Source: https://docs.zenrows.com/extract/introduction Turn any web page into structured JSON in a single request with Zenrows Extract, which builds a tailored extraction for each website and keeps it working when the site's layout changes. Extract is in **Beta**. It works on any domain once that domain has been prepared, and you can ask for a new one to be prepared yourself. Any active Zenrows API key can use it, with no extra setup needed. Zenrows Extract turns a web page into structured JSON in a single request. Add `extract=auto` to your Fetch call, and Zenrows identifies the meaningful data on the page and returns it as clean JSON, together with the raw HTML. There are no selectors to write, no schema to define, and no parser to maintain, and the extraction adapts automatically when a website changes its layout. ## How It Works Unlike general-purpose parsing rules that apply the same logic to every website, Extract builds and maintains an extraction that's tailored to each individual site. Zenrows analyzes a domain's pages, identifies its meaningful data points, and keeps that extraction working as the site evolves, so you get richer and more complete fields than a one-size-fits-all approach can offer. ## Basic Usage Enable Extract by adding the `extract=auto` parameter to your Zenrows request: ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&extract=auto" ``` The response returns `Content-Type: application/json` with a `parsed` field containing the structured data, and an `html` field with the raw page for validation during Beta. Two optional parameters shape what comes back: [`extract_fields`](/extract/setup#selecting-the-fields-you-need) narrows `parsed` to the fields you name, and [`extract=standard`](/extract/setup#extraction-modes) swaps each website's own field names for a canonical schema shared across a vertical, on the API and currently for real estate only. ## Next Steps Full parameter reference: response shape, domain coverage, pricing, and FAQ. The deprecated general-purpose parser, and how to migrate an integration off it. # Extract Source: https://docs.zenrows.com/extract/sdk-overview Extract doesn't need a separate SDK. Use the same Fetch client for your language and pass extract=auto as a request parameter. Extract has no dedicated client or package. It's the same SDK client you already use for Fetch, with `extract=auto` set as a request parameter. ```python Python theme={"dark"} from zenrows import ZenRowsClient client = ZenRowsClient("YOUR_ZENROWS_API_KEY") url = "https://www.scrapingcourse.com/ecommerce/" response = client.get(url, params={"extract": "auto"}) print(response.json()) ``` The response comes back as JSON with a `parsed` field containing the structured data Zenrows identified on the page, plus the raw `html` for validation. Extract's other parameters ride in the same `params` map: `{"extract": "auto", "extract_fields": "products.name,products.price"}` returns only the fields you name, and `{"extract": "standard"}` picks the [canonical schema](/extract/setup#extraction-modes) instead of the website's own field names. What Extract does and how it works. Full parameter reference: response shape, domain coverage, pricing, and FAQ. # Extract Source: https://docs.zenrows.com/extract/setup Turn any web page into structured JSON in a single request with Zenrows Extract, which builds a tailored extraction for each website and keeps it working when the site's layout changes. Extract turns a web page into structured JSON in a single request. Add `extract=auto` to your Fetch call, and Zenrows identifies the meaningful data on the page and returns it as clean JSON, together with the raw HTML. There are no selectors to write, no schema to define, and no parser to maintain. The extraction adapts automatically when a website changes its layout. Extract is in **Beta** and works on domains Zenrows has prepared. Any active Zenrows API key can use it on those, with no extra setup needed, and you can ask for a new domain to be prepared yourself. ## How Extract works Unlike general-purpose parsing rules that apply the same logic to every website, Extract builds and maintains an extraction that's tailored to each individual site. Zenrows analyzes a domain's pages, identifies its meaningful data points, and keeps that extraction working as the site evolves, so you get richer and more complete fields than a one-size-fits-all approach can offer. That tailoring is per domain, so a domain has to be prepared before Extract can serve it. Any domain can be: see [Domain coverage](#domain-coverage). ## Basic usage Enable Extract by adding the `extract=auto` parameter to your Zenrows request: ```python Python theme={"dark"} # pip install requests import requests url = 'https://www.scrapingcourse.com/ecommerce/' apikey = 'YOUR_ZENROWS_API_KEY' params = { 'url': url, 'apikey': apikey, 'extract': 'auto', } response = requests.get('https://api.zenrows.com/v1/', params=params) print(response.json()) ``` ```javascript Node.js theme={"dark"} // npm install axios const axios = require('axios'); const url = 'https://www.scrapingcourse.com/ecommerce/'; const apikey = 'YOUR_ZENROWS_API_KEY'; axios({ url: 'https://api.zenrows.com/v1/', method: 'GET', params: { 'url': url, 'apikey': apikey, 'extract': 'auto', }, }) .then(response => console.log(response.data)) .catch(error => console.log(error)); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class APIRequest { public static void main(final String... args) throws Exception { String apiUrl = "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&extract=auto"; String response = Request.get(apiUrl) .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { client := &http.Client{} req, err := http.NewRequest("GET", "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&extract=auto", nil) if err != nil { log.Fatalln(err) } resp, err := client.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { log.Fatalln(err) } log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' url = URI.parse('https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&extract=auto') conn = Faraday.new() conn.options.timeout = 180 res = conn.get(url, nil, nil) print(res.body) ``` ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&extract=auto" ``` This example extracts structured data from the Scraping Course eCommerce page. Instead of raw HTML alone, you get a JSON response with the meaningful data Zenrows identified on the page, plus the original HTML for reference. ## Parameters Extract adds two parameters to a [Fetch](/fetch/api-reference) request. Every other Fetch parameter keeps working alongside them. | PARAMETER | TYPE | DEFAULT | DESCRIPTION | | - | - | - | - | | **extract** | `string` | | Turns Extract on and picks the extraction mode. Accepted values: `auto`, `native`, `standard`.
*Use-case: [Get the same field names across websites](#extraction-modes)* | | **extract\_fields** | `string` | | Comma-separated list of the fields to return inside `parsed`, dot-notated for fields inside a group. Omit it to receive every field.
*Use-case: [Return only the fields you need](#selecting-the-fields-you-need)* | ## Response A request with `extract=auto` returns `Content-Type: application/json` with two fields: ```json JSON theme={"dark"} { "html": "...", "parsed": { "page_context": { "current_page": 1, "results_per_page": 69, "total_pages": 12, "total_results": 188 }, "products": [ { "name": "Abominable Hoodie", "price": 69, "currency": "USD", "availability": "in_stock", "sku": "MH09", "product_id": "246", "product_type": "variable", "image_url": "https://www.scrapingcourse.com/.../mh09-blue_main.jpg", "product_url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/" } ] } } ``` * **`parsed`** contains the structured data extracted from the page. * **`html`** contains the raw HTML of the scraped page. It's included during the beta so you can validate the extracted data against the source. Field names are in plain English and reflect the data each website actually exposes, so different websites can return different fields for similar pages. The set of fields inside `parsed` stays stable across requests for a given website, and new fields may be added over time as extraction improves, so treat unknown fields as additive rather than breaking. The `html` field is included for validation during the beta, and the overall response shape may evolve once the beta ends, so build your data pipeline around `parsed` and treat `html` as a validation aid rather than a permanent contract. Extraction is fully automatic, and a field is only returned when it's present on the page. Always run test requests on your target pages and verify all the data you need is extracted before using Extract in production. ## Selecting the fields you need `parsed` carries every field Extract found on the page. Add `extract_fields` with a comma-separated list to get back only the ones your pipeline reads: ```python Python theme={"dark"} # pip install requests import requests url = 'https://www.scrapingcourse.com/ecommerce/' apikey = 'YOUR_ZENROWS_API_KEY' params = { 'url': url, 'apikey': apikey, 'extract': 'auto', 'extract_fields': 'products.name,products.price', } response = requests.get('https://api.zenrows.com/v1/', params=params) print(response.json()) ``` ```javascript Node.js theme={"dark"} // npm install axios const axios = require('axios'); const url = 'https://www.scrapingcourse.com/ecommerce/'; const apikey = 'YOUR_ZENROWS_API_KEY'; axios({ url: 'https://api.zenrows.com/v1/', method: 'GET', params: { 'url': url, 'apikey': apikey, 'extract': 'auto', 'extract_fields': 'products.name,products.price', }, }) .then(response => console.log(response.data)) .catch(error => console.log(error)); ``` ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&extract=auto&extract_fields=products.name%2Cproducts.price" ``` ```json Response theme={"dark"} { "html": "...", "parsed": { "products": [ { "name": "Abominable Hoodie", "price": 69 }, { "name": "Adrienne Trek Jacket", "price": 57 } ] } } ``` How the list is read: * **A bare name selects a whole group.** `extract_fields=products` returns every product with all of its fields. * **A dot path selects inside a group, element by element.** `products.name` returns the `name` of every product in the list, not only the first one. * **Spaces after commas are ignored.** `products.name, products.price` selects the same fields as `products.name,products.price`. * **Only `parsed` is filtered.** The `html` field always comes back in full. * **Filtering never adds a field.** Naming something the page doesn't provide doesn't produce an empty field, it just isn't there. It works the same way in every extraction mode, so a `standard` request can be narrowed with the canonical field names: `extract=standard&extract_fields=listings.title,listings.price`. Field names come from each website, so they differ from site to site in the default mode. Send one request without `extract_fields` first, read the names in `parsed`, then pin the ones you want. ### When none of the requested fields exist A name that matches nothing is ignored as long as at least one other name in the list does match, so a typo costs you that field quietly. When **no** name matches, the request fails with `400` ([`REQS004`](/api-error-codes#REQS004)) rather than returning an empty object, and the message lists the fields the page does offer: ```json Response theme={"dark"} { "code": "REQS004", "detail": "None of the fields requested in 'extract_fields' are available for this page (requested: prize). Available fields: page_context, page_context.current_page, page_context.results_per_page, page_context.total_pages, page_context.total_results, products, products.availability, products.currency, products.image_url, products.name, products.price, products.product_id, products.product_type, products.product_url, products.sku. Adjust 'extract_fields', or omit it to receive every field this page provides.", "instance": "/v1", "status": 400, "title": "None of the requested 'extract_fields' are available for this page (REQS004)", "type": "https://docs.zenrows.com/api-error-codes#REQS004" } ``` ## Extraction modes The value of `extract` also picks which extraction resolves for the page: | Value | Extraction | | - | - | | `auto` | The default, and the same extraction as `native`. | | `native` | The website's own extraction. Field names follow what that site exposes, so they are as rich and as specific as the page itself. | | `standard` | A canonical schema shared by every website in the same vertical. The same field names come back from every site, so pages from different websites line up without mapping. | Stay on `auto` unless you're merging pages from several websites into one table, which is the job `standard` exists for. The trade is richness for uniformity: a native extraction returns everything a given site exposes, and a standard one returns the agreed set. The mode is an API-level parameter. The playground in your [dashboard](https://app.zenrows.com) runs the default extraction and lists `standard` as coming soon, so pick a mode from a direct API request or from an SDK, which passes the parameter straight through. The difference is visible on two real-estate search pages from different websites. In the default mode, each site names things its own way, one calling floor area `construction_area_m2` and the other `area_sqm`, one splitting the price into `price` and `price_currency`, the other into `price_amount` and `price_currency`. In `standard`, both return the same field names: ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.casasyterrenos.com%2Fyucatan%2Fmerida%2Fcasas%2Fventa&extract=standard" ``` ```python Python theme={"dark"} # pip install requests import requests apikey = 'YOUR_ZENROWS_API_KEY' for url in [ 'https://www.casasyterrenos.com/yucatan/merida/casas/venta', 'https://www.lamudi.com.mx/yucatan/merida/for-sale/', ]: response = requests.get('https://api.zenrows.com/v1/', params={ 'url': url, 'apikey': apikey, 'extract': 'standard', }) listings = response.json()['parsed']['listings'] print(url, sorted(listings[0])) # the same field names on both sites ``` ```json Response theme={"dark"} { "html": "...", "parsed": { "search": { "location": "merida", "total_results": 169 }, "listings": [ { "title": "Venta de Casa en San jeronimo en Conkal", "listing_type": "venta", "property_type": "casa", "price": { "value": 4500000, "currency": "MXN" }, "bedrooms": 3, "bathrooms": 3, "living_area": 215, "area_unit": "m²", "address": "Conkal, Yucatán", "city": "Conkal", "postal_code": null, "latitude": null, "longitude": null, "agent_name": null, "image": "https://cdn.casasyterrenos.com/img_casas/26-Casas-Renta-Pueblo-Conkal-Conkal-Yucatan-3879000-SQzkaHpu.jpg", "listing_id": "3879000", "url": "/propiedad/casa-venta-conkal-pueblo-conkal-conkal-yuc-3879000" } ] } } ``` `standard` currently covers **real estate** only, both search results and listing pages. A page in any other vertical is rejected with `400` ([`REQS004`](/api-error-codes#REQS004)), so use `auto` or `native` everywhere else. Any value other than the three above is rejected before the page is even fetched, with `400` ([`REQS004`](/api-error-codes#REQS004)) and the message `Invalid value "..." for 'extract' parameter. Supported values: auto, native, standard`. ## Domain coverage Extract works on **any domain**, once that domain has been prepared: Zenrows learns the shapes of its pages and checks that extraction returns the right data. A prepared domain then works for every Zenrows API key, so coverage is per domain and never per account, and there is no allowlist to manage on your side. Many domains are prepared already, and the set grows as people ask for the sites they need. ### Checking whether a domain is ready `GET /v1/extract/domains` returns everything Extract serves right now: ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/extract/domains?apikey=YOUR_ZENROWS_API_KEY" ``` ```json Response theme={"dark"} { "domains": [ { "domain": "zillow.com", "verticals": ["Real Estate/Houses", "Real Estate/Real Estate Buying and Selling"], "warm": true }, { "domain": "amazon.com", "verticals": ["Shopping", "Technology & Computing/Consumer Electronics"], "warm": true } ] } ``` An integration can check this before sending a request instead of handling an error after one. See the [endpoint reference](/extract/endpoints#which-domains-extract-serves) for the full response. ### Asking for a domain If the domain you need isn't there, ask for it: ```bash cURL theme={"dark"} curl -X POST "https://api.zenrows.com/v1/extract/prepared-domains?apikey=YOUR_ZENROWS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "domain": "example-shop.com", "example_urls": ["https://example-shop.com/product/blue-shirt"] }' ``` Preparation usually takes minutes. Poll [`GET /v1/extract/prepared-domains/{domain}`](/extract/endpoints#getting-a-domain-prepared) until `status` is `ready`, then send your `extract=auto` request as normal. Example URLs are worth including: they tell Zenrows exactly which kinds of page you care about. Each account can prepare a set number of new domains per month; the [endpoint reference](/extract/endpoints#getting-a-domain-prepared) covers how to check what's left. If you send `extract=auto` for a domain that isn't prepared, the request fails with a `403` error ([`REQS007`](/api-error-codes#REQS007)) naming the domain, and the message tells you how to get it prepared. Nothing about your plan or your account changes that: preparation is per domain. ## Combining with other features Extract works on the final HTML of the page, so it combines with the scraping features you already use. For JavaScript-heavy websites, add JavaScript Rendering with a short wait so dynamic content finishes loading before extraction. For protected websites, add Premium Proxy: ```python Python theme={"dark"} # pip install requests import requests url = 'https://www.scrapingcourse.com/ecommerce/' apikey = 'YOUR_ZENROWS_API_KEY' params = { 'url': url, 'apikey': apikey, 'extract': 'auto', 'js_render': 'true', 'wait': '3000', 'premium_proxy': 'true', } response = requests.get('https://api.zenrows.com/v1/', params=params) print(response.json()) ``` ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&extract=auto&js_render=true&wait=3000&premium_proxy=true" ``` When `extract=auto` is set, it takes precedence over other output formats. `autoparse`, `css_extractor`, `response_type`, and `outputs` are ignored on that request. ## Extract vs. Autoparse vs. CSS Extractor | Method | Best for | Pros | Cons | | - | - | - | - | | **Extract** (`extract=auto`) | Any site where you want the richest data | Tailored to each website, adapts when the site's layout changes, richer fields | The domain has to be prepared first | | **Autoparse** (`autoparse=true`, deprecated) | Integrations already built on it | Works on any site, no setup, included at no extra cost | General-purpose rules, often returns page scaffolding rather than the data you want. [Migrate to Extract](/fetch/features/autoparse#migrate-to-extract) | | **CSS Extractor** (`css_extractor`) | Specific data points, single site, custom requirements | Full control, precise targeting | Requires HTML knowledge, selectors are site-specific | ## Pricing Extract is **free during Beta**. You only pay the standard Fetch cost of the underlying request, including JavaScript Rendering or Premium Proxy if you use them. You can monitor your Zenrows usage in multiple ways to stay informed about your account activity and prevent unexpected overages. **Dashboard monitoring**: View real-time usage statistics, remaining requests, success rates, and request history on your [Analytics Page](https://app.zenrows.com/overview#performance). You can also set up usage alerts in your [notification settings](https://app.zenrows.com/settings/notifications) to receive notifications when you approach your limits. **Programmatic monitoring**: For automated monitoring in your applications, call the `/v1/subscriptions/self/details` endpoint with your API key in the `X-API-Key` header. This returns real-time usage data that you can integrate into your monitoring systems. [Learn more about the usage endpoint](/fetch/features/other#plan-usage). **Response header monitoring**: Track your concurrency usage through response headers included with each request: * `Concurrency-Limit`: Your maximum concurrent requests * `Concurrency-Remaining`: Available concurrent request slots * `X-Request-Cost`: Cost of the current request * `X-Key-Credits-Remaining`: Lowest remaining credits across your API key's [credit caps](/first-steps/api-key-credit-caps), sent only for keys with at least one cap ## Frequently Asked Questions (FAQ) Autoparse (`autoparse=true`) applies general-purpose extraction rules that work the same way on every website, and it is deprecated. Extract (`extract=auto`) builds an extraction tailored to each website, which returns richer, more complete fields and adjusts itself when the website's layout changes. Extract needs a domain to be prepared first, which you can request; see [Migrate to Extract](/fetch/features/autoparse#migrate-to-extract) for the parameter swap. The request fails with a `403` error ([`REQS007`](/api-error-codes#REQS007)) naming the domain, and the message tells you how to get it prepared: [`POST /v1/extract/prepared-domains`](/extract/endpoints#getting-a-domain-prepared), then retry once it reports `ready`. Nothing about your plan or your account changes the outcome, since preparation is per domain. Yes. Add [`extract_fields`](#selecting-the-fields-you-need) with a comma-separated list, using dot paths for fields inside a group: `extract_fields=products.name,products.price`. A dot path applies to every element of a list, so that example returns the name and price of every product. Omit the parameter to receive every field the page provides. `extract=auto` (the default) and `extract=native` return the website's own extraction, so field names follow what that particular site exposes and differ from site to site. `extract=standard` returns a canonical schema shared by every website in the same vertical, so pages from different websites line up without mapping. `standard` currently covers real estate only, and modes are available on the API, not yet in the dashboard playground. See [Extraction modes](#extraction-modes). The response shape for an enabled website stays stable. Your integration keeps receiving the same fields even as Zenrows improves extraction behind the scenes. New fields may be added over time, so treat unknown fields as additive rather than breaking. No. Access is per domain, not per account. Any active Zenrows API key can use `extract=auto` on a domain once that domain is prepared, and any key can ask for a domain to be prepared. There's no separate signup or plan change required. Treat `html` as a validation aid rather than a permanent part of the contract. It's included during the beta so you can check the extracted data against the source page. Build your data pipeline around `parsed`, since the surrounding response shape may evolve once the beta ends. Yes. Extract works alongside JavaScript Rendering, Premium Proxy, and other request options. It takes precedence over other output formats, so `autoparse`, `css_extractor`, `response_type`, and `outputs` are ignored on a request that sets `extract=auto`. # Introduction to Fetch Source: https://docs.zenrows.com/fetch/api-reference Explore the full Zenrows Fetch reference, including endpoint parameters, authentication, pricing tiers, and feature documentation. Zenrows Fetch (formerly, Universal Scraper API) is a versatile tool designed to simplify and enhance the process of extracting data from websites. Whether you're dealing with static or dynamic content, our API provides a range of features to meet your scraping needs efficiently. With Premium Proxies, Zenrows gives you access to over 55 million residential IPs from 190+ countries, ensuring 99.9% uptime and highly reliable scraping sessions. Our system also handles advanced fingerprinting, header rotation, and IP management, **enabling you to scrape even the most protected sites without needing to manually configure these elements**. Zenrows makes it easy to bypass complex anti-bot measures, handle JavaScript-heavy sites, and interact with web elements dynamically (all with the right features enabled). ## Key Features ### Adaptive Stealth Mode Let Zenrows automatically choose the proper configuration for each request. Set `mode=auto`, and the API starts with the cheapest viable setup, then escalates to JavaScript rendering or Premium Proxies only when needed. So you get reliable extraction without manual tuning or overpaying. **When to use:** Use Adaptive Stealth Mode when scraping multiple or unknown sites, when you want to minimize maintenance as sites change, or when reliability matters more than fine-grained control over parameters. **Real-world scenarios:** * Production workloads across many different domains * Sites whose anti-bot or rendering requirements change over time * Ensuring successful extraction by starting with simple requests and automatically escalating to JS rendering or premium proxies only when needed **Additional options:** * You are billed only for the configuration that succeeds; failed attempts are not charged * Managed automatically: `js_render` and `premium_proxy` (you are also allowed to choose the `proxy_country`) * Other parameters (e.g. `js_instructions`, `custom_headers`) work alongside Adaptive Stealth Mode ### JavaScript Rendering Render JavaScript on web pages using a headless browser to scrape dynamic content that traditional methods might miss. **When to use:** Use this feature when targeting modern websites built with JavaScript frameworks (React, Vue, Angular), single-page applications (SPAs), or any site that loads content dynamically after the initial page load. **Real-world scenarios:** * E-commerce product listings that load items as you scroll * Dashboards and analytics platforms that render charts/data with JavaScript * Social media feeds that dynamically append content * Sites that hide certain content until JavaScript is rendered **Additional options:** * Wait times to ensure elements are fully loaded * Interaction with the page to click buttons, fill forms, or scroll * Screenshot capabilities for visual verification * CSS-based extraction of specific elements ### Premium Proxies Leverage a vast network of residential IP addresses across 190+ countries, ensuring a 99.9% uptime for uninterrupted scraping. **When to use:** Essential for accessing websites with sophisticated anti-bot systems, geo-restricted content, or when you consistently encounter blocks with standard datacenter IPs. **Real-world scenarios:** * Scraping major e-commerce platforms (Amazon, Walmart) * Accessing real estate listings (Zillow, Redfin) * Gathering pricing data from travel sites (Expedia, Booking.com) * Collecting data from financial or investment platforms **Additional options:** * Geolocation selection to access region-specific content * Automatic IP rotation to prevent detection ### Custom Headers Add custom HTTP headers to your requests for more control over how your requests appear to target websites. **When to use:** When you need to mimic specific browser behavior, set cookies, or a referer. **Real-world scenarios:** * Setting language preferences to get content in specific languages * Adding a referer to avoid being blocked by bot detection systems ### Session Management Use a session ID to maintain the same IP address across multiple requests for up to 10 minutes. **When to use:** When scraping multi-page flows or processes that require maintaining the same IP across multiple requests. **Real-world scenarios:** * Multi-step forms processes * Maintaining consistent session for search results and item visits ### Advanced Data Extraction Extract only the data you need with CSS selectors or automatic parsing. **When to use:** When you need specific information from pages and want to reduce bandwidth usage or simplify post-processing. **Real-world scenarios:** * Extracting pricing information from product pages * Gathering contact details from business directories * Collecting specific metrics from analytics pages ### Language agnostic While Python examples are provided, the API works with any programming language that can make HTTP requests. ## Parameter Overview Customize your scraping requests using the following parameters: | PARAMETER | TYPE | DEFAULT | DESCRIPTION | | - | - | - | - | | **apikey** `required` | `string` | [**Get Your Free API Key**](https://app.zenrows.com/register?p=free) | Your unique API key for authentication | | **url** `required` | `string` | | The URL of the page you want to scrape | | [**mode**](/fetch/features/adaptive-stealth-mode) | `string` | | Enables Adaptive Stealth Mode when set to `auto`.
*Use-case: [Adaptive Stealth Mode vs Manual Parameters](/fetch/features/adaptive-stealth-mode#adaptive-stealth-mode-vs-manual-configuration)* | | [**js\_render**](/fetch/features/js-rendering) | `boolean` | false | Enable JavaScript rendering with a headless browser. Essential for modern web apps, SPAs, and sites with dynamic content.
*Use-case: [Load dynamic pages (SPAs, dashboards, infinite scroll)](/fetch/features/js-rendering#when-to-use-javascript-rendering)* | | [**js\_instructions**](/fetch/features/js-instructions) | `string` | | Execute custom JavaScript on the page to interact with elements, scroll, click buttons, or manipulate content. Use when you need to perform actions before the content is returned.
*Use-case: [Submit forms or simulate user actions](/fetch/features/js-instructions#common-use-cases-and-workflows)* | | [**custom\_headers**](/fetch/features/headers) | `boolean` | false | Enables you to add custom HTTP headers to your request, such as cookies or referer, to better simulate real browser traffic or provide site-specific information.
*Use-case: [Simulate browser behavior for reliable access](/fetch/features/headers#referrer-simulation)* | | [**premium\_proxy**](/fetch/features/premium-proxy) | `boolean` | false | Route through residential IPs for protected access to sites with anti-bot protection.
*Use-case: [Access protected sites using residential IPs](/fetch/features/premium-proxy#basic-usage)* | | [**proxy\_country**](/fetch/features/proxy-country) | `string` | | Set the country of the IP used for the request (requires Premium Proxies). Use for accessing geo-restricted content or seeing region-specific content.
*Use-case: [Access geo-restricted content](/fetch/features/proxy-country#common-use-cases)* | | [**session\_id**](/fetch/features/other#session-id) | `integer` | | Maintain the same IP for multiple requests for up to 10 minutes. Essential for multi-step processes.
*Use-case: [Keep session/IP across requests](/fetch/features/other#session-id)* | | [**original\_status**](/fetch/features/other#original-http-code) | `boolean` | false | Return the original HTTP status code from the target page. Useful for debugging in case of errors.
*Use-case: [Debug failed requests](/fetch/features/other#original-http-code)* | | [**allowed\_status\_codes**](/fetch/features/other#return-content-on-error) | `string` | | Returns the content even if the target page fails with specified status codes. Useful for debugging or when you need content from error pages.
*Use-case: [Debug failed requests and return the failed page](/fetch/features/other#return-content-on-error)* | | [**wait\_for**](/fetch/features/wait-for) | `string` | | Wait for a specific CSS Selector to appear in the DOM before returning content. Essential for elements that load asynchronously.
*Use-case: [Capture elements that load at unpredictable times](/fetch/features/wait-for#basic-usage)* | | [**wait**](/fetch/features/wait) | `integer` | 0 | Wait a fixed amount of milliseconds after page load. Use for sites that load content in stages or have delayed rendering.
*Use-case: [Pause the request process after the initial page load](/fetch/features/wait#basic-usage)* | | [**block\_resources**](/fetch/features/block-resources) | `string` | | Block specific resources (images, fonts, etc.) from loading to speed up scraping and reduce bandwidth usage. Enabled by default, careful when changing it.
*Use-case: [Optimize performance and minimize bandwidth](/fetch/features/block-resources#basic-usage)* | | [**json\_response**](/fetch/features/json-response) | `string` | false | Capture network requests in JSON format, including XHR or Fetch data. Ideal for intercepting API calls made by the web page.
*Use-case: [Capture API calls and background requests](/fetch/features/json-response#basic-usage)* | | [**css\_extractor**](/fetch/features/css-extractor) | `string (JSON)` | | Extract specific elements using CSS selectors. Perfect for targeting only the data you need from complex pages.
*Use-case: [Extract only specific data fields](/fetch/features/css-extractor)* | | [**extract**](/extract/setup) | `string` | | Extract structured JSON data from the page automatically, with no selectors to write. Beta. Accepted values: `auto`, `native`, `standard`.
*Use-case: [Get structured data from a page](/extract/setup)* | | [**extract\_fields**](/extract/setup#selecting-the-fields-you-need) | `string` | | Comma-separated list of the fields `extract` should return, dot-notated for fields inside a group. Omit it to receive every field.
*Use-case: [Return only the fields you need](/extract/setup#selecting-the-fields-you-need)* | | [**autoparse**](/fetch/features/autoparse) *(deprecated)* | `boolean` | false | General-purpose structured data from HTML. Deprecated in favor of `extract`, which is tailored per website. Still works for existing integrations.
*Use-case: [Migrate to Extract](/fetch/features/autoparse#migrate-to-extract)* | | [**response\_type**](/fetch/features/markdown) | `string` | | Convert HTML to other formats (Markdown, Plaintext, PDF). Useful for content readability, storage, or to train AI models.
*Use-case: [Extract only specific formats](/fetch/features/markdown)* | | [**screenshot**](/fetch/features/screenshot) | `boolean` | false | Capture an above-the-fold screenshot of the page. Helpful for visual verification or debugging.
*Use-case: [Visual verification of page rendering](/fetch/features/screenshot)* | | [**screenshot\_fullpage**](/fetch/features/screenshot) | `boolean` | false | Capture a full-page screenshot. Useful for content that extends below the fold.
*Use-case: [Capture complete page content](/fetch/features/screenshot#capture-mode)* | | [**screenshot\_selector**](/fetch/features/screenshot) | `string` | | Capture a screenshot of a specific element using CSS Selector. Perfect for capturing specific components.
*Use-case: [Capture specific page elements](/fetch/features/screenshot#capture-mode)* | | [**screenshot\_format**](/fetch/features/screenshot#image-format-and-quality) | `string` | | Choose between `png` (default) and `jpeg` formats for screenshots.
*Use-case: [Choose appropriate image format](/fetch/features/screenshot#image-format-and-quality)* | | [**screenshot\_quality**](/fetch/features/screenshot#image-format-and-quality) | `integer` | | For JPEG format, set quality from `1` to `100`. Lower values reduce file size but decrease quality.
*Use-case: [Control image compression levels](/fetch/features/screenshot#image-format-and-quality)* | | [**outputs**](/fetch/features/output-filters) | `string` | | Specify which data types to extract from the scraped HTML.
*Use-case: [Extract only specific data fields](/fetch/features/output-filters)* | ## Pricing Zenrows uses credit-based pricing. Every plan gives you a monthly credit allowance shared across Fetch, Extract, Batch, and Browser Sessions. The Free plan starts at 5,000 credits/month; the first paid tier, Build B1, is \$19/month for 45,000 credits, enough for 45,000 basic requests a month. Enterprise plans scale beyond 45M credits/month. For complex or highly protected websites, enabling advanced features like JavaScript rendering (`js_render`) and Premium Proxies unlocks Zenrows' full potential, ensuring the best success rate possible. The credit cost depends on the complexity of the request. You only spend the credits the scraping tech you need actually requires. With Adaptive Stealth Mode (`mode=auto`), the same credit weights apply; you're billed only for the configuration that succeeds. If multiple internal attempts are needed before success, only the successful request is charged. * **Standard request:** 1 credit * **JS rendering:** 5 credits * **Premium proxies:** 10 credits * **Both (JS & proxies, protected):** 25 credits For the full plan table, credit costs per tier, and top-up pricing, see our [pricing documentation page](/first-steps/pricing). ### Concurrency Concurrency determines how many requests can run simultaneously, and scales by plan tier: | Tier | Concurrency Limit | | - | - | | Free | 5 | | Build | 20 | | Launch | 50 | | Growth | 100 | | Scale | 200 (S1 to S3), 300 (S4), 400 (S5) | | Enterprise | Custom (500 to 1,000+) | **Important notes about concurrency:** * Canceling requests on the client side does NOT immediately free up concurrency slots * The server continues processing canceled requests until completion * If you exceed your concurrency limit, you'll receive a `429 Too Many Requests` error **If response size is exceeded:** * You'll receive a `413 Content Too Large` error * No partial data will be returned when a size limit is hit **Strategies for handling large pages:** 1. **Use CSS selectors**: Target only the specific data you need with `css_extractor` parameter 2. **Use `response_type`**: Convert to markdown or plaintext to reduce size 3. **Disable screenshots**: If using `screenshot` features, these can significantly increase response size 4. **Segment your scraping**: Break down large pages into smaller, more manageable sections ## Response Headers Zenrows provides useful information through response headers: | Header | Description | Example Value | Usage | | - | - | - | - | | **Concurrency-Limit** | Maximum concurrent requests allowed by your plan | `20` | Monitor your plan's capacity | | **Concurrency-Remaining** | Available concurrent request slots | `17` | Adjust request rate dynamically | | **X-Request-Cost** | Cost of this request | `0.001` | Track balance consumption | | **X-Key-Credits-Remaining** | Lowest remaining credits across the API key's [credit caps](/first-steps/api-key-credit-caps). Only sent for keys with at least one cap | `12500` | Slow down before a key cap stops requests | | **X-Request-Id** | Unique identifier for this request | `67fa4e35647515d8ad61bb3ee041e1bb` | Include when contacting support | | **Zr-Final-Url** | The final URL after any redirects occurred during the request | `https://example.com/page?id=123` | Track redirects | Why these headers matter: * **Monitoring usage**: Track your concurrent usage and stay within limits * **Support requests**: When reporting issues, always include the `X-Request-Id` for faster troubleshooting * **Cost tracking**: The `X-Request-Cost` helps you monitor your usage per request * **Redirection tracking**: `Zr-Final-Url` shows where you ended up after any redirects ## Additional Considerations Beyond the core features and limits, these additional aspects are important to consider when using Fetch: ### Cancelled Request Behavior When you cancel a request on the client side: * The server **continues processing** the request until completion * The concurrency slot remains occupied for up to 3 minutes * This can result in unexpected `429 Too Many Requests` errors * Implement request timeouts carefully to avoid depleting concurrency slots ### Security Best Practices To keep your Zenrows integration secure: * Store API keys as environment variables, never hardcode them * Monitor usage patterns to detect unauthorized use * Rotate API keys periodically for critical applications ### Regional Performance Optimization To optimize performance based on target website location: * Consider the geographical distance between your servers and the target website * For global applications, distribute scraping across multiple regions - the system does it by default * Monitor response times by region to identify optimization opportunities * For region-specific content, use the appropriate `proxy_country` parameter ### Compression Support Zenrows API supports response compression to optimize bandwidth usage and improve performance. Enabling compression offers several benefits for your scraping operations: * **Reduced latency**: Smaller response sizes mean faster data transfer times * **Lower bandwidth consumption**: Minimize data transfer costs and usage * **Improved client performance**: Less data to process means reduced memory usage Zenrows supports the following compression encodings: `gzip`, `deflate`, `br`. To use compression, include the appropriate `Accept-Encoding` header in your requests. Most HTTP clients already compress the request automatically. But you can also provide simple options to enable it: ```python Python (Requests) theme={"dark"} import requests response = requests.get( "https://api.zenrows.com/v1/", params={ "apikey": "YOUR_API_KEY", "url": "https://example.com" }, # Python Requests uses compression by default # headers={"Accept-Encoding": "gzip, deflate"} ) ``` ```javascript Javascript (Axios) theme={"dark"} const axios = require('axios'); const response = await axios.get('https://api.zenrows.com/v1/', { params: { apikey: 'YOUR_API_KEY', url: 'https://example.com' }, headers: { 'Accept-Encoding': 'gzip, deflate', // axios does not support br by default }, decompress: true, // Enables automatic handling of compression }); ``` ```bash cURL theme={"dark"} curl --compressed "https://api.zenrows.com/v1/?apikey=YOUR_API_KEY&url=https://example.com" ``` Most modern HTTP clients automatically handle decompression, so you'll receive the uncompressed content in your response object without any additional configuration. # Common Use Cases & Recipes Source: https://docs.zenrows.com/fetch/common-use-cases Browse ready-to-use web scraping recipes for anti-bot bypass, JavaScript rendering, data extraction, and other common scraping scenarios. This guide shows you the most common web scraping scenarios and the exact parameters you need for each one. Use these ready-to-implement recipes to quickly solve your specific scraping challenges with Fetch. ## Use Case Overview | Use Case | Key Parameters | When You Need This | | - | - | - | | Load dynamic pages (SPAs, dashboards, infinite scroll) | `js_render`, `wait_for`, `block_resources`, `js_instructions` | When content loads via JavaScript or requires user interaction | | Keep session/IP across requests | `session_id`, `custom_headers` | For multi-step workflows like account access → search → checkout | | Submit forms or simulate user actions | `js_instructions`, `js_render`, `wait_for` | When you need to click buttons, fill forms, or navigate pages | | Access geo-restricted content | `premium_proxy`, `proxy_country` | When content varies by location or is region-locked | | Handle pagination and infinite scroll | `js_render`, `js_instructions`, `wait_for` | When content loads dynamically as you scroll or click "Load More" | | Extract only specific data fields | `extract`, `css_extractor`, `outputs` | When you want structured data instead of full HTML | | Capture API calls and background requests | `json_response`, `js_render`, `wait_for` | When the data you need comes from AJAX/XHR requests | | Bypass anti-bot protection | `premium_proxy`, `js_render`, `custom_headers`, `session_id` | When sites use Cloudflare or other bot detection | | Debug failed requests | `original_status`, `allowed_status_codes`, `screenshot` | When requests fail and you need to understand why | | Optimize performance and costs | `block_resources`, `outputs`, `wait_for` | When you want faster responses and lower bandwidth usage | ## Code Recipes ### 1. Search for products using the search form This example shows how to automatically fill and submit a search form, then wait for the search results to load. ```python Python theme={"dark"} import requests url = "https://api.zenrows.com/v1/" params = { "apikey": "YOUR_ZENROWS_API_KEY", "url": "https://www.scrapingcourse.com/ecommerce/", "js_render": "true", "js_instructions": """[ {"wait_for":"#wp-block-search__input-1"}, {"fill":["#wp-block-search__input-1","hoodie"]}, {"wait":500}, {"click":"#block-2 > form > div > button"}, {"wait_for":"#main"} ]""", "premium_proxy": "true", } response = requests.get(url, params=params) print(response.text) ``` The `js_instructions` parameter fills the search field with "hoodie", clicks the search button, and waits for the main content area to load with search results before returning the content. Head to the [JS Instructions Documentation](/fetch/features/js-instructions) for more details. ### 2. Maintain a Session Across Requests Use the same `session_id` value to maintain cookies and session state across multiple requests. ```python Python theme={"dark"} import requests # Define session ID and API endpoint session_id = "1234" url = "https://api.zenrows.com/v1/" # First request establishes a session params1 = { "apikey": "", "url": "https://www.scrapingcourse.com/", "session_id": session_id } response1 = requests.get(url, params=params1) # Second request reuses the same session params2 = { "apikey": "", "url": "https://www.scrapingcourse.com/pagination", "session_id": session_id } response2 = requests.get(url, params=params2) ``` This approach is essential for workflows where you need to maintain the same IP across requests. Learn more about the `session_id` parameter [here](/fetch/features/other#session-id). ### 3. Extract Specific Fields Only Instead of getting the full HTML, extract only the data you need using CSS selectors. ```python Python theme={"dark"} params = { "apikey": "", "url": "https://www.scrapingcourse.com/ecommerce/", "css_extractor": """{"product_name":".product-name","price":".price"}""", } ``` Returns clean, structured data: ```json JSON expandable theme={"dark"} { "price": [ "$69.00", "$57.00", "$48.00", "$24.00", "$74.00", "$7.00", "$45.00", "$69.00", "$40.00", "$42.00", "$34.00", "$32.50", "$20.00", "$22.00", "$39.00", "$45.00" ], "product_name": [ "Abominable Hoodie", "Adrienne Trek Jacket", "Aeon Capri", "Aero Daily Fitness Tee", "Aether Gym Pant", "Affirm Water Bottle", "Aim Analog Watch", "Ajax Full-Zip Sweatshirt", "Ana Running Short", "Angel Light Running Short", "Antonia Racer Tank", "Apollo Running Short", "Arcadio Gym Short", "Argus All-Weather Tank", "Ariel Roll Sleeve Sweatshirt", "Artemis Running Short" ] } ``` Check the [CSS Extractor Documentation](/fetch/features/css-extractor) for more details. ### 4. Scrape Localized Content Access geo-restricted content or get localized pricing by routing your request through specific countries. ```python Python theme={"dark"} params = { "apikey": "", "url": "https://www.scrapingcourse.com/ecommerce/", "premium_proxy": "true", "proxy_country": "de" } ``` This example routes your request through Germany, allowing you to see content as it appears to German users. Find more details about geolocation on the [Proxy Country Documentation](/fetch/features/proxy-country). ### 5. Debugging a Blocked Request When requests fail, use these parameters to understand what's happening without losing the response data. #### Check the actual HTTP status Use the `original_status` parameter to see the real HTTP status code the target page returns. ```python Python theme={"dark"} params = { "apikey": "", "url": "https://example.com/blocked", "original_status": "true", } response = requests.get('https://api.zenrows.com/v1/', params=params) print(response) ``` Check more about the `original_status` parameter [here](/fetch/features/other#original-http-code). #### Allow specific error status codes Use the `allowed_status_codes` parameter to receive the actual HTML content even when the page returns error status codes. ```python Python theme={"dark"} params = { "apikey": "", "url": "https://example.com/blocked", "allowed_status_codes": "403,404,500", } response = requests.get('https://api.zenrows.com/v1/', params=params) print(response.text) ``` Find out more on the [Allowed Status Codes Documentation](/fetch/features/other#return-content-on-error). #### Add custom headers Use custom headers to simulate browser behavior for more reliable access. ```python Python theme={"dark"} params = { "apikey": "", "url": "https://example.com/blocked", } headers = { 'cookie': 'your-cookie', 'referer': 'https://www.google.com', } response = requests.get('https://api.zenrows.com/v1/', params=params, headers=headers) print(response.text) ``` Zenrows handles all browser-based headers. To find out more about it, check our [Headers Documentation](/fetch/features/headers). ## What's Next Ready to implement these recipes? Here are your next steps: * Explore the [Parameter Reference](/fetch/api-reference#parameter-overview) * Follow the [First Request Guide](/fetch/first-request) * Jump back to [Welcome](/first-steps/welcome) # Fetch FAQ: Cookies, Proxies, Output Source: https://docs.zenrows.com/fetch/faq Find answers to frequently asked questions about Zenrows API features, including cookies, session management, proxies, output formats and other common questions. Headers, including cookies, returned by the target website are prefixed with Zr- and included in all our responses. Suppose you are scraping a website that requires session cookies for authentication. By capturing the Zr-Cookies header from the initial response, you can include these cookies in your subsequent requests to maintain the session and access authenticated content. ```plaintext theme={"dark"} Zr-Content-Encoding: gzip Zr-Content-Type: text/html Zr-Cookies: _pxhd=Bq7P4CRaW1B... Zr-Final-Url: https://www.example.com/ ``` You could send those cookies in a subsequent request as [Custom Headers](/fetch/features/headers) and also use [session\_id](https://docs.zenrows.com/fetch/features/other#session-id) to keep the same IP for up to 10 minutes. By following this process, you can handle sessions and access restricted areas of the website seamlessly. If you need to scrape data from a website that requires login authentication, you can log in or register and access content behind a login. However, due to privacy and legal reasons, we offer limited support for these cases. Login and registration work like regular forms and can be treated as such. There are two main methods to send forms: 1. Send [POST requests](/fetch/faq#how-do-i-send-post-requests). 2. Fill in and submit a form using [JavaScript Instructions](/fetch/features/js-instructions). ```bash theme={"dark"} {"fill": [".input-selector", "website_username"]} // Fill the username input {"fill": [".input-selector", "website_password"]} // Fill the password input ``` All requests will return headers, including the [session cookies](/fetch/faq#can-i-get-cookies-from-the-responses). By using these cookies in subsequent requests, you can operate as a logged-in user. Additionally, you can include a [Session ID](/fetch/features/other#session-id) to maintain the same IP address for up to 10 minutes. Zenrows is a scraping tool, not a VPN. If your goal is to log in once and browse the internet with the same IP, you may need a different service. Suppose you need to perform multiple actions on a website that requires maintaining the same session/IP. You can use the [Session ID](/fetch/features/other#session-id) parameter to maintain the same IP between requests. Zenrows will store the IP for 10 minutes from the first request with that ID. All subsequent requests with that ID will use the same IP. However, session\_id will not store any other request data, such as session cookies. You will receive those cookies as usual and can decide which ones to send on the next request. Multiple Session IDs can run concurrently, with no limit to the number of sessions. Each plan comes with a concurrency limit that scales with your plan tier. See the [Concurrency, Support, and Rollover table](/first-steps/pricing#concurrency-support-and-rollover) for exact numbers. Requests up to that limit can run simultaneously, significantly improving speed. Sending requests above that limit will result in a 429 Too Many Requests error. We wrote a [guide on using concurrency](/fetch/features/concurrency#using-concurrency) that provides more details, including examples in Python and JavaScript. The same principles apply to other languages and libraries. There are different ways to approach submitting forms on a website when you need to retrieve data. POST Requests: The most straightforward way for non-secured endpoints is to send a [POST request](/fetch/faq#how-do-i-send-post-requests) as the page does. You can examine and replicate the requests in the browser DevTools. Imitate User Behavior Using JavaScript Instructions: Use [JavaScript Instructions](/fetch/features/js-instructions) to visit pages protected by anti-bot solutions and interact with them. This includes filling in inputs, clicking buttons, and performing other actions. ## Common Issues with CSS Selectors One of the most common issues users encounter when working with CSS Selectors in web scraping is improper encoding. CSS Selectors need to be correctly encoded to be recognized and processed by the API. You can use Zenrows' [Playground](https://app.zenrows.com/fetch) or an online tool to properly encode your CSS Selectors before sending them in a request. ## Example of Using a CSS Selector Let's say you want to extract content from the `.my-class` CSS selector and store it in a property named `test`. You would encode the selector and include it in your request like this: ```bash theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=YOUR_URL&css_extractor=%257B%2522test%2522%253A%2520%2522.my-class%2522%257D" ``` ## Troubleshooting CSS Selector Issues If you're still getting empty responses or the parser reports an error: 1. **Check the Raw HTML:** Request the plain HTML to see if the content served by the website differs from what you see in your browser. Some websites serve different content based on the user's location, device, or other factors. 2. **Verify the Selector:** Ensure the selector you're using is correct by testing it in your browser's Developer Tools (e.g., using Chrome's Console with `document.querySelectorAll(".my-class")`). 3. **Review the Documentation:** Refer to the Zenrows [documentation](/fetch/features/css-extractor) for detailed information on using CSS Selectors with the API. If the HTML looks correct, the selector works in the browser, but the parser still fails, contact us, and we'll help you troubleshoot the issue. ## See Also For comprehensive examples of working with complex layouts and advanced selector techniques, check out our [Advanced CSS Selector Examples](/fetch/troubleshooting/advanced-css-selector-examples) guide. `session_id` won't store any request data, such as session cookies. You will get those back as usual and decide which ones to send on the next request. Once you've extracted data using Zenrows, you might want to store it in CSV format. For simplicity, we'll focus on a single URL and save the data to one file. In real-world scenarios, you might need to handle multiple URLs and aggregate the results. To start, we'll explore how to export data to CSV using both Python and JavaScript. ## From JSON using Python If you've obtained JSON output from Zenrows, with [Extract](/extract/setup) (`extract=auto`) or with the deprecated `autoparse` parameter, you can use Python to convert this data into a CSV file. The example below uses `autoparse`, which is deprecated. For new work use `extract=auto`; the flattening code is the same, only the JSON shape changes. See [Migrate to Extract](/fetch/features/autoparse#migrate-to-extract). The Pandas library will help us flatten nested JSON attributes and save the data as a CSV file. Here's a sample Python script: ```python scraper.py theme={"dark"} # pip install requests pandas import requests import json import pandas as pd url = "https://www.zillow.com/san-francisco-ca/" apikey = "YOUR_ZENROWS_API_KEY" params = {"autoparse": True, "url": url, "apikey": apikey} response = requests.get("https://api.zenrows.com/v1/", params=params) content = json.loads(response.text) data = pd.json_normalize(content) data.to_csv("result.csv", index=False) ``` You can also adjust the `json_normalize` function to control how many nested levels to flatten and rename fields. For instance, to flatten only one inner level and remove `latLong` from latitude and longitude fields: ```python theme={"dark"} data = pd.json_normalize(content, max_level=1).rename( columns=lambda x: x.replace("latLong.", "")) ``` ## From HTML using Python When dealing with HTML output without the `autoparse` feature, you can use BeautifulSoup to parse the HTML and extract data. We'll use the example of an eCommerce site from [Scraping Course](https://www.scrapingcourse.com/ecommerce/). Create a dictionary for each product with essential details, then use Pandas to convert this list of dictionaries into a DataFrame and save it as a CSV file. Here's how to do it: ```python scraper.py theme={"dark"} # pip install requests beautifulsoup4 pandas import requests from bs4 import BeautifulSoup import pandas as pd url = "https://www.scrapingcourse.com/ecommerce/" apikey = "YOUR_ZENROWS_API_KEY" params = {"url": url, "apikey": apikey} response = requests.get("https://api.zenrows.com/v1/", params=params) soup = BeautifulSoup(response.content, "html.parser") content = [{ "product_name": product.select_one(".product-name").text.strip(), "price": product.select_one(".price").text.strip(), "rating": product.select_one(".rating").text.strip() if product.select_one(".rating") else "N/A", "link": product.select_one(".product-link").get("href"), } for product in soup.select(".product")] data = pd.DataFrame(content) data.to_csv("result.csv", index=False) ``` ## From JSON using JavaScript For JavaScript and Node.js, you can use the `json2csv` library to handle the JSON to CSV conversion. After getting the data, we will parse it with a `flatten` transformer. As the name implies, it will flatten the nested structures inside the JSON. Then, save the file using `writeFileSync`. Here's an example using the Zenrows Fetch with Node.js: ```javascript scraper.js theme={"dark"} // npm install zenrows json2csv const fs = require("fs"); const { Parser, transforms: { flatten }, } = require("json2csv"); const { ZenRows } = require("zenrows"); (async () => { const client = new ZenRows("YOUR_ZENROWS_API_KEY"); const url = "https://www.scrapingcourse.com/ecommerce/"; const { data } = await client.get(url, { autoparse: "true" }); const parser = new Parser({ transforms: [flatten()] }); const csv = parser.parse(data); fs.writeFileSync("result.csv", csv); })(); ``` ## From HTML using JavaScript For extracting data from HTML without `autoparse` you can use the cheerio library to parse the HTML and extract relevant information. We'll use the [Scraping Course eCommerce](https://www.scrapingcourse.com/ecommerce/) example for this task: As with the Python example, we will use AutoScout24 to extract data from HTML without the autoparse feature. For that, we will get the plain result and load it into cheerio. It will allow us to query elements as we would in the browser or with jQuery. We will return an object with essential data for each car entry in the list. Parse that list into CSV using `json2csv`, and no flatten is needed this time. And lastly, store the result. These last two steps are similar to the autoparse case. ```javascript scraper.js theme={"dark"} // npm install zenrows json2csv cheerio const fs = require("fs"); const cheerio = require("cheerio"); const { Parser } = require("json2csv"); const { ZenRows } = require("zenrows"); (async () => { const client = new ZenRows("YOUR_ZENROWS_API_KEY"); const url = "https://www.scrapingcourse.com/ecommerce/"; const { data } = await client.get(url); const $ = cheerio.load(data); const content = $(".product").map((_, product) => ({ product_name: $(product).find(".product-name").text().trim(), price: $(product).find(".price").text().trim(), rating: $(product).find(".rating").text().trim() || "N/A", link: $(product).find(".product-link").attr("href"), })) .toArray(); const parser = new Parser(); const csv = parser.parse(content); fs.writeFileSync("result.csv", csv); })(); ``` If you encounter any issues or need further assistance with your scraper setup, please contact us, and we'll be happy to help! We'll explore popular use cases for scraping, such as lists, tables, and product grids. Use these as inspiration and a guide for your scrapers. ## Scraping from Lists We will use the Wikipedia page on Web scraping for testing. A section at the bottom, "See also", contains links in a list. We can get the content by using the [CSS selector](/fetch/features/css-extractor) for the list items: `{"items": ".div-col > ul li"}`. That will get the text, but what of the links? To access attributes, we need a non-standard syntax for the selector: `@href`. It won't work with the previous selector since the last item is the `li` element, which does not have an `href` attribute. So we must change it for the link element: `{"links": ".div-col > ul a @href"}`. CSS selectors, in some languages, must be [encoded](/fetch/faq#how-to-encode-urls) to avoid problems with URLs. ```bash theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FWeb_scraping&css_extractor=%257B%2522items%2522%253A%2520%2522.div-col%2520%253E%2520ul%2520li%2522%252C%2520%2522links%2522%253A%2520%2522.div-col%2520%253E%2520ul%2520a%2520%2540href%2522%257D" ``` Our [Playground](https://app.zenrows.com/fetch) can help you write and test the selectors and output code in several languages. Extract Data from Wikipedia Lists ## Scraping from Tables Assuming regular tables (no empty cells, rows with fewer items, and others), we can extract table data with CSS selectors. We'll use a list of countries, the first table on the page, the one with the class `wikitable`. To extract the rank, which is the first column, we can use `"table.wikitable tr > :first-child"`. It will return an array with 243 items, 2 header lines, and 241 ranks. For the country name, second column, something similar but adding an `a` to avoid capturing the flags: `"table.wikitable tr > :nth-child(2) a"`. In this case, the array will have one less item since the second heading has no link. That might be a problem if we want to match items by array index. ```bash theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fen.m.wikipedia.org%2Fwiki%2FList_of_countries_and_dependencies_by_population&css_extractor=%257B%2522rank%2522%253A%2520%2522table.wikitable%2520tr%2520%253E%2520%253Afirst-child%2522%252C%2520%2522countries%2522%253A%2520%2522table.wikitable%2520tr%2520%253E%2520%253Anth-child%282%29%2520a%2522%257D" ``` Outputs: ```json theme={"dark"} { "countries": ["Country or dependent territory", "China", "India", ...], "rank": ["Rank", "-", "1", "2", ...] } ``` As stated above, this might prove difficult for non-regular tables. For those, we might prefer to get the Plain HTML and [scrape the content with a tool or library](/zenrows-academy/how-to-extract-data#using-external-libraries) so we can add conditionals and logic. This example lists items by column, not row, which might prove helpful in various cases. However, there are no easy ways to extract structured data from tables using CSS Selectors and group it by row. ## Scraping from Product Grids As with the tables, non-regular grids might cause problems. We'll scrape the price, product name, and link from an online store. By manually searching the page's content, we arrive at cards with the class `.product`. Those contain all the data we want. It is essential to avoid duplicates, so we have to use some precise selectors. For example, `".product-item .product-link @href"` for the links. We added the `.product-link` class because it is unique to the product cards. The same goes for name and price, which also have unique classes. All in all, the final selector would be: ```json theme={"dark"} { "links": ".product-item .product-link @href", "names": ".product-item .product-name", "prices": ".product-item .product-price" } ``` Several items are on the page at the time of this writing. And each array has the same number of elements, so everything looks fine. If we were to group them, we could zip the arrays. For example, in python, taking advantage of the auto-encoding that `requests.get` does to parameters. Remember to [encode](/fetch/faq#how-to-encode-urls) the URL and CSS extractor for different scenarios when that is not available. ```python scraper.py theme={"dark"} # pip install requests import requests import json zenrows_api_base = "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY" url = "https://www.scrapingcourse.com/ecommerce/" css_extractor = """{ "links": ".product .product-link @href", "names": ".product .product-name", "prices": ".product .product-price" }""" response = requests.get(zenrows_api_base, params={ "url": url, "css_extractor": css_extractor}) parsed_json = json.loads(response.text) result = zip(parsed_json["links"], parsed_json["names"], parsed_json["prices"]) print(list(result)) # [('/products/product1', 'Product 1', '$10.00'), ... ] ``` Remember that this approach won't work properly if, for example, some products have no price. Not all the arrays would have the same length, and the zipping would misassign data. Getting the Plain HTML and parsing the content with a library and custom logic is a better solution for those cases. If you encounter any problems or cannot correctly set up your scraper, contact us, and we'll help you. Zenrows allows you to send [Custom Headers](/fetch/features/headers) on requests in case you need to scrape a website that requires specific headers. However, it's important to test the success rate when changing them. Zenrows automatically manages certain headers, especially those related to the browser environment, such as User-Agent. Defensive systems inspect headers as a whole, and not all browsers use the same ones. If you choose to send custom headers, ensure the rest of the headers match accordingly. By default, POST requests use `application/x-www-form-urlencoded`. To send JSON data, you need to add the `Content-Type: application/json` header manually, though some software/tools may do this automatically. Before trying on your target site, we recommend using a testing site like httpbin.io to verify that the parameters are sent correctly. Ensure that the parameters are sent and the format is correct. If in doubt, switch between both modes to confirm that the changes are applied correctly. For more info on POST requests, see [How do I send POST requests?](/fetch/faq#how-do-i-send-post-requests). Send [POST requests](https://docs.zenrows.com/fetch/features/other#post-/-put-requests) using your chosen programming language. Zenrows will transparently forward the data to the target site. Before trying on your target site, we recommend using a testing site like httpbin.io to verify that the parameters are sent correctly. Testing is important because not all languages and tools handle POST requests the same way. Ensure that the parameters and format are correct. By default, browsers send content as `application/x-www-form-urlencoded`, but many sites expect JSON content, requiring the `Content-Type: application/json header`. When working with the Zenrows Fetch, it's crucial to encode your target URLs, especially if they contain query parameters. Encoding ensures that your URLs are correctly interpreted by the API, avoiding potential conflicts between the target URL's parameters and those used in the API request. Consider the following URL example: `https://www.scrapingcourse.com/ecommerce/?course=web-scraping§ion=advanced` If you were to send this URL directly as part of your API request without encoding, and you also include the `premium_proxy` parameter, the request might look something like this: ```bash theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https://www.scrapingcourse.com/ecommerce/?course=web-scraping§ion=advanced&premium_proxy=true" ``` In this scenario, the API might incorrectly interpret the `course` and `section` parameters as part of the API's query string rather than the target URL. This could lead to errors or unintended behavior. To avoid such issues, you should encode your target URL before including it in the API request. URL encoding replaces special characters (like `&`, `?`, and `=`) with a format that can be safely transmitted over the internet. Here's how you can encode the URL in Python: ```python encoder.py theme={"dark"} import urllib.parse encoded_url = urllib.parse.quote("https://www.scrapingcourse.com/ecommerce/?course=web-scraping§ion=advanced") ``` After encoding, your Fetch request would look like this: ```bash theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F%3Fcourse%3Dweb-scraping%26section%3Dadvanced&premium_proxy=true" ``` Many HTTP clients, such as `axios` (JavaScript) and `requests` (Python), automatically encode URLs for you. However, if you are manually constructing requests or using a client that doesn't handle encoding, you can use programming language functions or online tools to encode your URLs. For quick manual encoding, you can use an online tool, but remember that this method is not scalable for automated processes. If you are scraping a site with high-security measures and encounter blocks even with Premium Proxies and JS Render enabled, try these steps to unblock your requests: Use [geotargeting](/fetch/features/proxy-country) by selecting a country for the proxy, e.g., `proxy_country=us`. Many sites respond better to proxies close to their operation centers. Implement [Wait For Selector](/fetch/features/wait-for) to have the scraper look for specific content before returning. This feature can change how the system interacts with the site and might help unblock the request. Adjust the [Block Resources](/fetch/features/block-resources) settings. Zenrows blocks certain resources by default, such as CSS or images, to speed up scraping. Use your browser's DevTools to identify other resources to block, such as media or xhr (`block_resources=stylesheet,image,media,xhr`). Alternatively, disable blocking by setting it to false (`block_resources=none`). Many websites inspect the headers of a request to determine if it is coming from a legitimate browser. Adding custom headers to mimic normal browser behavior can help bypass these checks. Refer to our documentation on [Custom Headers](/fetch/features/headers) for more details. Combining these methods may yield the expected results. For high-security endpoints or inner pages, you may need to simulate a typical user session to avoid detection. First, obtain session cookies from a less protected page on the same site. This step mimics the initial user interaction with the site. Then, use these session cookies to access the more secure target page. Additionally, you can use the [Session ID](/fetch/features/other#session-id) feature to maintain the same IP address for up to 10 minutes, ensuring consistency in your requests and reducing the likelihood of being blocked. ## Understanding Proxy Types: Data Center vs. Residential IPs When it comes to web scraping proxies, there are two main types of IPs you can use: data center and residential. 1. **Data Center IPs:** These are IP addresses provided by cloud service providers or hosting companies. They are typically fast and reliable, but because they are easily recognizable as belonging to data centers, they are more likely to be blocked by websites that have anti-scraping measures in place. 2. **Residential IPs:** These IP addresses are assigned by Internet Service Providers (ISPs) to real residential users. Since they appear as regular users browsing the web, they are much harder to detect and block. This makes residential IPs particularly valuable when scraping sites with strong anti-bot protections, like Google or other heavily guarded domains. ## How Zenrows Uses Residential IPs By default, Zenrows uses data center connections for your requests. However, if you're facing blocks or need to scrape highly protected websites, you can opt for residential IPs by setting the `premium_proxy` parameter to `true`. This will route your request through a residential IP, significantly increasing your chances of success. It's important to note that using residential IPs comes with an additional cost due to the higher value and lower detection rate of these proxies. You can check out more about Premium Proxies [here!](/fetch/features/premium-proxy) ## Example of a Request with Residential IPs Here's how you can make a request using a residential IP: ```bash theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=YOUR_URL&premium_proxy=true" ``` In cases where you're also targeting content localized to specific regions, Zenrows supports [geotargeting](/first-steps/faq#what-is-geolocation-and-what-are-all-the-premium-proxy-countries) with residential IPs, allowing you to specify the country of the IP. ## Troubleshooting Blocks If you continue to experience blocks even with residential IPs, feel free to contact us, and we'll work with you to find a solution. Autoparse is deprecated. Use [Extract](/extract/setup) (`extract=auto`) instead, which builds a tailored extraction per website. The parameter still works for existing integrations, see [Migrate to Extract](/fetch/features/autoparse#migrate-to-extract). ## Simplifying Data Extraction with Autoparse Zenrows offers a powerful feature called Autoparse, designed to simplify the process of extracting structured data from websites. This feature leverages custom parsers allowing you to easily retrieve data in a structured JSON format rather than raw HTML. ## How It Works By default, when you call the Zenrows API, the response will be in Plain HTML. However, when you activate the `autoparse` parameter, the API will automatically parse the content of supported websites and return the data as a JSON object. This makes it much easier to work with the data, especially when dealing with complex websites that require extensive parsing logic. ## Example of a Request with Autoparse Here's how you can make an API call with the Autoparse feature enabled: ```bash theme={"dark"} curl "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=YOUR_URL&autoparse=true" ``` ## Limitations and Troubleshooting 1. **Supported Domains:** Only a limited set of sites has a dedicated parser. On every other site, Autoparse returns the JSON objects embedded in the page, which may be empty, incomplete, or not the data you asked for. [Extract](/extract/setup) covers any prepared domain and returns named fields instead. 2. **Fallback to HTML:** If you find that Autoparse doesn't return the desired results, you can simply remove the `autoparse` parameter and try the request again. This will return the plain HTML response, allowing you to manually parse the data as needed. Enabling JavaScript Rendering not only allows you to scrape content that would otherwise be inaccessible, but it also unlocks advanced scraping features. For example, with JavaScript Rendering, you can use the `wait_for` parameter to delay scraping until a specific element is present on the page, ensuring you capture the content you need. Check out more about JavaScript Rendering [here!](/fetch/features/js-rendering) Browser-based headers are crucial for ensuring that requests appear legitimate to target websites. Zenrows manages these headers to mimic real user behavior, which significantly reduces the risk of being blocked. By preventing customers from manually setting these headers, Zenrows can optimize the success rate and avoid common pitfalls associated with improper header configurations. ## Example of Sending Custom Headers Here's an example using `cURL` to send custom headers that are permitted along with your Zenrows request: ```bash bash theme={"dark"} curl \ -H "Accept: application/json" \ -H "Referer: https://www.google.com" \ "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=YOUR_URL&custom_headers=true" ``` # Adaptive Stealth Mode Source: https://docs.zenrows.com/fetch/features/adaptive-stealth-mode Zenrows intelligent scraping mode that automatically selects optimal configurations while minimizing costs and maintenance overhead. Adaptive Stealth Mode is Zenrows' automated execution mode that analyzes each request and selects the most cost-effective configuration needed for successful data extraction. Instead of manually configuring parameters, Adaptive Stealth Mode starts with basic settings and escalates only when necessary, eliminating ongoing maintenance as websites change their protection mechanisms. **When you enable Adaptive Stealth Mode, Zenrows:** * **Prioritizes successful extraction** above all else * **Starts with the cheapest viable configuration** for each request * **Automatically escalates** to more powerful features only when necessary * **Bills only for the configuration that succeeds** - failed attempts incur no charges * **Adapts automatically** as website behavior changes over time * **Reduces maintenance overhead** by eliminating the need to monitor and update scraping configurations Adaptive Stealth Mode eliminates the need for manual parameter tuning in production environments. ## How Adaptive Stealth Mode Works Adaptive Stealth Mode executes each request using a progressive strategy: 1. **Target Analysis:** Evaluates the target URL using known patterns and runtime signals to predict the best starting configuration. 2. **Lowest-Cost Attempt:** Executes the request with the least expensive configuration likely to succeed based on the analysis. 3. **Automatic Escalation:** Retries with stronger features if the initial attempt fails, progressively adding capabilities as needed. 4. **Success-Only Billing:** You are billed only for the configuration that completes successfully. Failed attempts incur no charges. This approach ensures reliable data extraction without overpaying for unnecessary features. ## Basic Usage ### API Usage Add `mode=auto` to your request: ```python Python theme={"dark"} # pip install requests import requests url = 'https://www.scrapingcourse.com/ecommerce/' apikey = 'YOUR_ZENROWS_API_KEY' params = { 'url': url, 'apikey': apikey, 'mode': 'auto', } response = requests.get('https://api.zenrows.com/v1/', params=params) print(response.text) ``` ```javascript Node.js theme={"dark"} // npm install axios const axios = require('axios'); const url = 'https://www.scrapingcourse.com/ecommerce/'; const apikey = 'YOUR_ZENROWS_API_KEY'; axios({ url: 'https://api.zenrows.com/v1/', method: 'GET', params: { 'url': url, 'apikey': apikey, 'mode': 'auto', }, }) .then(response => console.log(response.data)) .catch(error => console.log(error)); ``` ```java Java theme={"dark"} import org.apache.hc.client5.http.fluent.Request; public class APIRequest { public static void main(final String... args) throws Exception { String apiUrl = "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&mode=auto"; String response = Request.get(apiUrl) .execute().returnContent().asString(); System.out.println(response); } } ``` ```php PHP theme={"dark"} ``` ```go Go theme={"dark"} package main import ( "io" "log" "net/http" ) func main() { client := &http.Client{} req, err := http.NewRequest("GET", "https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&mode=auto", nil) if err != nil { log.Fatalln(err) } resp, err := client.Do(req) if err != nil { log.Fatalln(err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { log.Fatalln(err) } log.Println(string(body)) } ``` ```ruby Ruby theme={"dark"} # gem install faraday require 'faraday' url = URI.parse('https://api.zenrows.com/v1/?apikey=YOUR_ZENROWS_API_KEY&url=https%3A%2F%2Fwww.scrapingcourse.com%2Fecommerce%2F&mode=auto') conn = Faraday.new() conn.options.timeout = 180 res = conn.get(url, nil, nil) print(res.body) ``` ```bash cURL theme={"dark"} curl "https://api.zenrows.com/v1/?url=https://www.scrapingcourse.com/ecommerce/&apikey=YOUR_ZENROWS_API_KEY&mode=auto" ``` ## When to Use Adaptive Stealth Mode **Recommended when:** * Scraping multiple websites with different behaviors * Website requirements are unknown or change frequently * Reliability matters more than manual optimization * You're running production workloads at scale * You want to minimize maintenance overhead from website changes **Consider manual configuration for:** * Single, stable domains where you know the optimal setup * Scenarios requiring predictable, consistent feature usage * Cost-sensitive applications with well-understood requirements ## Managed Parameters Adaptive Stealth Mode automatically manages these API parameters: | Parameter | Purpose | When Applied | | - | - | - | | `js_render` | JavaScript rendering for dynamic content | Sites using React, Vue, Angular, or AJAX loading | | `premium_proxy` | Residential IP addresses | Sites blocking datacenter IPs or requiring geo-location | The managed parameters above are disabled when using Adaptive Stealth Mode to prevent conflicting configurations. Other parameters remain available for use. If you set the parameters manually, Adaptive Stealth Mode will return a 400 error: ```json theme={"dark"} { "code": "REQ_INVALID_PARAMS", "message": "js_render and premium_proxy parameters cannot be used with automatic mode" } ``` ## Adaptive Stealth Mode vs Manual Configuration | Aspect | Adaptive Stealth Mode (`mode=auto`) | Manual Parameters | | - | - | - | | Setup time | Single parameter | Requires tuning | | Success optimization | Automatic | User-managed | | Cost optimization | Automatic, per request | Fixed by chosen config | | Adaptation to site changes | Automatic | Requires updates | | Failed attempts billed | No | No | | Best for | Production, multi-domain workloads | Stable, well-known targets | ## Troubleshooting ### Common Issues **Adaptive Stealth Mode not working as expected** * Verify the `mode=auto` parameter is correctly set * Check that other conflicting parameters aren't overriding Adaptive Stealth Mode * Review the response headers for cost and feature information **Higher costs than expected** * Monitor the `X-Request-Cost` header to understand feature usage * Consider manual configuration if cost patterns are predictable * Use the Playground UI to preview costs before running requests **Inconsistent results** * Adaptive Stealth Mode adapts to website changes, which may affect feature selection * Use `session_id` to maintain consistency across related requests * Switch to manual configuration for completely predictable behavior ## Pricing Adaptive Stealth Mode uses transparent, credit-based pricing: | Configuration | Credits per request | When Used | | - | - | - | | Basic request | 1 | Simple static websites | | JavaScript rendering | 5 | Dynamic content requiring browser processing | | Premium Proxies | 10 | Sites blocking datacenter IPs | | Combined features | 25 | Highly protected sites requiring both JS and proxies | You are billed only once, for the configuration that succeeds. If Adaptive Stealth Mode requires multiple internal attempts before success, Zenrows bills only for the successful configuration. You can monitor your Zenrows usage in multiple ways to stay informed about your account activity and prevent unexpected overages. **Dashboard monitoring**: View real-time usage statistics, remaining requests, success rates, and request history on your [Analytics Page](https://app.zenrows.com/overview#performance). You can also set up usage alerts in your [notification settings](https://app.zenrows.com/settings/notifications) to receive notifications when you approach your limits. **Programmatic monitoring**: For automated monitoring in your applications, call the `/v1/subscriptions/self/details` endpoint with your API key in the `X-API-Key` header. This returns real-time usage data that you can integrate into your monitoring systems. [Learn more about the usage endpoint](https://docs.zenrows.com/fetch/features/other#plan-usage). **Response header monitoring**: Track your concurrency usage through response headers included with each request: * `Concurrency-Limit`: Your maximum concurrent requests * `Concurrency-Remaining`: Available concurrent request slots * `X-Request-Cost`: Cost of the current request * `X-Key-Credits-Remaining`: Lowest remaining credits across your API key's [credit caps](/first-steps/api-key-credit-caps), sent only for keys with at least one cap ## Frequently Asked Questions (FAQ) No. Adaptive Stealth Mode minimizes costs by avoiding unnecessary features and charging only for successful requests. It often reduces costs compared to manually over-configuring parameters. You retain full visibility through response headers (including request cost) and the Analytics dashboard, without needing to manage configuration decisions manually. Adaptive Stealth Mode adapts automatically without requiring parameter updates on your end. The system learns from new blocking patterns and adjusts accordingly. Yes, but with limitations. When you use `mode=auto`, only the managed parameters (`js_render`, and `premium_proxy`) are disabled to prevent conflicts. You can still use other API parameters like `css_extractor`, `session_id`, or custom request settings alongside Adaptive Stealth Mode. # Autoparse Source: https://docs.zenrows.com/fetch/features/autoparse Autoparse returns general-purpose JSON from a page. It is deprecated in favor of Zenrows Extract (extract=auto), which builds a tailored extraction for each website. **Autoparse is deprecated. Use [Extract](/extract/setup) (`extract=auto`) instead.** The `autoparse` parameter still works and existing integrations keep running, so nothing breaks today. It is no longer the recommended way to get JSON from a page, and new integrations should start with Extract. Any sunset will be announced separately, with notice. Autoparse returns structured JSON instead of raw HTML. A small set of sites has a dedicated parser behind the parameter; on every other site, Autoparse returns the JSON objects it finds embedded in the page's `