Verify Webhook Signatures

Verify HMAC-SHA256 signatures to ensure webhook payloads are authentic.

Signature Verification

Every webhook request includes an X-Webhook-Signature header containing an HMAC-SHA256 signature. Always verify this signature before processing the payload.

How Signing Works

  1. ThinnestAI constructs: {timestamp}.{raw_json_body}
  2. Signs with HMAC-SHA256 using your endpoint's secret
  3. Sends as X-Webhook-Signature: sha256={hex_digest}

Python

import hmac
import hashlib
import time

def verify_webhook(request_body: bytes, headers: dict, secret: str) -> bool:
    signature = headers.get("X-Webhook-Signature", "")
    timestamp = headers.get("X-Webhook-Timestamp", "")

    # Reject old timestamps (replay protection — 5 min window)
    if abs(time.time() - int(timestamp)) > 300:
        return False

    # Reconstruct expected signature
    message = f"{timestamp}.{request_body.decode()}".encode()
    expected = "sha256=" + hmac.new(
        secret.encode(), message, hashlib.sha256
    ).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(signature, expected)

FastAPI Example

from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = "your_endpoint_secret_here"

@app.post("/webhooks/thinnestai")
async def handle_webhook(request: Request):
    body = await request.body()
    headers = dict(request.headers)

    if not verify_webhook(body, headers, WEBHOOK_SECRET):
        raise HTTPException(401, "Invalid signature")

    import json
    payload = json.loads(body)
    event = payload["event"]
    data = payload["data"]

    if event == "chat.message.completed":
        print(f"Agent responded: {data.get('message', '')[:100]}")
    elif event == "voice.call.ended":
        print(f"Call ended, duration: {data.get('duration', 0)}s")

    return {"ok": True}

Node.js

const crypto = require('crypto');

function verifyWebhook(rawBody, headers, secret) {
  const signature = headers['x-webhook-signature'] || '';
  const timestamp = headers['x-webhook-timestamp'] || '';

  // Replay protection (5 min window)
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    return false;
  }

  const message = `${timestamp}.${rawBody}`;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(message)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

Express Example

const express = require('express');
const app = express();

const WEBHOOK_SECRET = 'your_endpoint_secret_here';

app.post('/webhooks/thinnestai', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyWebhook(req.body.toString(), req.headers, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const payload = JSON.parse(req.body);
  console.log(`Event: ${payload.event}`, payload.data);

  res.json({ ok: true });
});

Go

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "math"
    "strconv"
    "time"
)

func verifyWebhook(body []byte, signature, timestamp, secret string) bool {
    // Replay protection
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return false
    }

    message := fmt.Sprintf("%s.%s", timestamp, string(body))
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(message))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(signature), []byte(expected))
}

Best Practices

  • Always verify signatures before processing payloads
  • Check timestamps to prevent replay attacks (5 minute window)
  • Use constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node.js)
  • Respond with 2xx quickly — do heavy processing asynchronously
  • Handle duplicates — use X-Webhook-Delivery-Id for idempotency
  • Store the secret securely — treat it like an API key

On this page