Webhook Signature Verification
Verify the Stile-Signature header — three lines on Node.js with @stile/node, complete copy-paste handlers for every other stack.
Every webhook delivery is signed with an HMAC-SHA256 signature in the Stile-Signature header. Verify it before processing any event — an unverified endpoint will accept spoofed payloads from anyone who knows your URL. (HTTP header names are case-insensitive — the examples below read it as stile-signature, which is how most frameworks normalize it.)
On Node.js? Three lines
Don't implement verification yourself — @stile/node ships the verifier:
import Stile from "@stile/node";
const stile = new Stile(process.env.STILE_API_KEY!);
const event = await stile.webhooks.fromRequest(req, process.env.STILE_WEBHOOK_SECRET!);fromRequest() parses the header, enforces the timestamp window, and compares in constant time — and it stays correct as the signing scheme evolves. It works with any framework built on the Web API Request object (Next.js, Hono, Cloudflare Workers, Bun, Deno); if yours isn't, stile.webhooks.constructEvent(rawBody, signatureHeader, secret) takes the raw body directly. On failure it throws WebhookSignatureError with a code of missing_header, invalid_header, timestamp_expired, or signature_mismatch — catch it and respond 400. See the Node SDK reference for details.
The rest of this page is for every other stack. The complete handlers below — Python, Go, Ruby, PHP, and framework-agnostic Node.js — are maintained as part of the docs.
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 });
}Verification checklist
Whether you use the SDK or one of the handlers above, a correct webhook handler does all five of these:
| Check | Why it matters |
|---|---|
| Verify against the raw body | Body parsers re-serialize JSON; even an equivalent payload produces a different signature |
| Use a timing-safe comparison | Plain string equality (===, ==) leaks information through response timing |
| Enforce the 5-minute tolerance | Rejecting stale timestamps blocks replay of captured deliveries |
| Respond 400 on any failure | Never return 2xx for — or process — a payload you couldn't verify |
Dedupe on the event id | Retries reuse the event id (with new delivery IDs) — see handling duplicates |
Keep the signing secret server-side
The endpoint secret is shown once at creation — store it in your environment, never in client code. If it leaks, rotate it via the rotate-secret endpoint, which returns the new secret once.
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.
Next steps
Webhooks Guide
Endpoint setup, payload structure, retry schedule, deduplication, and local testing.
Webhook Endpoints API
Create and manage endpoints, inspect deliveries, retry failures, rotate secrets.
Events API
The event object and the full catalog of event types.
Security Best Practices
Key management, anti-spoofing, and why webhooks are the source of truth.
Webhooks
Receive real-time notifications when verification events occur — endpoint setup, signature verification, retries, deduplication, and local testing.
Error Handling
Parse Stile's error envelope, branch on stable error codes, and retry safely with exponential backoff and idempotency keys — in any language, no SDK required.