Skip to main content

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:

JSON
{
  "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.

Content-Type
Always application/json.
X-CloudArq-Event
The event type, e.g. audit.completed. Use this to dispatch in your handler without parsing the body.
X-CloudArq-Delivery
Per-delivery UUID. Same value on every retry of one logical event — perfect for an idempotency key. Store this on your side and reject duplicates.
X-CloudArq-Timestamp
Unix epoch seconds at sign time. Used together with the signature header for replay protection (reject if |t − now| > 300 seconds). Only sent when a signing secret is configured.
X-CloudArq-Signature
Format: 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)

Python
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)

JavaScript
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();
  },
);
Reject stale timestamps. Without the ±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.

AttemptWait after previousTotal elapsed
1immediate0
2+ 1 minute1m
3+ 5 minutes6m
4+ 30 minutes36m
5+ 2 hours2h 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.completedAvailable

A scheduled or on-demand scan finished and produced a scored audit. Carries score, total_findings, by_severity, total_monthly_waste, and occurred_at.

finding.snoozedPlanned

A user marked one or more findings as snoozed. Planned for the next sprint — receiver schemas may evolve.

connection.failedPlanned

An AWS connection failed credential validation during the periodic health check. Planned.