Webhooks
CloudArq pushes signed JSON events to your endpoint when scans complete and (soon) when other interesting things happen. This page covers the payload shape, signature verification, retry policy, and the full event catalog.
Payload shape
Every event is a single JSON object delivered as the request body of an HTTPS POST. The shape per event_type is listed under Event types. An audit.completed event looks like:
{
"event_type": "audit.completed",
"audit_id": "9b1c8a3e-4d2f-4f8b-9c1a-7d3e5f8b2a4c",
"connection_id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"score": 78,
"total_findings": 42,
"by_severity": { "critical": 1, "high": 4, "medium": 17, "low": 20 },
"total_monthly_waste": 312.50,
"occurred_at": "2026-05-01T14:23:45Z"
}Request headers
Every dispatch includes these headers. Receivers should index events by X-CloudArq-Delivery for idempotency — retries reuse the same UUID, so a delivery your handler has already processed should be a no-op.
application/json.audit.completed. Use this to dispatch in your handler without parsing the body. |t − now| > 300 seconds). Only sent when a signing secret is configured.t=<ts>,v1=<hex> where hex is the HMAC-SHA256 of "<ts>.<raw-body>" keyed with your signing secret. Only sent when a signing secret is configured. Compare in constant time.Signature verification
Verify every request before trusting the body. Skipping this lets anyone with your URL forge events. Recompute the HMAC over the raw request bytes — re-serialising JSON breaks the comparison because key ordering and whitespace must match exactly.
Python (FastAPI)
import hmac, hashlib, time
from fastapi import Request, HTTPException
WEBHOOK_SECRET = "your-32-char-secret-from-cloudarq"
TOLERANCE_SECONDS = 300 # 5 minutes
async def verify(request: Request) -> dict:
sig_header = request.headers["X-CloudArq-Signature"]
body = await request.body()
# Parse "t=<ts>,v1=<hex>"
parts = dict(p.split("=", 1) for p in sig_header.split(","))
ts = int(parts["t"])
sig = parts["v1"]
# Reject stale or future-dated requests (replay protection).
if abs(time.time() - ts) > TOLERANCE_SECONDS:
raise HTTPException(401, "stale signature")
expected = hmac.new(
WEBHOOK_SECRET.encode(),
f"{ts}.".encode() + body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "bad signature")
return await request.json()Node (Express)
import crypto from "node:crypto";
import express from "express";
const WEBHOOK_SECRET = "your-32-char-secret-from-cloudarq";
const TOLERANCE_SECONDS = 300; // 5 minutes
// IMPORTANT: use express.raw() so req.body is the exact bytes the
// HMAC was computed over. express.json() re-serialises and breaks
// signature verification.
app.post(
"/webhooks/cloudarq",
express.raw({ type: "application/json" }),
(req, res) => {
const sigHeader = req.header("X-CloudArq-Signature") ?? "";
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=", 2)),
);
const ts = parseInt(parts.t, 10);
const sig = parts.v1;
if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) {
return res.status(401).send("stale signature");
}
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(`${ts}.`)
.update(req.body)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(sig, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event...
res.status(204).end();
},
);±300s window, an attacker can capture a valid request and replay it weeks later. The snippets above enforce this.Retry policy
A non-2xx response (or a connection error / timeout) queues the delivery for retry. Each attempt reuses the original X-CloudArq-Delivery UUID so your handler can de- dup. After five total attempts the row dead-letters, an in-app notification fires for your tenant's owners, and the delivery appears in your integration card with a "Retry now" button you can click to try again manually.
| Attempt | Wait after previous | Total elapsed |
|---|---|---|
| 1 | immediate | 0 |
| 2 | + 1 minute | 1m |
| 3 | + 5 minutes | 6m |
| 4 | + 30 minutes | 36m |
| 5 | + 2 hours | 2h 36m |
| → | After attempt 5: dead-letter, in-app notification, delivery shown in UI for manual retry. | |
A receiver returning 2xx at any point ends the retry chain — partial success is success. 4xx responses are still retried; if the URL is misconfigured the schedule is the natural backoff while you fix it.
Event types
Set event_type on your handler's dispatch table to one of these strings. Planned events are listed so you can reserve handler slots ahead of release.
audit.completedAvailableA scheduled or on-demand scan finished and produced a scored audit. Carries score, total_findings, by_severity, total_monthly_waste, and occurred_at.
finding.snoozedPlannedA user marked one or more findings as snoozed. Planned for the next sprint — receiver schemas may evolve.
connection.failedPlannedAn AWS connection failed credential validation during the periodic health check. Planned.