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

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

# Get notified when a Batch job finishes

> Get a signed notification the moment a Zenrows Batch job finishes, verify it, and download the results automatically.

Instead of polling a job until it ends, attach a webhook and Zenrows sends your endpoint an HTTPS `POST` the moment a run finishes: `run.completed` when it completes, `run.failed` when it fails. Sign deliveries with an HMAC key so your receiver knows they came from Zenrows.

**Uses:** [Batch webhooks](/batch/developer-guide-restapi#set-up-webhooks), [HMAC keys](/batch/developer-guide-restapi#manage-hmac-keys) and [signature verification](/batch/developer-guide-restapi#verify-signed-webhooks)

## Ask your AI agent

Paste this prompt into a coding agent such as Claude Code or Cursor, with the [Zenrows docs connected](/academy/connect-your-ai-agent#give-your-agent-the-zenrows-docs). Run it in the project where your receiver lives.

```text theme={"dark"}
Using the Zenrows docs, add an endpoint to this app that receives Zenrows Batch webhooks.
Verify each delivery's signature, ignore duplicates, and when a job completes, download its
successful results. Read the signing secret from an environment variable.
```

## Create a signing key

Signed deliveries use your organization's active HMAC key. Create it once with `POST /hmac/keys/rotate`. The response holds the `kid` and the base64 `secret`, and the secret is shown this one time, so store it in your secrets manager right away.

```bash cURL theme={"dark"}
curl -X POST "https://async.api.zenrows.com/v1/hmac/keys/rotate" \
  -H "X-API-Key: YOUR_ZENROWS_API_KEY"
```

```json Response theme={"dark"}
{
  "kid": "01M4YA0ZHAWNNRHAHS8YGKNCSD",
  "secret": "YOUR_BASE64_SECRET",
  "created_at": "2026-10-14T22:55:13.450816637Z"
}
```

`GET /hmac/keys` shows which `kid` is active, without secrets. Create the key before you ask for `"signature": true`: a signed webhook on an organization with no active key returns `400` with `webhook_signing_requires_active_key`.

## Attach a webhook to a job

Add a `webhook` object with your HTTPS `url` and `"signature": true` when you create the job:

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

  response = requests.post(
      "https://async.api.zenrows.com/v1/jobs",
      headers={"X-API-Key": "YOUR_ZENROWS_API_KEY"},
      json={
          "type": "regular",
          "status": "closed",
          "zenrows_params": {"mode": "auto", "response_type": "markdown"},
          "tasks": [{"url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/"}],
          "webhook": {"url": "https://your-app.com/webhooks/zenrows", "signature": True},
      },
      timeout=60,
  )
  response.raise_for_status()
  job = response.json()
  print(job["job_id"], job["webhook"])
  ```

  ```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": { "mode": "auto" },
      "tasks": [{ "url": "https://www.scrapingcourse.com/ecommerce/product/abominable-hoodie/" }],
      "webhook": { "url": "https://your-app.com/webhooks/zenrows", "signature": true }
    }'
  ```
</CodeGroup>

The SDKs take the same object: `webhook={"url": ..., "signature": True}` on `submit_regular` in Python, and `{ webhook: { url, signature: true } }` in the options of `submitRegular` in Node.js. On an existing job, set or replace it with `PUT /jobs/{job_id}/webhook` and remove it with `DELETE /jobs/{job_id}/webhook`.

## What Zenrows sends

Each delivery is a JSON `POST` with these headers:

```text theme={"dark"}
Content-Type: application/json
User-Agent: zenrows-webhook/v1
X-ZenRows-Event-Id: 01M4YA1WKH0WQE2FMZXFAX568R
X-Signature: t=1792018543,v1=cded0ddd59bae9a3cc8da24b3ccce606303f89b2f0a3aa96ccd14c0ad390b04e,kid=01M4YA0ZHAWNNRHAHS8YGKNCSD
```

And this body, captured from a real `run.completed` delivery:

```json theme={"dark"}
{
  "schema": "zenrows.webhook/v1",
  "event_id": "01M4YA1WKH0WQE2FMZXFAX568R",
  "event_type": "run.completed",
  "occurred_at": "2026-10-14T22:55:43.217627452Z",
  "job_id": "01M4YA1VEQKR8VD0EA3ZBWJ2ZX",
  "run_id": "01M4YA1VEQ6MGJZ1R2D2X63ZZG",
  "run_sequence": 1,
  "stats": {
    "total": 1,
    "completed": 1,
    "successful": 1,
    "failed": 0,
    "spend": { "credits": 1, "cost": 0.001 }
  }
}
```

A `run.failed` event also carries the run's `failure_reason` and `failure_detail`. Zenrows delivers each event at least once, so the same `X-ZenRows-Event-Id` can arrive more than once.

## Receive and verify deliveries

To verify a delivery:

1. Split `X-Signature` into `t`, `v1` and `kid`, and pick the secret for that `kid`.
2. Compute `HMAC-SHA256(base64_decode(secret), "<t>." + raw_body)` as lowercase hex.
3. Compare it with `v1` in constant time. Reject old timestamps to block replays.

Sign the raw bytes you received, before any JSON parsing. Then skip events you have already handled, answer `200` quickly, and fetch the results.

<CodeGroup>
  ```python Python (Flask) theme={"dark"}
  # pip install flask requests
  import base64, hashlib, hmac, os, time
  import requests
  from flask import Flask, abort, request

  app = Flask(__name__)

  # kid -> base64 secret, as returned once by POST /v1/hmac/keys/rotate
  SECRETS = {os.environ["ZENROWS_WEBHOOK_KID"]: os.environ["ZENROWS_WEBHOOK_SECRET"]}
  API_KEY = os.environ["ZENROWS_API_KEY"]
  seen_events = set()  # use a database or Redis in production

  def verify(raw_body: bytes, header: str) -> bool:
      parts = dict(item.split("=", 1) for item in header.split(",") if "=" in item)
      secret = SECRETS.get(parts.get("kid"))
      if not secret or not parts.get("t", "").isdigit() or abs(time.time() - int(parts["t"])) > 300:
          return False
      expected = hmac.new(
          base64.b64decode(secret), f"{parts['t']}.".encode() + raw_body, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, parts.get("v1", ""))

  @app.post("/webhooks/zenrows")
  def zenrows_webhook():
      raw_body = request.get_data()  # the exact bytes Zenrows signed
      if not verify(raw_body, request.headers.get("X-Signature", "")):
          abort(401)

      event_id = request.headers.get("X-ZenRows-Event-Id")
      if event_id in seen_events:
          return "", 200  # a repeat delivery: already handled
      seen_events.add(event_id)

      event = request.get_json()
      if event["event_type"] == "run.completed":
          results = requests.get(
              f"https://async.api.zenrows.com/v1/jobs/{event['job_id']}/results",
              headers={"X-API-Key": API_KEY},
              params={"status": "successful"},
              timeout=30,
          ).json()["results"]
          print(f"Run {event['run_id']} done: {event['stats']['successful']} pages, {len(results)} on first results page")
      elif event["event_type"] == "run.failed":
          print(f"Run {event['run_id']} failed: {event.get('failure_reason')} {event.get('failure_detail')}")
      return "", 200

  if __name__ == "__main__":
      app.run(port=8000)
  ```

  ```javascript Node.js (http) theme={"dark"}
  import crypto from "node:crypto";
  import http from "node:http";

  // kid -> base64 secret, as returned once by POST /v1/hmac/keys/rotate
  const SECRETS = { [process.env.ZENROWS_WEBHOOK_KID]: process.env.ZENROWS_WEBHOOK_SECRET };
  const API_KEY = process.env.ZENROWS_API_KEY;
  const seenEvents = new Set(); // use a database or Redis in production

  function verify(rawBody, header = "") {
    const parts = Object.fromEntries(
      header
        .split(",")
        .filter((item) => item.includes("="))
        .map((item) => [item.slice(0, item.indexOf("=")), item.slice(item.indexOf("=") + 1)]),
    );
    const secret = SECRETS[parts.kid];
    if (!secret || !/^\d+$/.test(parts.t ?? "")) return false;
    if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
    const expected = crypto
      .createHmac("sha256", Buffer.from(secret, "base64"))
      .update(Buffer.concat([Buffer.from(`${parts.t}.`), rawBody]))
      .digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(parts.v1 ?? "");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  http
    .createServer(async (req, res) => {
      if (req.method !== "POST" || req.url !== "/webhooks/zenrows") return res.writeHead(404).end();

      const chunks = [];
      for await (const chunk of req) chunks.push(chunk);
      const rawBody = Buffer.concat(chunks); // the exact bytes Zenrows signed

      if (!verify(rawBody, req.headers["x-signature"])) return res.writeHead(401).end();

      const eventId = req.headers["x-zenrows-event-id"];
      if (seenEvents.has(eventId)) return res.writeHead(200).end(); // a repeat delivery
      seenEvents.add(eventId);
      res.writeHead(200).end(); // acknowledge first, then do the work

      const event = JSON.parse(rawBody);
      if (event.event_type === "run.completed") {
        const page = await (
          await fetch(`https://async.api.zenrows.com/v1/jobs/${event.job_id}/results?status=successful`, {
            headers: { "X-API-Key": API_KEY },
          })
        ).json();
        console.log(`Run ${event.run_id} done: ${event.stats.successful} pages, ${page.results.length} on first results page`);
      } else if (event.event_type === "run.failed") {
        console.log(`Run ${event.run_id} failed: ${event.failure_reason} ${event.failure_detail}`);
      }
    })
    .listen(8000);
  ```
</CodeGroup>

Run either one with `ZENROWS_WEBHOOK_KID`, `ZENROWS_WEBHOOK_SECRET` and `ZENROWS_API_KEY` set. Zenrows delivers to public `https` URLs, so for local development expose port 8000 with a tunnel such as ngrok or cloudflared and use that URL in the job.

## Example output

The Flask receiver's log for a valid delivery, the same delivery again, and a tampered one:

```text theme={"dark"}
Run 01M4YA1VEQ6MGJZ1R2D2X63ZZG done: 1 pages, 1 on first results page
127.0.0.1 - - "POST /webhooks/zenrows HTTP/1.1" 200 -
127.0.0.1 - - "POST /webhooks/zenrows HTTP/1.1" 200 -
127.0.0.1 - - "POST /webhooks/zenrows HTTP/1.1" 401 -
```

## Test your receiver

`POST /webhook/test` sends a `webhook.test` event to any URL, signed the same way as real deliveries, and tells you what your endpoint answered. It runs no job.

<CodeGroup>
  ```bash cURL theme={"dark"}
  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": true }'
  ```

  ```javascript Node.js theme={"dark"}
  const response = await fetch("https://async.api.zenrows.com/v1/webhook/test", {
    method: "POST",
    headers: { "X-API-Key": "YOUR_ZENROWS_API_KEY", "Content-Type": "application/json" },
    body: JSON.stringify({ url: "https://your-app.com/webhooks/zenrows", signature: true }),
  });
  console.log(response.status, await response.json());
  ```
</CodeGroup>

Against a receiver that answered `200`, the response reads:

```json theme={"dark"}
{ "delivered": true, "event_id": "01M4YA16TD86SYQQP57SBWM33F", "status_code": 200, "elapsed_ms": 386 }
```

Against one that answered `500`:

```json theme={"dark"}
{ "delivered": false, "event_id": "01M4YATAZ66RKFGCVB7AQ6HQR8", "status_code": 500, "error": "http_5xx:500", "elapsed_ms": 13 }
```

## Customize it

| Change | How |
| - | - |
| Add a webhook to an existing job | `PUT /jobs/{job_id}/webhook` with `{"url": ..., "signature": true}` |
| Stop deliveries | `DELETE /jobs/{job_id}/webhook` |
| Unsigned deliveries | `"signature": false`, and authenticate with a hard-to-guess path such as `/webhooks/zenrows/<random-token>` |
| Rotate the signing key | `POST /hmac/keys/rotate` stages a candidate, accept both `kid`s, then `POST /hmac/keys/rotate/finalize`, see [Manage HMAC keys](/batch/developer-guide-restapi#manage-hmac-keys) |
| A webhook on every scheduled run | Add `webhook` to a [scheduled job](/academy/batch/scheduled-jobs) |

## Troubleshooting

* **Every signature fails**: you're signing parsed or re-serialized JSON, or the secret as text. Sign the raw request bytes, with the secret base64-decoded first.
* **`400` with `webhook_signing_requires_active_key`**: your organization has no active HMAC key. [Create one](#create-a-signing-key), then submit again.
* **`400` with `callback_url_must_be_https`**: the URL uses `http`. Serve the receiver over HTTPS, or use a tunnel URL while developing.
* **`delivered: false` from the test endpoint**: read `status_code` and `error`. Your endpoint must be public and answer with a `2xx`.
* **The same event arrives twice**: deliveries are at least once. Store each `X-ZenRows-Event-Id` and skip repeats.
* **You missed a delivery**: the job still holds the answer. Read `GET /jobs/{job_id}`; `latest_run.status` and the results endpoint are always available.

## Related recipes

<CardGroup cols={2}>
  <Card title="Scrape a list of URLs" icon="list" href="/academy/scrape-from-a-list-of-urls">
    Submit the job this webhook reports on.
  </Card>

  <Card title="Run a Batch job on a schedule" icon="calendar" href="/academy/batch/scheduled-jobs">
    Get a webhook each time a scheduled run ends.
  </Card>

  <Card title="Extract real estate listings and property data" icon="home" href="/academy/extract-property-data">
    A Batch pipeline from search page to every listing.
  </Card>

  <Card title="Retry failed requests" icon="refresh" href="/academy/retry-failed-requests">
    Handle errors in your own request loop.
  </Card>
</CardGroup>


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