Webhook security — verify every request

Every outbound webhook Wilow sends (lead, ticket, tool) is signed. This page explains the scheme in one place so you can wire it up once and trust it everywhere.

If you haven't configured a webhook yet, start at Lead webhook or Ticket webhook — this page is the reference you come back to when implementing the receiver.

Headers we send

Header Value Purpose
Content-Type application/json Body is always a UTF-8 JSON object.
X-Wilow-Signature sha256=<hex> HMAC-SHA256 of the raw body, using your secret.
X-Wilow-Event e.g. lead.created, lead.re_engaged, handoff.created Lets you route on the event without parsing the body.
X-Wilow-Delivery-Id UUID v4 Unique per send. Use for dedup / logging / replay detection.
Authorization Bearer <jwt> (tool webhooks only, when configured) Forwarded visitor JWT — see Authenticated visitor tools.
User-Agent Wilow-Webhook/1.0 (or Wilow-Tool-Webhook/1.0) Identifies Wilow in your access logs.

Your own custom headers on the lead webhook (API tokens, etc.) are appended after these. You can't overwrite any of the above — the server drops attempts to set Content-Type, Authorization, X-Wilow-Signature, X-Wilow-Event, X-Wilow-Delivery-Id, or User-Agent via custom headers, so signature verification can't be bypassed from the config side and the visitor JWT forward (#73) can't be clobbered with a static credential.

The signing scheme

  1. We serialize the payload to a compact JSON string (no extra whitespace, no trailing newline).
  2. We compute HMAC-SHA256(secret, rawBody) and hex-encode it.
  3. We send the hex digest prefixed with sha256= in the X-Wilow-Signature header.

Your job on the receiving side is the same three steps in reverse:

  1. Read the raw request body — before any JSON parsing.
  2. Recompute HMAC-SHA256(secret, rawBody).
  3. Compare to the received signature using a constant-time compare. Anything else — ==, string equality, a === b — is vulnerable to timing attacks.

Reject on mismatch with 401. Don't log the expected signature value.

Verifying the signature

Pick the language for your stack — the logic is the same: hash the raw bytes with HMAC-SHA256 and your secret, prefix with sha256=, and constant-time-compare.

import crypto from "node:crypto";
import express from "express";

const app = express();

app.post(
  "/wilow/leads",
  // Raw parser — must run BEFORE express.json() or any other middleware
  // that consumes the body stream.
  express.raw({ type: "application/json" }),
  (req, res) => {
    const received = req.header("X-Wilow-Signature") ?? "";
    const expected = "sha256=" + crypto
      .createHmac("sha256", process.env.WILOW_LEAD_SECRET)
      .update(req.body) // Buffer, not string
      .digest("hex");

    // Constant-time compare; Buffer.from is required to cast both sides to the same length.
    const ok = received.length === expected.length
      && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
    if (!ok) return res.sendStatus(401);

    const payload = JSON.parse(req.body.toString("utf8"));
    // handle payload…
    res.sendStatus(200);
  },
);
import hmac
import hashlib
import os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WILOW_LEAD_SECRET"].encode()

@app.post("/wilow/leads")
def receive_lead():
    raw = request.get_data()  # bytes, BEFORE request.json
    received = request.headers.get("X-Wilow-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(received, expected):
        abort(401)
    payload = request.get_json()
    # handle payload…
    return "", 200

# FastAPI: request.body() must be awaited; everything else is identical.
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "os"
)

var secret = []byte(os.Getenv("WILOW_LEAD_SECRET"))

func verify(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "read failed", http.StatusBadRequest)
        return
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write(body)
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
    received := r.Header.Get("X-Wilow-Signature")
    if !hmac.Equal([]byte(received), []byte(expected)) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    // parse `body` and handle…
    w.WriteHeader(http.StatusOK)
}
require "openssl"

class WilowWebhooksController < ActionController::API
  def leads
    raw = request.raw_post
    received = request.headers["X-Wilow-Signature"].to_s
    expected = "sha256=" + OpenSSL::HMAC.hexdigest("SHA256", ENV["WILOW_LEAD_SECRET"], raw)
    unless ActiveSupport::SecurityUtils.secure_compare(received, expected)
      head :unauthorized
      return
    end
    payload = JSON.parse(raw)
    # handle payload…
    head :ok
  end
end

Raw-body pitfalls by framework

The single most common mistake: your framework parses the JSON body before you verify, then you re-serialize and the bytes differ from what we signed. Signature mismatch, but it's not an attack — your framework ate the raw bytes.

  • Express: app.use(express.json()) is global by default. Either scope it away from the webhook route (app.use("/wilow", express.raw({ type: "application/json" }))) or add a pre-middleware that captures req.rawBody before JSON parsing.
  • Fastify: addContentTypeParser — register a parser that keeps the raw buffer.
  • Flask: request.get_data(cache=True) preserves the raw body for verification even if request.get_json() runs later.
  • Django: request.body is the raw bytes. Don't call .POST or .data on the request before reading it — some middleware breaks this.
  • Rails: request.raw_post is the raw bytes. params will have the parsed version already; don't re-serialize that and expect it to match.
  • Go / net/http: r.Body is a stream — read it once into a buffer with io.ReadAll. If you need it downstream, restore with r.Body = io.NopCloser(bytes.NewBuffer(body)).

Dedup + replay protection

Every request carries X-Wilow-Delivery-Id — a fresh UUID per send. We don't currently retry on failure, but if we do in the future (or if a network glitch causes a duplicate at the edge), you should dedupe on this header on your side.

const seen = new Set(); // in practice, use Redis or your DB

if (seen.has(deliveryId)) return res.sendStatus(200); // already processed
seen.add(deliveryId);

A timestamp-based replay window isn't currently part of the scheme — we don't include a timestamp in the signed bytes. If you need replay protection beyond delivery-id dedup, file an issue and we'll consider adding a X-Wilow-Timestamp header with the usual |timestamp|body| signing.

Rotating the secret

Admin → Widget → Lead webhook / Ticket webhook → Rotate secret.

Rotation is instant on our side: the next send uses the new secret. Expect a brief window where requests in flight with the old secret are still being processed on yours — if your receiver rejects them, we log the 401 and move on (no retries). Plan rotations during a lull.

You can also do a zero-downtime rotation:

  1. Accept both the old and new secret on your side. Try each; accept if either verifies.
  2. Rotate in the Wilow admin. Observe a clean run of the new-secret requests.
  3. Remove the old secret from your verifier.

Common failure modes

  • Always 401 — you're either using a stale secret or JSON-parsing before verifying. Log the first 8 hex chars of both received and expected and check whether your raw body length matches Content-Length.
  • Intermittent 401 under load — a reverse proxy or CDN is mutating the body. Terminate TLS and read the body directly; don't let anything in front of your verifier normalize whitespace, lowercase keys, or re-serialize.
  • Signature mismatches after a custom-header edit — custom headers are sent alongside the signature but are NOT covered by it. Adding or changing them never invalidates a signature; if yours breaks when you edit headers, you're including request headers in what you hash. Hash only the body.
  • Tests work, prod doesn't — if your local test harness sends JSON via JSON.stringify(obj) in your test runner, the signed bytes match. Wilow sends compact JSON too, but any middleware that inserts a trailing newline or a BOM will cause mismatches in prod only.