Skip to main content
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, HMAC keys and signature verification

Ask your AI agent

Paste this prompt into a coding agent such as Claude Code or Cursor, with the Zenrows docs connected. Run it in the project where your receiver lives.

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.
cURL
Response
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:
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:
And this body, captured from a real run.completed delivery:
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.
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:

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.
Against a receiver that answered 200, the response reads:
Against one that answered 500:

Customize it

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

Scrape a list of URLs

Submit the job this webhook reports on.

Run a Batch job on a schedule

Get a webhook each time a scheduled run ends.

Extract real estate listings and property data

A Batch pipeline from search page to every listing.

Retry failed requests

Handle errors in your own request loop.