# Verify webhook signatures

> Authenticate SchedStack deliveries with the Sched-Signature HMAC-SHA256 header, reject replays, and dedup on Idempotency-Key — with runnable Node, Python, and Go receivers.

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

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](#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.

## 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}` — 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.

**Read the raw body first**

You must HMAC the **exact bytes** of the request body. If your framework parses JSON and
re-serializes it, key order and whitespace change and the signature will never match. Read
the raw body **before** parsing — every example below does this.

## Verify a delivery

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.

**Order matters**

Verify the signature **first**, dedup **second**, parse and act **last**. A request that fails
signature verification should never reach your idempotency store or your business logic.

## 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.).

```js

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

```python

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 _seen
def 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", 200
```

**FastAPI / Starlette**

The logic is identical. Read the raw body with `body = await request.body()` inside an
`async def` handler, take `request.method` and `request.url.path`, and read headers from
`request.headers`. Everything else (parse, tolerance, HMAC, `hmac.compare_digest`) is the same.

```go
package main

	"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

`{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").pathname` preserves percent-encoding.
- **Python** — `request.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).

## 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

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.

**Tradeoff, stated plainly**

Delivery is at-least-once, so **you** are responsible for dedup. Our dispatch SLO covers the
time to *initiate* a delivery, not your endpoint's processing — slow or failing receivers are
retried, which is exactly when duplicates appear.

## Next steps

- [Quickstart](/docs/quickstart/) — create your first schedule and receive a delivery.
- [Introduction](/docs/) — the durability model and core guarantees.
