# 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](https://bytekit.com/docs/guides/scraping#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

```js title="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

```python title="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 **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](https://bytekit.com/docs/guides/monitors) — webhook payload shapes per monitor type
- [Scraping](https://bytekit.com/docs/guides/scraping) — the `X-ByteKit-Event` header and its event types
- [API Reference: Monitors](https://bytekit.com/docs/api/monitors/createmonitor) — where `webhook_secret` is set