Webhook Signature Verification
Verify webhook signatures in any language — no SDK required.
Every webhook delivery includes a stile-signature header containing an HMAC-SHA256 signature. Always verify it before processing the event to prevent spoofed payloads.
Algorithm
The verification algorithm is the same in every language:
- Extract the
stile-signatureheader from the request - Parse the header to get the timestamp (
t) and signature (v1) - Build the signed payload:
"{timestamp}.{raw_body}" - Compute
HMAC-SHA256(webhook_secret, signed_payload)as a hex string - Compare the computed signature with
v1using a timing-safe comparison - Reject if the timestamp is more than 5 minutes old (replay protection)
Header format
stile-signature: t=1741564800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt— Unix timestamp (seconds) when the webhook was sentv1— HMAC-SHA256 signature as lowercase hex
Complete handlers
Each example below is a complete, copy-paste-ready webhook handler. Pick your language:
import { createHmac, timingSafeEqual } from "node:crypto";
const WEBHOOK_SECRET = process.env.STILE_WEBHOOK_SECRET!;
const TOLERANCE = 300; // 5 minutes
export async function POST(req: Request) {
const rawBody = await req.text();
const sig = req.headers.get("stile-signature");
if (!sig) {
return new Response("Missing signature", { status: 400 });
}
// Parse header
const parts = sig.split(",");
const timestamp = parts.find((p) => p.startsWith("t="))?.slice(2);
const signature = parts.find((p) => p.startsWith("v1="))?.slice(3);
if (!timestamp || !signature) {
return new Response("Malformed signature", { status: 400 });
}
// Check timestamp (replay protection)
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp));
if (age > TOLERANCE) {
return new Response("Timestamp expired", { status: 400 });
}
// Compute expected signature
const expected = createHmac("sha256", WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Timing-safe comparison
const valid = timingSafeEqual(
Buffer.from(signature, "hex"),
Buffer.from(expected, "hex"),
);
if (!valid) {
return new Response("Invalid signature", { status: 400 });
}
// Signature verified — process the event
const event = JSON.parse(rawBody);
if (event.type === "verification_session.verified") {
// Grant access, update your database, etc.
}
return Response.json({ received: true });
}import hmac, hashlib, time, json, os
from flask import Flask, request, jsonify
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["STILE_WEBHOOK_SECRET"]
TOLERANCE = 300 # 5 minutes
@app.route("/webhooks", methods=["POST"])
def handle_webhook():
raw_body = request.get_data(as_text=True)
sig_header = request.headers.get("stile-signature", "")
# Parse header
parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p)
timestamp = parts.get("t")
signature = parts.get("v1")
if not timestamp or not signature:
return "Missing signature", 400
# Check timestamp (replay protection)
if abs(time.time() - int(timestamp)) > TOLERANCE:
return "Timestamp expired", 400
# Compute expected signature
payload = f"{timestamp}.{raw_body}"
expected = hmac.new(
WEBHOOK_SECRET.encode(),
payload.encode(),
hashlib.sha256,
).hexdigest()
# Timing-safe comparison
if not hmac.compare_digest(signature, expected):
return "Invalid signature", 400
# Signature verified — process the event
event = json.loads(raw_body)
if event["type"] == "verification_session.verified":
# Grant access, update your database, etc.
pass
return jsonify(received=True)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var webhookSecret = os.Getenv("STILE_WEBHOOK_SECRET")
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
sigHeader := r.Header.Get("stile-signature")
// Parse header
var timestamp, signature string
for _, part := range strings.Split(sigHeader, ",") {
if strings.HasPrefix(part, "t=") {
timestamp = part[2:]
} else if strings.HasPrefix(part, "v1=") {
signature = part[3:]
}
}
if timestamp == "" || signature == "" {
http.Error(w, "Missing signature", 400)
return
}
// Check timestamp (replay protection)
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
http.Error(w, "Timestamp expired", 400)
return
}
// Compute expected signature
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(fmt.Sprintf("%s.%s", timestamp, body)))
expected := hex.EncodeToString(mac.Sum(nil))
// Timing-safe comparison
if !hmac.Equal([]byte(signature), []byte(expected)) {
http.Error(w, "Invalid signature", 400)
return
}
// Signature verified — process the event
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON", 400)
return
}
if event["type"] == "verification_session.verified" {
// Grant access, update your database, etc.
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"received":true}`))
}require "sinatra"
require "openssl"
require "json"
WEBHOOK_SECRET = ENV["STILE_WEBHOOK_SECRET"]
TOLERANCE = 300 # 5 minutes
post "/webhooks" do
raw_body = request.body.read
sig_header = request.env["HTTP_STILE_SIGNATURE"] || ""
# Parse header
parts = sig_header.split(",").map { |p| p.split("=", 2) }.to_h
timestamp = parts["t"]
signature = parts["v1"]
halt 400, "Missing signature" unless timestamp && signature
# Check timestamp (replay protection)
halt 400, "Timestamp expired" if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE
# Compute expected signature
payload = "#{timestamp}.#{raw_body}"
expected = OpenSSL::HMAC.hexdigest("sha256", WEBHOOK_SECRET, payload)
# Timing-safe comparison
halt 400, "Invalid signature" unless OpenSSL.secure_compare(signature, expected)
# Signature verified — process the event
event = JSON.parse(raw_body)
if event["type"] == "verification_session.verified"
# Grant access, update your database, etc.
end
content_type :json
{ received: true }.to_json
end<?php
$webhookSecret = getenv("STILE_WEBHOOK_SECRET");
$tolerance = 300; // 5 minutes
$rawBody = file_get_contents("php://input");
$sigHeader = $_SERVER["HTTP_STILE_SIGNATURE"] ?? "";
// Parse header
$parts = [];
foreach (explode(",", $sigHeader) as $part) {
[$key, $value] = explode("=", $part, 2);
$parts[$key] = $value;
}
$timestamp = $parts["t"] ?? null;
$signature = $parts["v1"] ?? null;
if (!$timestamp || !$signature) {
http_response_code(400);
exit("Missing signature");
}
// Check timestamp (replay protection)
if (abs(time() - intval($timestamp)) > $tolerance) {
http_response_code(400);
exit("Timestamp expired");
}
// Compute expected signature
$payload = "{$timestamp}.{$rawBody}";
$expected = hash_hmac("sha256", $payload, $webhookSecret);
// Timing-safe comparison
if (!hash_equals($signature, $expected)) {
http_response_code(400);
exit("Invalid signature");
}
// Signature verified — process the event
$event = json_decode($rawBody, true);
if ($event["type"] === "verification_session.verified") {
// Grant access, update your database, etc.
}
header("Content-Type: application/json");
echo json_encode(["received" => true]);If you're using Node.js, the @stile/node SDK provides a convenience method:
npm install @stile/nodeimport Stile from "@stile/node";
const stile = new Stile(process.env.STILE_API_KEY!);
export async function POST(req: Request) {
const event = await stile.webhooks.fromRequest(
req,
process.env.STILE_WEBHOOK_SECRET!,
);
if (event.type === "verification_session.verified") {
// Grant access, update your database, etc.
}
return Response.json({ received: true });
}Common pitfalls
Body parsers modify the raw body
Many frameworks (Express, Django, Rails) parse the JSON body before your handler runs. Signature verification requires the raw, unmodified request body.
| Framework | How to access raw body |
|---|---|
| Next.js App Router | request.text() (built-in) |
| Express | Use a raw body middleware on the webhook route (skip express.json()) |
| Flask | request.get_data(as_text=True) |
| Django | request.body.decode() |
| Sinatra | request.body.read |
Go net/http | io.ReadAll(r.Body) |
| PHP | file_get_contents("php://input") |
Clock skew
The timestamp check rejects events older than 5 minutes. If your server's clock is significantly off, legitimate webhooks will be rejected. Use NTP to keep your server clock synchronized.
Timing-safe comparison
Always use a constant-time comparison function (timingSafeEqual, hmac.compare_digest, hash_equals, etc.) to prevent timing attacks that could reveal the signature byte-by-byte.
Related
- Webhooks guide — setup, payload structure, retry behavior, deduplication
- Webhook Endpoints API — create and manage endpoints via the API