Skip to content

Verify webhook signatures

Every outbound webhook delivery includes an x-stayingapi-signature header. To trust the payload came from us, you must:

  1. Parse the header to extract t (timestamp) and v1 (signature).
  2. Recompute HMAC-SHA256(t.rawBody, signing_secret).
  3. Timing-safe compare it to v1.
  4. 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, os
from 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)
}

Next