Webhook Signature Verification
Verify and parse Stile webhook requests with standard-library examples for Node.js, Python, Go, Ruby, and PHP.
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.)
The signing contract is language-independent. The examples below use standard platform cryptography libraries, so no Stile package is required.
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
Signature verification examples
Each example verifies and parses one request, then calls an application-owned transactional processor. That processor is intentionally not hidden inside signature code; it must enforce the business-processing contract below before changing application state.
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 ||
!/^\d{1,16}$/.test(timestamp) ||
!Number.isSafeInteger(Number(timestamp)) ||
!signature ||
!/^[0-9a-f]{64}$/.test(signature)
) {
return new Response("Malformed signature", { status: 400 });
}
// Check timestamp (replay protection)
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(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. This application-owned function must implement
// the transaction and association checks documented below.
const event = JSON.parse(rawBody);
await processStileEventTransactionally(event);
return Response.json({ received: true });
}import hmac, hashlib, time, json, os, re
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 timestamp.isdigit()
or len(timestamp) > 16
or not signature
or not re.fullmatch(r"[0-9a-f]{64}", signature)
):
return "Malformed signature", 400
# Check timestamp (replay protection)
timestamp_value = int(timestamp)
if abs(time.time() - timestamp_value) > 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. This application-owned function must implement
# the transaction and association checks documented below.
event = json.loads(raw_body)
process_stile_event_transactionally(event)
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, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
http.Error(w, "Malformed signature", 400)
return
}
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 := mac.Sum(nil)
provided, err := hex.DecodeString(signature)
if err != nil || len(provided) != sha256.Size {
http.Error(w, "Malformed signature", 400)
return
}
// Timing-safe comparison
if !hmac.Equal(provided, expected) {
http.Error(w, "Invalid signature", 400)
return
}
// Signature verified. This application-owned function must implement
// the transaction and association checks documented below.
var event map[string]interface{}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "Invalid JSON", 400)
return
}
if err := processStileEventTransactionally(event); err != nil {
http.Error(w, "Processing failed", 500)
return
}
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(",").each do |part|
key, value = part.split("=", 2)
parts[key] = value if value
end
timestamp = parts["t"]
signature = parts["v1"]
unless timestamp&.match?(/\A\d{1,16}\z/) && signature&.match?(/\A[0-9a-f]{64}\z/)
halt 400, "Malformed signature"
end
# 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. This application-owned function must implement
# the transaction and association checks documented below.
event = JSON.parse(raw_body)
process_stile_event_transactionally(event)
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) {
if (!str_contains($part, "=")) {
continue;
}
[$key, $value] = explode("=", $part, 2);
$parts[$key] = $value;
}
$timestamp = $parts["t"] ?? null;
$signature = $parts["v1"] ?? null;
if (
!is_string($timestamp)
|| !ctype_digit($timestamp)
|| strlen($timestamp) > 16
|| !is_string($signature)
|| preg_match('/^[0-9a-f]{64}$/', $signature) !== 1
) {
http_response_code(400);
exit("Malformed 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. This application-owned function must implement
// the transaction and association checks documented below.
$event = json_decode($rawBody, true);
process_stile_event_transactionally($event);
header("Content-Type: application/json");
echo json_encode(["received" => true]);Required business processing
A valid signature proves that Stile sent the payload. It does not prove that the referenced session belongs to the user, order, or action currently being fulfilled. Implement the processStileEventTransactionally placeholder in the examples with this contract:
if event.type == "verification_session.verified":
session = event.data.object
begin database transaction
insert event.id into processed_webhook_events with a unique constraint
if event.id already exists: commit and return
update exactly one pending transaction where:
transaction.stile_session_id == session.id
transaction.id == session.client_reference_id
session.status == "verified"
if exactly one row was not updated: roll back and raise an error
commitThe session ID must be the one you persisted when creating the session, and client_reference_id must be the server-set reference for that same transaction. Return 2xx only after this work is durably persisted or queued. Return 5xx on a temporary processing failure so Stile retries.
Verification checklist
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 | Automatic and manual retries reuse the event and delivery IDs; another subscribed endpoint has its own delivery record — 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.