Webhooks
SuperScraper delivers two kinds of outbound webhooks (crawl/batch completion, and feed monitor deliveries), and receives one inbound webhook (Stripe billing events). They are not all signed the same way, see below.
Crawl completion (signed)
Pass webhook (or the legacy alias webhookUrl) to POST /v1/crawl. When the job finishes, SuperScraper POSTs a signed envelope to your URL, with retries.
POST <your webhook URL>
Content-Type: application/json
X-SuperScraper-Event: crawl.completed
X-SuperScraper-Timestamp: 1751500000
X-SuperScraper-Signature: sha256=<hex hmac>
{
"event": "crawl.completed",
"timestamp": 1751500000,
"data": {
"jobId": "01hwz3p4q8k2nmx7vyd6cbse1f",
"status": "done",
"pagesScraped": 24,
"resultUrl": "mock://results/01hwz3p4q8k2nmx7vyd6cbse1f.jsonl",
"results": [ { "url": "...", "markdown": "...", "listing": { "...": "..." } } ]
}
}On failure, the event is crawl.failed and data carries { jobId, status: "failed", error } instead.
Verifying the signature
X-SuperScraper-Signature is sha256=<hex> where <hex> is HMAC-SHA256("{timestamp}.{rawBody}", WEBHOOK_SIGNING_SECRET), computed over the raw request body (not the parsed JSON) and the X-SuperScraper-Timestamp value:
const crypto = require('crypto');
function verify(rawBody, timestampHeader, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestampHeader}.${rawBody}`)
.digest('hex');
return expected === signatureHeader;
}Differences / limits. Signing only happens when the server has WEBHOOK_SIGNING_SECRET configured, if it isn't set, the webhook is still delivered but unsigned (no X-SuperScraper-Signature header). Always check for the header before trusting a payload as authentic. Delivery retries up to 3 times with exponential backoff (any non-2xx or network error is retryable); the target URL is passed through the same SSRF guard as scrape requests, so private/loopback/metadata targets are rejected.
Respond with any 2xx status to acknowledge delivery.
Batch webhooks
POST /v1/batch with async: true (or more than 100 URLs) supports the same signed-webhook mechanism as crawl, with events batch.completed / batch.failed.
Feed / monitor deliveries (unsigned)
POST /v1/feeds accepts a delivery.webhook URL. When the scheduled feed run finds new rows since the last run, it POSTs:
POST <your webhook URL>
Content-Type: application/json
{
"event": "feed.new_rows",
"feedId": "feed_8f3a12bc",
"title": "New roofing contractors, Austin TX",
"connectorId": "google-maps",
"newCount": 3,
"rows": [ { "...": "..." } ],
"ts": "2026-07-10T09:00:00.000Z"
}Differences / limits. Feed webhooks are not HMAC-signed today (no X-SuperScraper-Signature header), unlike crawl/batch webhooks above. Delivery is best-effort: a single POST attempt with a 15s timeout, no retry. Per the media policy, rows only ever carries links and score/fact fields, never a downloaded media file.
Inbound: Stripe billing events
POST /webhooks/stripe receives Stripe's own webhook events (checkout completion, subscription renewal), this is Stripe calling us, not us calling you. It verifies the stripe-signature header against STRIPE_WEBHOOK_SECRET via the Stripe SDK's constructEvent before processing; an invalid signature is rejected. You don't need to do anything with this endpoint as an API consumer, it's listed here for completeness on what "webhook" means across the API.
Summary
| Webhook | Direction | Signed? | Retries | Configured via |
|---|---|---|---|---|
| Crawl / batch completion | Outbound (us → you) | Yes, HMAC-SHA256 (if WEBHOOK_SIGNING_SECRET set) | Up to 3, exponential backoff | webhook field on /v1/crawl or async /v1/batch |
| Feed / monitor new rows | Outbound (us → you) | No | None (best-effort) | delivery.webhook on /v1/feeds |
| Stripe billing events | Inbound (Stripe → us) | Yes, Stripe signature | Stripe's own retry policy | Configured server-side, not by API consumers |