> ## Documentation Index
> Fetch the complete documentation index at: https://docs.botdog.co/llms.txt
> Use this file to discover all available pages before exploring further.

# Signature verification

> Verify the X-Webhook-Signature header so you know a delivery really came from Botdog.

Every webhook `POST` from Botdog includes an `X-Webhook-Signature` header. Verifying it lets your endpoint reject forged requests and replays.

## Header format

```http theme={null}
X-Webhook-Signature: t=1730000000,s=9f1c3aef8e3b…ab
```

| Part | Meaning                                                                         |
| ---- | ------------------------------------------------------------------------------- |
| `t`  | UNIX timestamp (seconds) at the moment Botdog computed the signature.           |
| `s`  | Lowercase hex `HMAC-SHA256` of `"{t}.{rawBody}"` using your webhook **secret**. |

The secret is shown **once** when the webhook is created in the [dashboard](https://app.botdog.co/settings/integrations/webhooks). Store it securely — typically in an env var or secret manager. You can rotate it from the same screen.

## Verification algorithm

1. Read the `X-Webhook-Signature` header and split on `,` to get `t=…` and `s=…`.
2. Read the **raw** request body as a string. Do not re-serialize it — even reordering keys will change the bytes and break the signature.
3. Compute `HMAC-SHA256(secret, "{t}.{rawBody}")` and hex-encode the result.
4. Compare the computed value with `s` using a **constant-time** comparison (e.g. `crypto.timingSafeEqual`).
5. Optionally reject requests where `t` is older than a few minutes to defeat replay attacks.

## Examples

### Node.js (Express)

Capture the raw body before JSON parsing so the bytes match what Botdog signed.

```javascript theme={null}
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.BOTDOG_WEBHOOK_SECRET;

app.post('/webhooks/botdog', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.header('x-webhook-signature') ?? '';
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const { t, s } = parts;

  if (!t || !s) return res.status(400).end();

  // Optional: reject if older than 5 minutes
  const ageSeconds = Math.floor(Date.now() / 1000) - Number(t);
  if (ageSeconds > 5 * 60) return res.status(400).end();

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${t}.${req.body.toString('utf8')}`)
    .digest('hex');

  const ok = expected.length === s.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(s));

  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString('utf8'));
  // handle the event…
  res.status(200).end();
});
```

### Python (Flask)

```python theme={null}
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["BOTDOG_WEBHOOK_SECRET"].encode()

@app.post("/webhooks/botdog")
def botdog_webhook():
    header = request.headers.get("X-Webhook-Signature", "")
    parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
    t = parts.get("t")
    s = parts.get("s")
    if not t or not s:
        abort(400)

    # Optional: reject if older than 5 minutes
    if int(time.time()) - int(t) > 5 * 60:
        abort(400)

    raw = request.get_data()  # bytes — do NOT re-serialize JSON
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, s):
        abort(401)

    event = request.get_json()
    # handle the event…
    return "", 200
```

### Ruby (Sinatra)

```ruby theme={null}
require "openssl"
require "sinatra"

SECRET = ENV.fetch("BOTDOG_WEBHOOK_SECRET")

post "/webhooks/botdog" do
  header = request.env["HTTP_X_WEBHOOK_SIGNATURE"].to_s
  parts  = Hash[header.split(",").map { |kv| kv.split("=", 2) }]
  t = parts["t"]
  s = parts["s"]
  halt 400 if t.nil? || s.nil?

  halt 400 if (Time.now.to_i - t.to_i) > 5 * 60

  request.body.rewind
  raw = request.body.read
  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{t}.#{raw}")

  halt 401 unless Rack::Utils.secure_compare(expected, s)

  # handle the event…
  status 200
end
```

## Common verification failures

| Symptom                                             | Cause                                                                                   |
| --------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Signature never matches.                            | You're hashing the parsed-then-re-serialized body. Sign the **raw** bytes.              |
| Works locally, fails behind a proxy.                | A middleware (compression, body parser) mutated the body. Capture the raw body earlier. |
| Matches sometimes, fails other times.               | You're comparing strings without constant-time equality, or your secret is wrong.       |
| All deliveries are rejected after rotating the key. | Existing webhooks still use the previous secret until you update them in the dashboard. |

## Rotating the secret

If a secret may have been exposed, rotate it from the [Webhooks dashboard](https://app.botdog.co/settings/integrations/webhooks). Update your endpoint with the new value as soon as possible — deliveries signed with the old secret will fail verification after rotation.
