Security
UCRM uses HMAC-SHA256 for webhook authentication on both legs (inbound + outbound). Same scheme as Stripe / GitHub — likely already familiar to your team.
Signature scheme
signature = hex(HMAC_SHA256(secret, "<timestamp>.<body>"))
timestamp— Unix seconds (string)body— raw request body bytes (NOT a re-serialised JSON; sign exactly what hits the wire)secret— shared secret, exchanged out-of-band
Headers UCRM sends + expects:
X-UCRM-Signature: <hex-hmac-sha256>
X-UCRM-Timestamp: <unix-seconds>
UCRM also accepts v1=<hex> prefix on the signature (Stripe-compatible).
Anti-replay
Reject events with timestamp outside [now - 5 min, now + 5 min]. UCRM enforces this on inbound; we recommend you do the same on your endpoints.
Constant-time comparison
Always compare signatures with constant-time equality (e.g. Node crypto.timingSafeEqual, Go subtle.ConstantTimeCompare). Naive == is timing-leakable.
Reference implementations
Node.js / TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, header: string, ts: string, secret: string): boolean {
// Anti-replay
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
// Compute expected
const expected = createHmac("sha256", secret)
.update(`${ts}.${rawBody}`, "utf8")
.digest("hex");
// Allow v1= prefix
const provided = header.replace(/^v1=/, "");
if (provided.length !== expected.length) return false;
try {
return timingSafeEqual(Buffer.from(provided, "hex"), Buffer.from(expected, "hex"));
} catch {
return false;
}
}
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verify(rawBody []byte, header, ts, secret string) bool {
tsNum, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return false
}
if abs(time.Now().Unix()-tsNum) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "." + string(rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
provided := strings.TrimPrefix(header, "v1=")
return hmac.Equal([]byte(provided), []byte(expected))
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
Python
import hmac
import hashlib
import time
def verify(raw_body: bytes, header: str, ts: str, secret: str) -> bool:
try:
ts_num = int(ts)
except ValueError:
return False
if abs(time.time() - ts_num) > 300:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{ts}.{raw_body.decode('utf-8')}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
provided = header.removeprefix("v1=")
return hmac.compare_digest(expected, provided)
Idempotency
Beyond signature verification, the inbound webhook receiver dedupes on (adapter_slug, event_id) via the webhook_events_processed ledger. Replays return 200 { applied: false, replay: true } immediately.
You should also dedupe on your end — UCRM might retry an outbound call (e.g. on a 504 from your side that actually succeeded). Use UCRM's grant_id (or whatever correlation id we passed) as the dedup key on your end.
Rotating secrets
Coordinate with UCRM team via Slack / email. Rotation flow:
- Generate new secret on both sides
- UCRM accepts BOTH old + new for a 24h overlap window (per-adapter env var:
UCRM_ADAPTER_<SLUG>_WEBHOOK_SECRET_NEXT) - After overlap, drop old secret
- Verify metrics (no spike in
signature mismatcherrors)
Rotation cadence: at least annually, or immediately on suspected leak.
URL allowlist
UCRM's outbound webhooks (journey call_webhook nodes) are HTTPS-only. We refuse to fire to plain HTTP URLs even in dev — encrypt journey context in transit always.
API key rotation
Project API keys can be rotated from the admin: Settings → API keys → Revoke + Create new. UCRM accepts both keys for a configurable overlap window so you can roll deploys without downtime.