Docs

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:

  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

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.

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 ||
    !/^\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 });
}
webhooks.py
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)
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, 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}`))
}
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(",").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
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) {
    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
    commit

The 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:

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 idAutomatic 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.

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