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
- We serialize the payload to a compact JSON string (no extra whitespace, no trailing newline).
- We compute
HMAC-SHA256(secret, rawBody)and hex-encode it. - We send the hex digest prefixed with
sha256=in theX-Wilow-Signatureheader.
Your job on the receiving side is the same three steps in reverse:
- Read the raw request body — before any JSON parsing.
- Recompute
HMAC-SHA256(secret, rawBody). - 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
endRaw-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 capturesreq.rawBodybefore 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 ifrequest.get_json()runs later. - Django:
request.bodyis the raw bytes. Don't call.POSTor.dataon the request before reading it — some middleware breaks this. - Rails:
request.raw_postis the raw bytes.paramswill have the parsed version already; don't re-serialize that and expect it to match. - Go / net/http:
r.Bodyis a stream — read it once into a buffer withio.ReadAll. If you need it downstream, restore withr.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:
- Accept both the old and new secret on your side. Try each; accept if either verifies.
- Rotate in the Wilow admin. Observe a clean run of the new-secret requests.
- 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
receivedandexpectedand check whether your raw body length matchesContent-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.