Verify webhook signatures
SchedStack signs every delivery so your endpoint can prove the request came from us and
not an attacker who learned your URL. Delivery is at-least-once: the same occurrence can
arrive more than once (retries, reclaims after a crash). The signature plus the
Idempotency-Key header are what make that safe — verify the signature, then dedup on the
key before you act.
This is a static guarantee you implement on your side. Read it once, copy a receiver below, and you are done.
What we send
Section titled “What we send”On each delivery attempt, SchedStack sets these request headers:
| Header | Example | Meaning |
|---|---|---|
Sched-Signature |
t=1719460800,v1=8f3c… |
Timestamp + one or more HMAC signatures. Omitted entirely if the schedule has no signing secret — see Unsigned deliveries. |
Sched-Timestamp |
1719460800 |
Unix seconds when we signed. Equals the t inside Sched-Signature. |
Sched-Delivery-Id |
dlv_2a9f… |
Stable id for this delivery. |
Sched-Attempt |
1 |
Attempt counter for this occurrence (a decimal integer, 1-based; increments on retry). |
Idempotency-Key |
evt_42 |
Stable across retries and reclaims of the same occurrence. Dedup on this. Falls back to the delivery id when you did not supply one. |
The Sched-Signature value is a comma-separated list, no spaces:
Sched-Signature: t=<unix_seconds>,v1=<hex>[,v1=<hex>...]t— the Unix timestamp (seconds) we signed at.v1— a hex-encoded HMAC-SHA256 signature. There is onev1per active signing secret. During a secret rotation you will see two. Accept the request if anyv1verifies against the secret you hold.
The signed string
Section titled “The signed string”We compute the signature over this exact byte string:
{timestamp}.{delivery_id}.{attempt}.{METHOD}.{path}.{body}Concatenated literally, joined by ., with the raw request body appended last:
{timestamp}— thetvalue (same integer asSched-Timestamp).{delivery_id}— theSched-Delivery-Idvalue.{attempt}— theSched-Attemptvalue, used verbatim as its decimal string.{METHOD}— the HTTP method, uppercased (POST,PUT, …).{path}— the URL-escaped path of your endpoint, with no query string (defaults to/).{body}— the raw request body bytes, exactly as received, before any JSON parsing.
The signature is then HMAC-SHA256(secret, signed_string), hex-encoded.
Verify a delivery
Section titled “Verify a delivery”-
Parse
tand everyv1out of theSched-Signatureheader. -
Reject replays. If
|now − t|exceeds your tolerance, reject with400. We recommend 300 seconds. -
Rebuild the signed string from this request:
t,Sched-Delivery-Id,Sched-Attempt, the uppercased method, the escaped path (no query string), then the raw body bytes. -
Compute
HMAC-SHA256(your_secret, signed_string)and hex-encode it. -
Constant-time compare your result against each
v1. Accept if any matches. (Constant-time compare avoids leaking the secret through timing.) -
Dedup on
Idempotency-Keybefore you act on the payload. Only now is it safe to JSON-parse.
Runnable receivers
Section titled “Runnable receivers”Each example reads the raw body, rejects stale timestamps, accepts any matching v1, and
dedups on Idempotency-Key. Replace the in-memory dedup stub with a durable store (a unique
constraint on the key, Redis SETNX, etc.).
import express from "express";import crypto from "node:crypto";
// The signing secret you configured for the schedule, as raw bytes.const SIGNING_SECRET = process.env.SCHEDSTACK_SIGNING_SECRET;const TOLERANCE_SECONDS = 300;
const app = express();
// Capture the RAW body. Do NOT use express.json() ahead of this route — it// would consume the stream and you would lose the exact bytes you must sign.app.use(express.raw({ type: "*/*" }));
// Stub: replace with a durable, atomic check (DB unique key, Redis SETNX, …).const seen = new Set();const alreadyProcessed = (k) => seen.has(k);const markProcessed = (k) => seen.add(k);
app.post("/webhooks/sched", (req, res) => { const header = req.get("Sched-Signature"); if (!header) return res.status(400).send("missing signature"); // unsigned delivery
// 1. Parse t and every v1. let t; const v1s = []; for (const part of header.split(",")) { const [k, v] = part.trim().split("="); if (k === "t") t = Number(v); else if (k === "v1" && v) v1s.push(v); } if (!Number.isFinite(t) || v1s.length === 0) { return res.status(400).send("bad signature header"); }
// 2. Reject stale timestamps (replay protection). const now = Math.floor(Date.now() / 1000); if (Math.abs(now - t) > TOLERANCE_SECONDS) { return res.status(400).send("timestamp out of tolerance"); }
// 3. Rebuild the signed string. body is a Buffer of the raw bytes. const deliveryId = req.get("Sched-Delivery-Id"); const attempt = req.get("Sched-Attempt"); const method = req.method.toUpperCase(); const path = new URL(req.originalUrl, "http://placeholder").pathname; // escaped, no query const body = req.body; const signed = Buffer.concat([ Buffer.from(`${t}.${deliveryId}.${attempt}.${method}.${path}.`), body, ]);
// 4. HMAC-SHA256, hex. const expected = crypto.createHmac("sha256", SIGNING_SECRET).update(signed).digest("hex"); const expectedBuf = Buffer.from(expected);
// 5. Constant-time compare against each v1; accept if any matches. const ok = v1s.some((v1) => { const got = Buffer.from(v1); return got.length === expectedBuf.length && crypto.timingSafeEqual(got, expectedBuf); }); if (!ok) return res.status(401).send("signature mismatch");
// 6. Dedup on Idempotency-Key BEFORE acting. const idemKey = req.get("Idempotency-Key"); if (alreadyProcessed(idemKey)) return res.status(200).send("ok (duplicate)"); markProcessed(idemKey);
const payload = JSON.parse(body.toString("utf8")); // now safe to parse // ... handle payload ... res.status(200).send("ok");});
app.listen(3000);import hashlibimport hmacimport osimport time
from flask import Flask, request, abort
# The signing secret you configured for the schedule, as raw bytes.SIGNING_SECRET = os.environ["SCHEDSTACK_SIGNING_SECRET"].encode()TOLERANCE_SECONDS = 300
app = Flask(__name__)
# Stub: replace with a durable, atomic check (DB unique key, Redis SETNX, …)._seen = set()def already_processed(k): return k in _seendef mark_processed(k): _seen.add(k)
@app.post("/webhooks/sched")def sched_webhook(): header = request.headers.get("Sched-Signature") if not header: abort(400) # unsigned delivery
# 1. Parse t and every v1. t = None v1s = [] for part in header.split(","): k, _, v = part.strip().partition("=") if k == "t": t = int(v) elif k == "v1" and v: v1s.append(v) if t is None or not v1s: abort(400)
# 2. Reject stale timestamps (replay protection). if abs(int(time.time()) - t) > TOLERANCE_SECONDS: abort(400)
# 3. Rebuild the signed string. get_data() returns the RAW body bytes. body = request.get_data() delivery_id = request.headers["Sched-Delivery-Id"] attempt = request.headers["Sched-Attempt"] method = request.method.upper() path = request.path # escaped path component, no query string prefix = f"{t}.{delivery_id}.{attempt}.{method}.{path}.".encode() signed = prefix + body
# 4. HMAC-SHA256, hex. expected = hmac.new(SIGNING_SECRET, signed, hashlib.sha256).hexdigest()
# 5. Constant-time compare against each v1; accept if any matches. if not any(hmac.compare_digest(v1, expected) for v1 in v1s): abort(401)
# 6. Dedup on Idempotency-Key BEFORE acting. idem_key = request.headers["Idempotency-Key"] if already_processed(idem_key): return "ok (duplicate)", 200 mark_processed(idem_key)
payload = request.get_json(force=True) # now safe to parse # ... handle payload ... return "ok", 200package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" "os" "strconv" "strings" "time")
// The signing secret you configured for the schedule, as raw bytes.var signingSecret = []byte(os.Getenv("SCHEDSTACK_SIGNING_SECRET"))
const toleranceSeconds = 300
// Stub: replace with a durable, atomic check (DB unique key, Redis SETNX, …).func alreadyProcessed(k string) bool { return false }func markProcessed(k string) {}
func handler(w http.ResponseWriter, r *http.Request) { header := r.Header.Get("Sched-Signature") if header == "" { http.Error(w, "missing signature", http.StatusBadRequest) // unsigned delivery return }
// 1. Parse t and every v1. var ts int64 var haveTS bool var v1s []string for _, part := range strings.Split(header, ",") { k, v, found := strings.Cut(strings.TrimSpace(part), "=") if !found { continue } switch k { case "t": n, err := strconv.ParseInt(v, 10, 64) if err != nil { http.Error(w, "bad t", http.StatusBadRequest) return } ts, haveTS = n, true case "v1": if v != "" { v1s = append(v1s, v) } } } if !haveTS || len(v1s) == 0 { http.Error(w, "bad signature header", http.StatusBadRequest) return }
// 2. Reject stale timestamps (replay protection). if d := time.Now().Unix() - ts; d > toleranceSeconds || d < -toleranceSeconds { http.Error(w, "timestamp out of tolerance", http.StatusBadRequest) return }
// 3. Read the RAW body, then rebuild the signed string. body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "read error", http.StatusBadRequest) return } deliveryID := r.Header.Get("Sched-Delivery-Id") attempt := r.Header.Get("Sched-Attempt") method := strings.ToUpper(r.Method) path := r.URL.EscapedPath() // matches the signer exactly; no query string prefix := strconv.FormatInt(ts, 10) + "." + deliveryID + "." + attempt + "." + method + "." + path + "." signed := append([]byte(prefix), body...)
// 4. HMAC-SHA256, hex. mac := hmac.New(sha256.New, signingSecret) mac.Write(signed) expected := []byte(hex.EncodeToString(mac.Sum(nil)))
// 5. Constant-time compare against each v1; accept if any matches. ok := false for _, v1 := range v1s { if hmac.Equal([]byte(v1), expected) { ok = true break } } if !ok { http.Error(w, "signature mismatch", http.StatusUnauthorized) return }
// 6. Dedup on Idempotency-Key BEFORE acting. idemKey := r.Header.Get("Idempotency-Key") if alreadyProcessed(idemKey) { w.WriteHeader(http.StatusOK) return } markProcessed(idemKey)
// ... json.Unmarshal(body, &payload); handle ... w.WriteHeader(http.StatusOK)}
func main() { http.HandleFunc("/webhooks/sched", handler) http.ListenAndServe(":3000", nil)}The escaped path
Section titled “The escaped path”{path} is the URL-escaped path component of your endpoint URL — the path as it appears
in the request line, with percent-encoding preserved and no query string. It defaults to
/ when the endpoint has no path.
For ordinary ASCII paths like /webhooks/sched, the escaped path and the decoded path are
identical, so any path accessor works. If your routes contain percent-encoded or non-ASCII
characters, derive the value from the raw request target so it matches the signer
byte-for-byte:
- Go —
r.URL.EscapedPath()matches exactly. - Node —
new URL(req.originalUrl, "http://x").pathnamepreserves percent-encoding. - Python —
request.pathis decoded; for escaped paths read the raw request target from the WSGI environment and split on?. The exact key is server-specific — gunicorn exposesRAW_URI, others useREQUEST_URI; under ASGI servers it may be absent, so fall back torequest.pathfor ordinary ASCII paths (the common case).
Unsigned deliveries
Section titled “Unsigned deliveries”If a schedule has no active signing secret, the Sched-Signature header is omitted
entirely and the delivery is unsigned. (Sched-Timestamp, Sched-Delivery-Id,
Sched-Attempt, and Idempotency-Key are still sent.)
Treat a missing Sched-Signature as a failure on any endpoint you expect to be signed — the
examples above return 400 rather than silently trusting the request. Configure a signing
secret before relying on signatures in production.
Why both signature and idempotency key
Section titled “Why both signature and idempotency key”The signature proves authenticity (this request is from SchedStack and was not tampered
with). The Idempotency-Key gives you exactly-once effect on top of at-least-once
delivery: the same occurrence carries the same key across every retry and reclaim, so
deduping on it makes redelivery a no-op. You need both. A verified-but-duplicate request is
still a duplicate.
Next steps
Section titled “Next steps”- Quickstart — create your first schedule and receive a delivery.
- Introduction — the durability model and core guarantees.