Verify webhook signatures
Every outbound webhook delivery includes an x-stayingapi-signature header. To
trust the payload came from us, you must:
- Parse the header to extract
t(timestamp) andv1(signature). - Recompute
HMAC-SHA256(t.rawBody, signing_secret). - Timing-safe compare it to
v1. - Reject if the timestamp is older than 5 minutes.
The signing_secret is the value shown to you exactly once when you created the
webhook in the dashboard or via POST /v1/webhooks.
Node (express)
import express from "express";import crypto from "node:crypto";
const app = express();const SECRET = process.env.STAYINGAPI_WEBHOOK_SECRET;
app.post( "/hooks/staying", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header("x-stayingapi-signature"); if (!sig) return res.status(401).send("missing signature");
const [tPart, v1Part] = sig.split(","); const t = tPart.split("=")[1]; const v1 = v1Part.split("=")[1];
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) { return res.status(401).send("timestamp too old"); }
const signed = `${t}.${req.body.toString("utf8")}`; const expected = crypto.createHmac("sha256", SECRET).update(signed).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) { return res.status(401).send("bad signature"); }
const event = JSON.parse(req.body.toString("utf8")); console.log("event:", event.event); res.status(200).send("ok"); });
app.listen(3000);Python (flask)
import hmac, hashlib, time, osfrom flask import Flask, request
app = Flask(__name__)SECRET = os.environ["STAYINGAPI_WEBHOOK_SECRET"].encode()
@app.post("/hooks/staying")def hook(): sig = request.headers.get("x-stayingapi-signature") if not sig: return ("missing signature", 401) parts = dict(p.split("=") for p in sig.split(",")) t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > 300: return ("timestamp too old", 401)
signed = f"{t}.{request.data.decode()}".encode() expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(v1, expected): return ("bad signature", 401)
event = request.get_json() print("event:", event["event"]) return ("ok", 200)Go
package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time")
var secret = []byte(os.Getenv("STAYINGAPI_WEBHOOK_SECRET"))
func hook(w http.ResponseWriter, r *http.Request) { sig := r.Header.Get("x-stayingapi-signature") parts := strings.Split(sig, ",") t := strings.TrimPrefix(parts[0], "t=") v1 := strings.TrimPrefix(parts[1], "v1=")
if ts, _ := strconv.ParseInt(t, 10, 64); time.Now().Unix()-ts > 300 { http.Error(w, "stale", 401) return }
body, _ := io.ReadAll(r.Body) mac := hmac.New(sha256.New, secret) mac.Write([]byte(t + "." + string(body))) expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(v1), []byte(expected)) { http.Error(w, "bad sig", 401) return } w.Write([]byte("ok"))}
func main() { http.HandleFunc("/hooks/staying", hook) http.ListenAndServe(":3000", nil)}