Signature Verification
All webhook requests include a signature header that allows you to verify the request came from Scribe Sight.
Signature Header
Each request includes:
X-ScribeSight-Signature: t=1704280500,v1=5257a869e5ecb...
The header contains:
t— Unix timestamp when the signature was generatedv1— HMAC-SHA256 signature
Verification Steps
1. Extract Components
Parse the signature header:
def parse_signature_header(header):
parts = dict(item.split('=', 1) for item in header.split(','))
return parts.get('t'), parts.get('v1')
2. Construct Signed Payload
Concatenate timestamp and request body:
signed_payload = f"{timestamp}.{request_body}"
3. Compute Expected Signature
Generate HMAC-SHA256 using your webhook secret:
import hmac
import hashlib
expected_signature = hmac.new(
webhook_secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
4. Compare Signatures
Use timing-safe comparison:
import hmac
if not hmac.compare_digest(expected_signature, received_signature):
raise ValueError("Invalid signature")
5. Check Timestamp
Reject requests older than 5 minutes to prevent replay attacks:
import time
tolerance = 300 # 5 minutes
current_time = int(time.time())
request_time = int(timestamp)
if abs(current_time - request_time) > tolerance:
raise ValueError("Timestamp too old")
Complete Examples
Python
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_xxxxxxxxxxxx"
def verify_signature(payload: bytes, signature_header: str) -> bool:
"""Verify webhook signature."""
try:
# Parse header
parts = dict(item.split('=', 1) for item in signature_header.split(','))
timestamp = parts.get('t')
signature = parts.get('v1')
if not timestamp or not signature:
return False
# Check timestamp (5 minute tolerance)
if abs(int(time.time()) - int(timestamp)) > 300:
return False
# Compute expected signature
signed_payload = f"{timestamp}.{payload.decode()}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Timing-safe comparison
return hmac.compare_digest(expected, signature)
except Exception:
return False
@app.route('/webhooks/scribesight', methods=['POST'])
def webhook():
signature = request.headers.get('X-ScribeSight-Signature', '')
if not verify_signature(request.data, signature):
abort(401)
event = request.json
# Process event...
return '', 200
Node.js
const crypto = require("crypto");
const express = require("express");
const app = express();
const WEBHOOK_SECRET = "whsec_xxxxxxxxxxxx";
function verifySignature(payload, signatureHeader) {
try {
// Parse header
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("="))
);
const timestamp = parts.t;
const signature = parts.v1;
if (!timestamp || !signature) return false;
// Check timestamp (5 minute tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) return false;
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
} catch {
return false;
}
}
app.post(
"/webhooks/scribesight",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-scribesight-signature"] || "";
if (!verifySignature(req.body.toString(), signature)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body);
// Process event...
res.status(200).send();
}
);
Rotating Secrets
When you rotate your webhook secret:
- The old secret remains valid for 24 hours
- Both signatures are sent during the transition
- Your endpoint should accept either signature
X-ScribeSight-Signature: t=1704280500,v1=new_signature,v1_prev=old_signature