stile
Guides

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:

  1. Extract the stile-signature header from the request
  2. Parse the header to get the timestamp (t) and signature (v1)
  3. Build the signed payload: "{timestamp}.{raw_body}"
  4. Compute HMAC-SHA256(webhook_secret, signed_payload) as a hex string
  5. Compare the computed signature with v1 using a timing-safe comparison
  6. Reject if the timestamp is more than 5 minutes old (replay protection)

Header format

stile-signature: t=1741564800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t — Unix timestamp (seconds) when the webhook was sent
  • v1 — HMAC-SHA256 signature as lowercase hex

Complete handlers

Each example below is a complete, copy-paste-ready webhook handler. Pick your language:

app/api/webhooks/route.ts
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 });
}
webhooks.py
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)
webhooks.go
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}`))
}
webhooks.rb
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
webhooks.php
<?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/node
app/api/webhooks/route.ts
import 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.

FrameworkHow to access raw body
Next.js App Routerrequest.text() (built-in)
ExpressUse a raw body middleware on the webhook route (skip express.json())
Flaskrequest.get_data(as_text=True)
Djangorequest.body.decode()
Sinatrarequest.body.read
Go net/httpio.ReadAll(r.Body)
PHPfile_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.

On this page