Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

scdoc
1
2
3
4
5
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.
  • Signaturesha256= 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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
1
2
3
4
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
1
2
3
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
    1
    2
    3
    4
    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
1
2
3
4
5
6
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#

Updated 2026-05-17 13:26:49 View source (.md) rev 2