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:
| header | description |
|---|---|
X-Cwbl-Signature | the signature, signed with your current (primary) secret. format: t=<unix_seconds>,v1=<hex_hmac>. |
X-Cwbl-Signature-Rotated | present only during a rotation grace window — the same payload signed with your previous secret. |
X-Cwbl-Event-Id | a 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-hexHMAC-SHA256signature.
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:
js/ts
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.
Spring Boot
// 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.
js/ts
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.
js/ts
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 secretaccept 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.
js/ts
// `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 otherwiserelated
- completion webhooks — configuring a webhook on a scrape request, the
scrape.completepayload shape, and delivery guarantees. - authentication — api tokens for calling the api.