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

HeaderAlways presentDescription
X-Webhook-TimestampYesUnix timestamp in seconds at the moment the delivery attempt was signed
X-Webhook-SignatureOnly when the webhook has a secretsha256= followed by the lowercase hex HMAC-SHA256 digest
X-ByteKit-EventYesEvent 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:

  • timestamp is the X-Webhook-Timestamp header value verbatim, as the decimal string it arrives as — not a re-formatted or re-parsed date.
  • raw_body is 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.
  • secret is the webhook secret — the value you passed as webhook_secret, or the one returned once in the webhook_secret field 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

verify-webhook.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

verify_webhook.py
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 unsignedX-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