Skip to main content

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 generated
  • v1 — 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:

  1. The old secret remains valid for 24 hours
  2. Both signatures are sent during the transition
  3. Your endpoint should accept either signature
X-ScribeSight-Signature: t=1704280500,v1=new_signature,v1_prev=old_signature