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, jsonfrom 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===.