Skip to Content

webhook verification

every webhook we deliver is signed so you can confirm it came from us and wasn’t tampered with in transit. anyone who learns your endpoint url can POST to it — the signature is what proves a delivery actually originated from crawlbrulee.

this page covers verification for all webhook types. the mechanics — headers, signing scheme, rotation, and replay protection — are identical regardless of which event triggered the delivery.

always verify the signature before trusting a delivery. treat an unsigned or invalid delivery as hostile and reject it with a 401.

signing secret

your signing secret is per-organization — one secret covers every webhook in your org. find it on the dashboard Webhooks screen at /<org>/account/webhooks, where you can also reveal and rotate it. it looks like:

whsec_a1b2c3d4e5f6...

store it the same way you’d store an api key — in an environment variable or secrets manager, never in source control.

# .env CRAWLBRULEE_WEBHOOK_SECRET=whsec_a1b2c3d4e5f6...

signature headers

each delivery carries these headers:

headerdescription
X-Cwbl-Signaturethe signature, signed with your current (primary) secret. format: t=<unix_seconds>,v1=<hex_hmac>.
X-Cwbl-Signature-Rotatedpresent only during a rotation grace window — the same payload signed with your previous secret.
X-Cwbl-Event-Ida stable delivery id, identical across all retry attempts. use it for idempotency / dedup.

the X-Cwbl-Signature value packs two fields:

X-Cwbl-Signature: t=1736937060,v1=5257a869e7...
  • t — the unix timestamp (in seconds) at which the delivery was signed.
  • v1 — the lowercase-hex HMAC-SHA256 signature.

signed payload construction

the signature is the HMAC-SHA256 of a signed payload string, keyed with your signing secret and rendered as a lowercase hex digest. the signed payload string is:

<t>.<raw request body bytes>

that is: the t value from the header, a literal ., then the raw request body exactly as received — the bytes on the wire, before any json parsing.

compute the hmac over the raw request body bytes, not a parsed-and-re-serialized object. parsing and re-serializing reorders keys and reformats whitespace, and the signature will never match. capture the raw body in your web framework before any body parser touches it.

verifying a delivery

parse the signature header

split X-Cwbl-Signature on , and read out t and v1.

check the timestamp (replay protection)

reject the delivery if |now - t| exceeds 300 seconds. this bounds how long a captured delivery can be replayed against your endpoint.

recompute the hmac

compute HMAC-SHA256(secret, "<t>." + rawBody) and lowercase-hex encode it.

compare in constant time

compare your computed digest to v1 using a constant-time comparison (e.g. crypto.timingSafeEqual, hmac.compare_digest) to avoid leaking timing information. if they match, the delivery is authentic.

dedupe on the event id

track X-Cwbl-Event-Id in a store and skip deliveries you’ve already processed — retries reuse the same id.

manual verification

if you’d rather not pull in the sdk, here’s the full recipe over the raw body:

import crypto from 'node:crypto' const TOLERANCE_SECONDS = 300 // `rawBody` must be the raw string/Buffer, not a parsed object. // `signatureHeader` is the value of `X-Cwbl-Signature` (or `X-Cwbl-Signature-Rotated`). function verifyWebhook(rawBody, signatureHeader, secret) { const parts = Object.fromEntries( signatureHeader.split(',').map((kv) => kv.split('=')), ) const timestamp = Number(parts.t) const received = parts.v1 // reject stale deliveries (replay protection) if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) { return false } const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`, 'utf8') .digest('hex') const a = Buffer.from(received ?? '', 'utf8') const b = Buffer.from(expected, 'utf8') return a.length === b.length && crypto.timingSafeEqual(a, b) }

capturing the raw request body

the recipe above hinges on one thing: the exact bytes of the request body, before any json parser runs. most frameworks parse json for you by default and hand back an object — re-serializing that object reorders keys and reformats whitespace, so its bytes no longer match what was signed. reach for the raw bytes instead.

every mainstream framework can expose them. each recipe below feeds the bytes straight into the verifyWebhook / verify_webhook helper from the manual verification recipe.

// Binding the body as a String (not a DTO) hands you the raw payload, // exactly as received, before Jackson deserializes it. @PostMapping("/webhooks/crawlbrulee") public ResponseEntity<Void> handle( @RequestBody String rawBody, @RequestHeader("X-Cwbl-Signature") String signature) { if (!verifyWebhook(rawBody, signature, SECRET)) { return ResponseEntity.status(401).build(); } // safe to parse rawBody now return ResponseEntity.ok().build(); }

ping + authenticated fetch (when raw bytes are out of reach)

a few managed platforms and edge runtimes consume the request body before your handler ever sees it, leaving no way to recover the original bytes for an hmac check. rather than fight the framework, you can treat the webhook as a ping and make your org api token the trust anchor: the delivery only tells you that a job finished; you fetch the full result yourself over an authenticated request.

this is a legitimate design choice, not a downgrade. the api token already gates access to your org’s data, so a forged or replayed ping can never surface another org’s results — the worst a spoofed delivery can do is make you re-fetch one of your own jobs.

