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.
Every webhook POST from Botdog includes an X-Webhook-Signature header. Verifying it lets your endpoint reject forged requests and replays.
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. Store it securely — typically in an env var or secret manager. You can rotate it from the same screen.
Verification algorithm
- Read the
X-Webhook-Signature header and split on , to get t=… and s=….
- Read the raw request body as a string. Do not re-serialize it — even reordering keys will change the bytes and break the signature.
- Compute
HMAC-SHA256(secret, "{t}.{rawBody}") and hex-encode the result.
- Compare the computed value with
s using a constant-time comparison (e.g. crypto.timingSafeEqual).
- 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.
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)
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)
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. Update your endpoint with the new value as soon as possible — deliveries signed with the old secret will fail verification after rotation.