Skip to Content

completion webhooks

instead of polling an async job until it finishes, hand 🍮 crawlbrulee a url and we’ll send a single signed POST the moment the job reaches a terminal state.

webhooks are configured per job on the async endpoint. sync scrape doesn’t support them — the response is your result, so there’s nothing to notify.

configure a webhook

add a webhook object to your POST /api/scrape/async body:

curl -X POST https://api.crawlbrulee.com/api/scrape/async \ -H "Authorization: Bearer $CRAWLBRULEE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "extract": { "markdown": true }, "webhook": { "url": "https://your-app.com/hooks/crawlbrulee", "metadata": { "order_id": "A-1234" } } }'
fieldtypedescription
urlstringwhere to deliver the event. https is required in production. max 2048 characters.
metadataobject (opt.)opaque correlation object echoed back verbatim in the payload. max 2048 bytes serialized.

use metadata to route a delivery without keeping your own job_id → context mapping — e.g. stash an order id, tenant id, or callback target and read it straight off the payload.

we keep the webhook url and metadata only for as long as it takes to deliver — they’re erased from our records within about 24 hours of the job reaching a terminal state.

the delivery

when the job reaches a terminal state we send one POST to your url:

POST /hooks/crawlbrulee HTTP/1.1 content-type: application/json X-Cwbl-Event-Id: d2f8a1de-9c3b-4f6e-8a52-1c9d4f3b7a10 X-Cwbl-Signature: t=1736937060,v1=5257a869e7...
{ "event_id": "d2f8a1de-9c3b-4f6e-8a52-1c9d4f3b7a10", "timestamp": "2025-01-15T10:31:00.000Z", "event": "scrape.complete", "data": { "job_id": "683a1f2b4c5d6e7f8a9b0c1d", "status": "success", "url": "https://example.com", "completed_at": "2025-01-15T10:30:58.000Z", "metadata": { "order_id": "A-1234" }, "response_meta": { "usage": { "credits": 1, "engine": "http", "proxy": "basic", "screenshot_slices": 0 } } } }
fielddescription
event_idunique per event, stable across retries. also sent as the X-Cwbl-Event-Id header.
timestampwhen this delivery was sent (iso 8601). matches the t value in the signature.
eventthe event type. currently always scrape.complete.
data.job_idthe async job id. build the result url from it: GET /api/scrape/result/{job_id}.
data.statusoutcome — success, failed, or cancelled.
data.urlthe url that was scraped.
data.completed_atwhen the job reached its terminal state (may predate timestamp if a delivery was retried).
data.errorgeneric failure message. present only when status is failed.
data.metadatayour correlation object, echoed verbatim. present only if you supplied it.
data.response_metausage metadata: usage = { credits, engine, proxy, screenshot_slices }. present only on status: "success" deliveries.

a failed delivery carries the same customer-safe message as the async status endpoint:

{ "event_id": "a1b2c3d4-...", "timestamp": "2025-01-15T10:31:00.000Z", "event": "scrape.complete", "data": { "job_id": "683a1f2b4c5d6e7f8a9b0c1d", "status": "failed", "url": "https://example.com", "completed_at": "2025-01-15T10:30:58.000Z", "error": "Scrape job failed. Please try again or contact support if the problem persists." } }

the payload is a pointer, not the result — it never carries the scraped content. fetch that from GET /api/scrape/result/{job_id} using data.job_id.

verify the signature

every delivery is signed so you can confirm it came from 🍮 crawlbrulee and wasn’t tampered with in transit. the X-Cwbl-Signature header carries an HMAC-SHA256 over "<timestamp>.<raw request body>" (X-Cwbl-Event-Id gives you a stable idempotency key), and your per-org signing secret lives on the dashboard Webhooks screen.

signature verification, key rotation, and replay protection are covered in webhook verification — including a manual recipe and the @crawlbrulee/sdk / crawlbrulee sdk helpers. the same scheme applies to every webhook type.

the sdks also expose a one-call helper that fetches the scrape result straight from a verified delivery — client.fetchScrapeResultFromWebhook(body) (js) / client.fetch_scrape_result_from_webhook(body) (Python) — returning the result on success and raising on a failed or cancelled job.

retries & delivery guarantees

  • success is any 2xx response. respond quickly (within 10 seconds) and do heavy work afterward — a slow handler is treated as a failed delivery.
  • failures — a non-2xx response, a 10-second timeout, or a connection error — are retried after 1m, 5m, and 30m (4 attempts total). after that we stop trying; poll GET /api/scrape/status/{job_id} as a fallback if your endpoint was down.
  • redirects are not followed. a 3xx counts as a failed attempt, so point the webhook at its final url.
  • delivery is at-least-once. retries reuse the same event_id (and X-Cwbl-Event-Id header) — use it as an idempotency key to dedupe.

always verify the signature before trusting a delivery. anyone who learns your endpoint url can POST to it — the signature is what proves a request actually came from 🍮 crawlbrulee.

putting it together

a minimal endpoint that verifies, deduplicates, and acknowledges fast. it uses the sdk verification helper — see webhook verification for the verification mechanics and a dependency-free recipe.

import express from 'express' import { verifyWebhookSignature } from '@crawlbrulee/sdk' const app = express() const seen = new Set() // use a real store (Redis, db) in production const SECRET = process.env.CRAWLBRULEE_WEBHOOK_SECRET // capture the RAW body — required for signature verification app.post('/hooks/crawlbrulee', express.raw({ type: 'application/json' }), async (req, res) => { const rawBody = req.body.toString('utf8') const result = await verifyWebhookSignature({ payload: rawBody, headers: req.headers, secret: SECRET, }) if (!result.verified) { return res.status(401).send('bad signature') } const event = JSON.parse(rawBody) // ack immediately, then process out of band res.status(200).send('ok') if (seen.has(event.event_id)) return // already handled seen.add(event.event_id) if (event.data.status === 'success') { void handleResult(event.data.job_id, event.data.metadata) } })