---
title: Webhook Signatures
path: advanced/webhook-signatures
status: published
---

# Webhook Signatures

Every outbound webhook from ScaiVault is signed with HMAC-SHA256. Your endpoint **must** verify the signature before trusting the payload. Without verification, anyone who knows your URL can forge events.

## Headers on delivery

```
X-ScaiVault-Event-Id: evt_01HK7X9Z
X-ScaiVault-Event-Type: secret.rotated
X-ScaiVault-Timestamp: 1714478400
X-ScaiVault-Signature: sha256=3a1e2b0c...
User-Agent: ScaiVault-Webhook/1.0
```

- `Timestamp` — Unix seconds at dispatch.
- `Signature` — `sha256=` followed by hex HMAC-SHA256.

## What gets signed

`HMAC(secret, "{timestamp}.{raw_body}")` — the timestamp, a literal `.`, and the raw request body bytes. No JSON reparsing, no header normalization.

## Verify in Python

```python
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
WEBHOOK_SECRET = os.environ["SCAIVAULT_WEBHOOK_SECRET"].encode()
MAX_AGE_SECONDS = 300

@app.post("/scaivault/webhook")
async def on_event(request: Request):
    timestamp = request.headers.get("X-ScaiVault-Timestamp", "")
    signature_header = request.headers.get("X-ScaiVault-Signature", "")
    if not timestamp or not signature_header.startswith("sha256="):
        raise HTTPException(400, "missing signature")

    # Reject replays
    age = int(time.time()) - int(timestamp)
    if abs(age) > MAX_AGE_SECONDS:
        raise HTTPException(401, "stale signature")

    body = await request.body()
    expected = hmac.new(
        WEBHOOK_SECRET,
        f"{timestamp}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()

    received = signature_header.removeprefix("sha256=")
    if not hmac.compare_digest(expected, received):
        raise HTTPException(401, "bad signature")

    event = await request.json()
    # Now it's safe to trust event["event_type"], event["path"], etc.
    return {"ok": True}
```

## Verify in TypeScript / Node

```typescript
import crypto from "node:crypto";
import { Request, Response } from "express";

const WEBHOOK_SECRET = process.env.SCAIVAULT_WEBHOOK_SECRET!;
const MAX_AGE_SECONDS = 300;

export function verify(req: Request): boolean {
  const timestamp = req.header("X-ScaiVault-Timestamp");
  const sigHeader = req.header("X-ScaiVault-Signature");
  if (!timestamp || !sigHeader?.startsWith("sha256=")) return false;

  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
  if (Math.abs(age) > MAX_AGE_SECONDS) return false;

  const rawBody = (req as any).rawBody as Buffer;  // express.raw({type:'*/*'})
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");

  const received = sigHeader.slice("sha256=".length);
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(received, "hex"),
    );
  } catch {
    return false;
  }
}
```

Two important details for JavaScript:

- Use the **raw request body**, not a re-serialized JSON object. Body parsing rearranges whitespace and breaks the signature.
- Use `timingSafeEqual` to prevent timing attacks.

## Verify in curl (for debugging)

```bash
BODY='{"event_type":"test",...}'
TIMESTAMP=1714478400
EXPECTED=$(printf "%s.%s" "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
echo "Expected sha256=$EXPECTED"
```

Compare against what arrived in `X-ScaiVault-Signature`.

## Replay protection

The `X-ScaiVault-Timestamp` header plus a max-age check (5 minutes is standard) prevents replay of captured requests. A captured request is only valid for 5 minutes after the dispatch time.

For stronger replay protection, persist `X-ScaiVault-Event-Id` in a short-TTL store and reject duplicates:

```python
if redis.exists(f"seen:{event_id}"):
    return {"ok": True, "duplicate": True}
redis.set(f"seen:{event_id}", "1", ex=600)
```

## Secret rotation

Each webhook has a `secret` configured at registration. To rotate:

1. Generate a new secret.
2. PATCH the webhook with the new secret *and* add the old one as `previous_secret` (kept for 24h):

   ```bash
   curl -X PATCH https://scaivault.scailabs.ai/v1/webhooks/wh_abc \
     -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"secret": "new-secret-here", "previous_secret_ttl": "24h"}'
   ```

3. During the overlap, ScaiVault signs with the new secret; your endpoint accepts either the new or old signature.
4. After the TTL passes, only the new secret is valid.

Your verification code for the overlap:

```python
for candidate in [WEBHOOK_SECRET, OLD_WEBHOOK_SECRET]:
    expected = hmac.new(candidate, signing_input, hashlib.sha256).hexdigest()
    if hmac.compare_digest(expected, received):
        break
else:
    raise HTTPException(401, "bad signature")
```

## Common mistakes

- **Parsing JSON before verifying.** Many frameworks re-serialize the body after parsing. You must sign and verify the *raw* bytes.
- **No timestamp check.** Without one, a captured webhook is replayable forever.
- **String comparison for signatures.** Use a constant-time compare (`hmac.compare_digest` in Python, `timingSafeEqual` in Node).
- **Leaking signature details.** Don't respond with "bad signature expected X got Y" — that helps an attacker. A plain `401` is fine.

## What's next

- [Events and Webhooks](../core-concepts/events-and-webhooks) — event catalog and delivery semantics.
- [Webhooks Reference](../reference/webhooks) — management endpoints.
