SICenter Logo
FeaturesMCPPricingDocsBlog
Log inGet started

Docs · API · Webhooks

Verify SICenter webhook signatures.

Every outbound webhook from SICenter is signed with HMAC-SHA256 using the per-integration secret you configure when you connect a webhook destination. Verify the signature before trusting the payload.

Header format

Each request carries an X-SICenter-Signature header following the Stripe-style scheme:

X-SICenter-Signature: t=1714508400,v1=8b5e4fa9c4d2b7e1a3...
  • t — Unix timestamp the signature was generated. Reject anything older than 5 minutes to prevent replay attacks.
  • v1 — Hex-encoded HMAC-SHA256 of the string <t>.<raw_request_body>.

Verify against the raw request body. Frameworks that auto-parse JSON often re-serialise it before handing it to your handler — that re-serialised form will not match the bytes we signed. Capture the raw bytes (e.g. Express express.raw({type:'application/json'}), FastAPI await request.body()) before computing the HMAC.

Node.js

// Node.js (built-in crypto, no deps)
import { createHmac, timingSafeEqual } from "node:crypto"

export function verifySicenterSignature({
  payload,         // raw request body STRING (not parsed JSON)
  signatureHeader, // value of X-SICenter-Signature
  secret,          // your webhook signing secret
  toleranceSec = 300, // 5 minutes
}) {
  // Header format:  t=1714508400,v1=<hex>
  const parts = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=")),
  )
  const t = Number(parts.t)
  const v1 = parts.v1
  if (!t || !v1) return false
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false

  const expected = createHmac("sha256", secret)
    .update(`${t}.${payload}`)
    .digest("hex")

  // Constant-time compare to prevent timing oracles.
  const a = Buffer.from(v1, "hex")
  const b = Buffer.from(expected, "hex")
  return a.length === b.length && timingSafeEqual(a, b)
}

Python

# Python 3 (hashlib + hmac, stdlib only)
import hashlib, hmac, time

def verify_sicenter_signature(payload: bytes, signature_header: str,
                              secret: str, tolerance_sec: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    v1 = parts.get("v1")
    if not v1 or abs(time.time() - t) > tolerance_sec:
        return False

    msg = f"{t}.".encode() + payload
    expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(v1, expected)

Go

// Go 1.21+
package webhook

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "strings"
    "time"
)

// VerifySignature returns nil iff the X-SICenter-Signature header
// matches the computed HMAC over the raw payload bytes.
func VerifySignature(payload []byte, header, secret string, tolerance time.Duration) error {
    parts := map[string]string{}
    for _, kv := range strings.Split(header, ",") {
        bits := strings.SplitN(kv, "=", 2)
        if len(bits) == 2 {
            parts[bits[0]] = bits[1]
        }
    }

    var t int64
    if _, err := fmt.Sscanf(parts["t"], "%d", &t); err != nil {
        return fmt.Errorf("invalid t: %w", err)
    }
    if delta := time.Now().Unix() - t; delta < -int64(tolerance.Seconds()) || delta > int64(tolerance.Seconds()) {
        return fmt.Errorf("timestamp out of tolerance")
    }

    mac := hmac.New(sha256.New, []byte(secret))
    fmt.Fprintf(mac, "%d.%s", t, payload)
    expected := hex.EncodeToString(mac.Sum(nil))
    if !hmac.Equal([]byte(expected), []byte(parts["v1"])) {
        return fmt.Errorf("signature mismatch")
    }
    return nil
}

Ruby

# Ruby (stdlib only)
require "openssl"
require "rack" # for parameter parsing of the header

def verify_sicenter_signature(payload, signature_header, secret, tolerance: 300)
  parts = signature_header.split(",").map { |p| p.split("=", 2) }.to_h
  t = parts["t"]&.to_i
  v1 = parts["v1"]
  return false unless t && v1
  return false if (Time.now.to_i - t).abs > tolerance

  expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{payload}")
  Rack::Utils.secure_compare(expected, v1)
end

PHP

<?php
// PHP 7.4+
function verifySicenterSignature(
    string $payload,
    string $signatureHeader,
    string $secret,
    int $toleranceSec = 300
): bool {
    $parts = [];
    foreach (explode(",", $signatureHeader) as $kv) {
        [$k, $v] = array_pad(explode("=", $kv, 2), 2, null);
        $parts[$k] = $v;
    }
    $t = (int)($parts["t"] ?? 0);
    $v1 = $parts["v1"] ?? "";
    if ($t === 0 || $v1 === "") return false;
    if (abs(time() - $t) > $toleranceSec) return false;

    $expected = hash_hmac("sha256", $t . "." . $payload, $secret);
    return hash_equals($expected, $v1);
}

Common failure modes

  • Comparing strings with ==. Always use a constant-time comparator (timingSafeEqual, hmac.compare_digest, hmac.Equal, hash_equals). String equality leaks the prefix of the expected signature byte-by-byte through timing.
  • Missing replay window. An attacker who captures one valid request can replay it forever if you only check the HMAC. Always reject |now - t| > 300.
  • Wrong body bytes. See the warning above — the signature is over the raw HTTP body, not the parsed JSON.
  • Secret in the wrong place. The secret lives in your webhook integration settings on the SICenter dashboard. Never paste it into client-side code or public repos.

Need to rotate a webhook secret? Open /dashboard/integrations, reconnect the webhook, and copy the new secret. Old signatures stop validating immediately on rotation.

SICenter Logo

Building a safer cyberspace. Discover, monitor, and secure your external attack surface — all from one console.

Product

  • Features
  • Pricing
  • Docs
  • Blog

Company

  • About
  • Security
  • Contact

Resources

  • security@sicenter.io
  • Status
  • Changelog

© 2026 SICenter — Strategic Intelligence Center

Status