check the timestamp

parse t out of X-Cwbl-Signature and reject the ping if |now - t| exceeds 300 seconds. you skip the hmac, but this freshness check still bounds how long a captured delivery can be replayed.

dedupe on the event id

track X-Cwbl-Event-Id and ignore pings you’ve already handled — retries reuse the same id.

read only the job id, ignore the payload contents

don’t trust any result data in the body. read just data.job_id — it’s only a pointer telling you what to fetch next.

fetch the full result with your api token

call GET /api/scrape/result/{job_id} with your org api token. the endpoint only ever returns jobs your org owns, so this is where trust actually comes from — a spoofed job_id either 404s or returns one of your own jobs, never someone else’s.

import { Crawlbrulee } from '@crawlbrulee/sdk' const client = new Crawlbrulee({ apiKey: process.env.CRAWLBRULEE_API_KEY }) const TOLERANCE_SECONDS = 300 // `headers` and `parsedBody` come from your framework; raw bytes are unavailable. async function handlePing(headers, parsedBody) { const sig = headers['x-cwbl-signature'] ?? '' const t = Number(sig.split(',')[0]?.split('=')[1]) if (!t || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) { return { status: 401 } // stale } const eventId = headers['x-cwbl-event-id'] if (await alreadyProcessed(eventId)) return { status: 200 } // already handled // read only the job id — ignore everything else in the payload const jobId = parsedBody.data.job_id await client.getScrapeResult(jobId) // API token is the trust anchor await markProcessed(eventId) return { status: 200 } }

prefer signature verification over the raw body whenever your framework allows it — see capturing the raw request body. reach for ping + fetch only in the genuine cases where the bytes are unrecoverable.

verifying with the sdk

the official sdks ship a verification helper that handles parsing, replay protection, constant-time comparison, and rotation (it checks both the primary and rotated headers) in one call. it returns a structured result telling you whether the delivery is authentic and which secret signed it.

import { verifyWebhookSignature } from '@crawlbrulee/sdk' const secret = process.env.CRAWLBRULEE_WEBHOOK_SECRET // `payload` must be the raw request body (string or Uint8Array), not a parsed object. const result = await verifyWebhookSignature({ payload: rawBody, headers: req.headers, // pass the request headers through as-is secret, toleranceSeconds: 300, // optional; defaults to 300 }) if (result.verified) { // result.signedWith is 'primary' or 'rotated' console.log('verified, signed with', result.signedWith) } else { // result.reason: 'missing_signature' | 'malformed_signature' // | 'timestamp_out_of_tolerance' | 'signature_mismatch' console.warn('rejected:', result.reason) }

the sdk verification helpers ship in sdk v0.2.0. the result object also surfaces a reason on failure so you can log exactly why a delivery was rejected.

rotating your signing secret

if your secret is exposed — or you rotate on a schedule — open the Webhooks screen and rotate it. rotation doesn’t break live receivers: it opens a grace window during which every delivery is signed with both secrets, so a receiver holding either secret keeps verifying. this grace window has no automatic expiry — the previous secret keeps working until you revoke it (or rotate again).

rotate in the dashboard

generate a new secret on the Webhooks screen. from this point, every delivery carries two signatures:

X-Cwbl-Signature: t=...,v1=... # signed with the new (primary) secret X-Cwbl-Signature-Rotated: t=...,v1=... # signed with the previous secret

accept either signature during the window

until your endpoint holds the new secret, verify against whichever header matches the secret you currently hold. the sdk helper checks both automatically and reports signedWith / signed_with as 'primary' or 'rotated'. if you verify manually, try X-Cwbl-Signature first, then fall back to X-Cwbl-Signature-Rotated:

function verifyAnySignature(rawBody, headers, secret) { const primary = headers['x-cwbl-signature'] const rotated = headers['x-cwbl-signature-rotated'] return ( (primary && verifyWebhook(rawBody, primary, secret)) || (rotated && verifyWebhook(rawBody, rotated, secret)) ) }

roll out the new secret

deploy the new secret to your endpoint. it now verifies against the X-Cwbl-Signature header.

revoke the previous secret

once every receiver is on the new secret, click revoke previous on the dashboard Webhooks screen. the X-Cwbl-Signature-Rotated header stops being sent.

only one previous secret is kept at a time — rotating again replaces it. if you verify manually, keep the dual-header check in your handler permanently so future rotations need no code change. the sdk helper already does this for you.

fetching the result on success

for webhook types that point at an async result (such as scrape-completion webhooks), the sdks expose a helper that verifies the outcome and fetches the result in one step — it returns the result on success and raises on a failed or cancelled job.

// `body` is the parsed webhook payload. Verify the signature first. const result = await client.fetchScrapeResultFromWebhook(body) // throws on a failed or cancelled job; returns the scrape result otherwise
  • completion webhooks — configuring a webhook on a scrape request, the scrape.complete payload shape, and delivery guarantees.
  • authentication — api tokens for calling the api.