Skip to content

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.

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 one v1 per active signing secret. During a secret rotation you will see two. Accept the request if any v1 verifies against the secret you hold.

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} — the t value (same integer as Sched-Timestamp).
  • {delivery_id} — the Sched-Delivery-Id value.
  • {attempt} — the Sched-Attempt value, 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.

  1. Parse t and every v1 out of the Sched-Signature header.

  2. Reject replays. If |now − t| exceeds your tolerance, reject with 400. We recommend 300 seconds.

  3. 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.

  4. Compute HMAC-SHA256(your_secret, signed_string) and hex-encode it.

  5. Constant-time compare your result against each v1. Accept if any matches. (Constant-time compare avoids leaking the secret through timing.)

  6. Dedup on Idempotency-Key before you act on the payload. Only now is it safe to JSON-parse.

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);

{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:

  • Gor.URL.EscapedPath() matches exactly.
  • Nodenew URL(req.originalUrl, "http://x").pathname preserves percent-encoding.
  • Pythonrequest.path is decoded; for escaped paths read the raw request target from the WSGI environment and split on ?. The exact key is server-specific — gunicorn exposes RAW_URI, others use REQUEST_URI; under ASGI servers it may be absent, so fall back to request.path for ordinary ASCII paths (the common case).

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.

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.

  • Quickstart — create your first schedule and receive a delivery.
  • Introduction — the durability model and core guarantees.