Skip to content

Verify a webhook signature

Every event we send carries X-Zillow-Signature: t=<unix>,v1=<hex>. Verify by recomputing HMAC-SHA256 over <t>.<raw_body> with your webhook secret.

Express (Node.js)

import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.ZILLOW_WEBHOOK_SECRET;
// IMPORTANT: parse as raw bytes, not JSON, so we can hash the exact body.
app.post("/webhooks/zillow", express.raw({ type: "*/*" }), (req, res) => {
const sigHeader = req.header("X-Zillow-Signature") ?? "";
const m = /^t=(\d+),v1=([a-f0-9]+)$/.exec(sigHeader);
if (!m) return res.status(401).send("bad signature");
const [, ts, sig] = m;
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return res.status(401).send("stale signature");
}
const expected = crypto.createHmac("sha256", SECRET)
.update(`${ts}.${req.body.toString("utf8")}`).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
console.log("Got", event.event, "for job", event.data.job.id);
// Queue work; respond fast.
res.status(200).end();
});
app.listen(8080);

FastAPI (Python)

import hmac, hashlib, os, re, time, json
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["ZILLOW_WEBHOOK_SECRET"]
@app.post("/webhooks/zillow")
async def hook(request: Request):
raw = await request.body()
header = request.headers.get("x-zillow-signature", "")
m = re.match(r"^t=(\d+),v1=([a-f0-9]+)$", header)
if not m: raise HTTPException(401, "bad signature")
ts, sig = m.group(1), m.group(2)
if abs(time.time() - int(ts)) > 300:
raise HTTPException(401, "stale signature")
expected = hmac.new(SECRET.encode(), f"{ts}.{raw.decode()}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
raise HTTPException(401, "bad signature")
event = json.loads(raw)
# ... queue work and return fast
return {"ok": True}

Go

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var secret = []byte(os.Getenv("ZILLOW_WEBHOOK_SECRET"))
func handler(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
parts := strings.Split(r.Header.Get("X-Zillow-Signature"), ",")
if len(parts) != 2 { http.Error(w, "bad sig", 401); return }
ts := strings.TrimPrefix(parts[0], "t=")
sig := strings.TrimPrefix(parts[1], "v1=")
tsI, _ := strconv.ParseInt(ts, 10, 64)
if math.Abs(float64(time.Now().Unix() - tsI)) > 300 { http.Error(w, "stale", 401); return }
h := hmac.New(sha256.New, secret)
h.Write([]byte(ts + "." + string(raw)))
expected := hex.EncodeToString(h.Sum(nil))
if !hmac.Equal([]byte(sig), []byte(expected)) { http.Error(w, "bad sig", 401); return }
// ...
w.WriteHeader(200)
}
func main() {
http.HandleFunc("/webhooks/zillow", handler)
http.ListenAndServe(":8080", nil)
}

Common pitfalls

  • Hashing the parsed object instead of the raw body. The whitespace and key order matter. Always use the raw bytes.
  • Not checking the timestamp. Without skew validation, an attacker who once captured a request could replay it forever.
  • Constant-time compare. Use crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal — not ===.