stile
Guides

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:

  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 });
}

Verification checklist

Whether you use the SDK or one of the handlers above, a correct webhook handler does all five of these:

CheckWhy it matters
Verify against the raw bodyBody parsers re-serialize JSON; even an equivalent payload produces a different signature
Use a timing-safe comparisonPlain string equality (===, ==) leaks information through response timing
Enforce the 5-minute toleranceRejecting stale timestamps blocks replay of captured deliveries
Respond 400 on any failureNever return 2xx for — or process — a payload you couldn't verify
Dedupe on the event idRetries 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.

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.

Next steps

On this page