Guide
Webhooks
A webhook sends each alert and event in your workspace to your HTTPS endpoint as it happens, signed so you can prove it came from Cloudraw.
Set one up
- Create it in the console (Settings → Integrations → Webhooks) or with
POST …/webhooks: anhttps://URL and the event categories you want. - Store the signing secret from the answer. It is shown once;
POST …/webhooks/{id}/rotate-secretissues a new one (the old one stops signing at once). - Send a test delivery with
POST …/webhooks/{id}/testand check that your endpoint verifies it.
Event categories
| Category | What it carries |
|---|---|
security | Security alerts: blocked access, quarantines, suspicious activity, policy enforcement. |
connector | Connector health: offline, back online, update results. |
device | Device trust and health changes. |
access | Access and identity events, including just-in-time requests and grants (subscribe to jit for only those). |
jit | Just-in-time access events only (they arrive with category access). |
all | Everything, including system, file, app-firewall and support events. |
The categories audit and billing are not available yet: a webhook that subscribes to them is refused with 400 category-not-available-yet. Use Log streaming for the audit log. Webhooks created earlier with those categories keep receiving their other categories.
The delivery
POST /hooks/cloudraw HTTP/1.1
Content-Type: application/json
User-Agent: cloudraw-notify/1
X-Cloudraw-Timestamp: 1791883451
X-Cloudraw-Signature-V1: sha256=5d1c0b2f…
X-Cloudraw-Signature: sha256=9a4e77c1…
{
"id": "evt_k2x9m0s8d4",
"tenant": "tq7x2m9k4ab",
"category": "security",
"at": "2026-10-13T09:24:11.000Z",
"event": {
"action": "device.quarantine",
"severity": "warn",
"message": "DANA-LAPTOP was quarantined: disk encryption turned off.",
"data": { "device": "dvc_5mxq2b7r8k" }
}
}- Answer with any 2xx within 8 seconds (15 seconds at most in total). Anything else, or a timeout, is retried after 5 s, 30 s, 2 min, 10 min and 30 min; after the sixth failed attempt the event is dropped. The console (and
GET …/webhooks,last_delivery) shows the last delivery result. - Deliveries to one webhook are sent in order, one at a time. Retries carry the same
id: store the ids you processed and ignore repeats. - The URL must resolve to a public address; private and loopback addresses are refused.
- Do slow work after answering (queue it).
Verify the signature
Use X-Cloudraw-Signature-V1: sha256= followed by the hex HMAC-SHA256 of <X-Cloudraw-Timestamp>.<raw body>, keyed with your signing secret. Reject the delivery when the signature does not match or the timestamp is more than 300 seconds from your clock (replay protection). Always compute over the raw bytes you received, before any JSON parsing, and compare in constant time.
The older X-Cloudraw-Signature (HMAC of the body alone, no timestamp) is still sent for existing integrations; new code should use V1.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.CLOUDRAW_WEBHOOK_SECRET;
const app = express();
app.post("/hooks/cloudraw", express.raw({ type: "application/json" }), (req, res) => {
const ts = req.get("X-Cloudraw-Timestamp") || "";
const sig = req.get("X-Cloudraw-Signature-V1") || "";
const expected = "sha256=" + createHmac("sha256", SECRET).update(`${ts}.`).update(req.body).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const ok = sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!fresh || !ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
res.status(204).end(); // answer first, then process (dedupe on event.id)
queue.push(event);
});import hmac, hashlib, os, time
from flask import Flask, request, abort
SECRET = os.environ["CLOUDRAW_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/hooks/cloudraw")
def cloudraw():
ts = request.headers.get("X-Cloudraw-Timestamp", "")
sig = request.headers.get("X-Cloudraw-Signature-V1", "")
raw = request.get_data() # raw bytes, before parsing
expected = "sha256=" + hmac.new(SECRET, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not ts.isdigit() or abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(sig, expected):
abort(401)
event = request.get_json()
enqueue(event) # dedupe on event["id"]
return "", 204# Verify a delivery you saved (headers + raw body), e.g. in an Azure Function.
function Test-CloudrawSignature([string]$Secret, [string]$Timestamp, [string]$SignatureV1, [byte[]]$RawBody) {
$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
if ([math]::Abs($now - [long]$Timestamp) -gt 300) { return $false }
$hmac = [System.Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($Secret))
$msg = [Text.Encoding]::UTF8.GetBytes("$Timestamp.") + $RawBody
$expected = 'sha256=' + (($hmac.ComputeHash($msg) | ForEach-Object { $_.ToString('x2') }) -join '')
# constant-time compare
$a = [Text.Encoding]::ASCII.GetBytes($expected); $b = [Text.Encoding]::ASCII.GetBytes($SignatureV1)
if ($a.Length -ne $b.Length) { return $false }
$diff = 0; for ($i = 0; $i -lt $a.Length; $i++) { $diff = $diff -bor ($a[$i] -bxor $b[$i]) }
return $diff -eq 0
}# body.json = the raw body exactly as received; TS = the X-Cloudraw-Timestamp header
printf '%s.' "$TS" | cat - body.json \
| openssl dgst -sha256 -hmac "$CLOUDRAW_WEBHOOK_SECRET" -hex \
| sed 's/^.*= /sha256=/'
# compare with the X-Cloudraw-Signature-V1 headerSecurity checklist
- Use an endpoint that is only reachable over HTTPS with a valid certificate.
- Verify every delivery (signature and timestamp) before acting on it.
- Keep the signing secret in a secret store; rotate it if it may have leaked.
- Treat the payload as data: alerts can quote names that people chose (device names, app names).