Skip to main content

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.

Header format

X-Webhook-Signature: t=1730000000,s=9f1c3aef8e3b…ab
PartMeaning
tUNIX timestamp (seconds) at the moment Botdog computed the signature.
sLowercase 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

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

SymptomCause
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.