Webhooks
Verify ByteKit webhook deliveries — signature and timestamp headers, the HMAC-SHA256 scheme, and Node and Python verification code.
ByteKit delivers webhooks for monitors, async scrapes, bulk jobs, and sitemap jobs. Anyone who learns your webhook URL can POST to it, so every delivery is signed with a secret only you and ByteKit hold. Verify that signature before you trust a payload.
Delivery headers
| Header | Always present | Description |
|---|---|---|
X-Webhook-Timestamp | Yes | Unix timestamp in seconds at the moment the delivery attempt was signed |
X-Webhook-Signature | Only when the webhook has a secret | sha256= followed by the lowercase hex HMAC-SHA256 digest |
X-ByteKit-Event | Yes | Event type — see Webhook event header |
All three are reserved: they cannot be overridden through a webhook_headers map.
The signing scheme
The signed string is the timestamp, a literal ., and the raw request body:
signature = "sha256=" + hex(HMAC_SHA256(secret, timestamp + "." + raw_body))Three details decide whether your verification works:
timestampis theX-Webhook-Timestampheader value verbatim, as the decimal string it arrives as — not a re-formatted or re-parsed date.raw_bodyis the exact bytes of the request body, captured before any JSON parsing. Re-serializing the parsed object changes key order and whitespace, and the digest with it.secretis the webhook secret — the value you passed aswebhook_secret, or the one returned once in thewebhook_secretfield of a monitor create response (64 lowercase hex characters). It is never returned again, so store it when you create the webhook.
Compare the result against X-Webhook-Signature with a constant-time comparison
(crypto.timingSafeEqual, hmac.compare_digest), never with ==.
Replay window
Each delivery attempt is signed at the moment it is sent, so a retry carries a fresh timestamp rather than replaying the original one. A tight tolerance therefore does not break retries.
Reject a delivery whose X-Webhook-Timestamp is further from your own clock than a tolerance
you choose — five minutes is a reasonable default. Without that check, a signature captured off
the wire stays valid forever. ByteKit does not enforce the window for you, and it does not
de-duplicate: a delivery your endpoint accepted but answered slowly can arrive again, so make
your handler idempotent.
Your endpoint must answer 2xx within 10 seconds. Failed deliveries are retried with
exponential backoff.
Verify a delivery
Node.js
const crypto = require('node:crypto');
// `rawBody` must be the request body as received (string or Buffer), NOT a
// re-serialized JSON object. In Express, use express.raw({ type: 'application/json' }).
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const timestamp = headers['x-webhook-timestamp'];
const signature = headers['x-webhook-signature'];
// No signature header means the webhook has no secret: the delivery is
// unauthenticated. Treat it as unverified.
if (!timestamp || !signature) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > toleranceSeconds) return false;
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Node lowercases incoming header names, so headers['x-webhook-signature'] is the right lookup
on a raw http or Express request.
Python
import hashlib
import hmac
import time
# `raw_body` must be the request body as received (bytes), NOT a re-serialized
# JSON object. In Flask, use `request.get_data()`.
def verify_webhook(raw_body, headers, secret, tolerance_seconds=300):
timestamp = headers.get("X-Webhook-Timestamp")
signature = headers.get("X-Webhook-Signature")
# No signature header means the webhook has no secret: the delivery is
# unauthenticated. Treat it as unverified.
if not timestamp or not signature:
return False
try:
age = abs(int(time.time()) - int(timestamp))
except (TypeError, ValueError):
return False
if age > tolerance_seconds:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Flask, Django, and FastAPI all expose request headers through a case-insensitive mapping, so the capitalized lookups above match however the header arrives on the wire.
Unsigned deliveries
X-Webhook-Signature is set only when the webhook has a secret. A small number of monitors
created before secrets became mandatory still have none, and their deliveries go out unsigned
— X-Webhook-Timestamp is present, X-Webhook-Signature is absent.
An unsigned delivery cannot be authenticated. Both snippets above return false for it, which is
the safe reading: treat it as unverified rather than as trusted. If you own such a monitor,
recreate it so it gets a secret.
Next steps
- Monitors — webhook payload shapes per monitor type
- Scraping — the
X-ByteKit-Eventheader and its event types - API Reference: Monitors — where
webhook_secretis set