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)
endPHP
<?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.