Outbound webhook event catalog
<!-- GENERATED FILE — do not hand-edit. --> <!-- Regenerate: `npm run docs:webhooks`. Verify: `npm run docs:webhooks:check`. -->These are the events a webhook registration can subscribe to. Every event listed here is delivered by the product; you can subscribe a registration to any of them.
Events
| Event | Description |
|---|---|
approval.request.completed | A multi-step approval request completed |
posture.changed | Posture score updated |
risk.created | New risk registered |
risk.updated | Risk status or score changed |
compliance.milestone | Control status progressed |
integration.synced | Integration sync completed |
integration.failed | Integration sync failed |
alert.triggered | Enterprise alert condition met |
evidence.propagated | Evidence linked to an additional framework line |
webhook.registration.circuit_opened | A webhook registration stopped delivering after repeated failures |
evidence.linked | Evidence was linked to a framework line or a tenant control |
evidence.unlinked | An evidence link was removed |
control.created | A tenant control — an implemented safeguard the org owns — was created |
control.linked_to_line | A tenant control was linked to a framework line |
control.unlinked_from_line | A tenant control was unlinked from a framework line |
incident.opened | A new incident was recorded |
incident.closed | An incident was closed |
policy.published | A policy version went in force (published, or approved and activated) |
vendor.created | A vendor was added to the register |
Envelope
Every delivery body is a JSON object shaped like:
{
"event": "risk.created",
"eventId": "b3f9…",
"schemaVersion": 1,
"sequence": 42,
"replay": false,
"timestamp": "2026-09-19T00:00:00.000Z",
"data": { /* event-specific payload */ }
}
schemaVersion identifies the shape of this envelope — bumped ONLY when a top-level field or a data shape changes, never for a URL version bump (that is a different axis; see docs/developers/public-api-versioning.md). It is currently 1. A consumer that has not seen a schemaVersion before this field existed should treat an absent value as 1 as well.
replay is true only when this delivery was re-sent from the durable log (see "Replay" below). A synthetic delivery produced by the "send test event" action (see "Testing an endpoint before going live" below) carries "test": true instead — present ONLY on a test delivery, so an absent key is unambiguous. Check this field, not just the destination URL, before treating a delivery as a real compliance event.
Signing
Every delivery carries an X-CMDash-Signature header of the form t=<unixSeconds>,v1=<hex>[,v1=<hex>]. The exact string that is HMAC-SHA256'd is:
${t}.${rawBody}
where t is the unix-seconds timestamp embedded in the header and rawBody is the exact bytes of the HTTP request body as received — verify against the raw body, not a re-serialized parse of it (key order / whitespace differences would break verification).
On receipt: recompute the signature over ${t}.${rawBody} using your configured secret, compare (constant-time) against each v1= value in the header, and reject if |now - t| > 300s (5 minutes) — this is what makes a captured delivery unusable if replayed later.
Do not hand-roll this. Both official SDKs ship the exact algorithm above as a tested function: verifyWebhookSignature in @cmdash/api-sdk (JavaScript) and verify_webhook_signature in cmdash_sdk (Python). Both do the timing-safe compare and the replay-window check for you, and both are tested against the same fixture the server produces. A comparison against == instead of a constant-time compare, or a verifier that forgets the timestamp window, is a security hole; use the shipped function instead of reproducing the algorithm by hand.
Ordering, sequencing and gap detection
Every delivery carries X-CMDash-Sequence and X-CMDash-Event-Id, and repeats both inside the SIGNED body as sequence and eventId. Headers are not covered by the HMAC — do your gap detection on the body copy, not the header.
Guaranteed:
sequenceis monotonically increasing and gapless per webhook registration, starting at 1.sequence > last_seen + 1means you missed something.- No overtaking. Sequence N is not attempted while any lower sequence for the same registration is still open, so a failing endpoint blocks its own queue rather than reordering around the failure.
- At-least-once with an idempotency id.
eventIdis stable across every retry AND every replay of the same logical event. Deduplicate on it. - Replays are marked with
X-CMDash-Replay: trueandreplay: truein the body.
Not guaranteed — state these to yourself before you build on them:
- No global ordering. Sequences are per registration. Two registrations subscribed to the same event have independent sequence spaces and may receive it in either order.
- Not exactly-once on the wire. A delivery that succeeded at your end but whose response was lost is retried.
- No cross-event causal ordering beyond the order events reached the bus. Racing producers get whichever order reached the sequencer first, not domain causality.
Retries follow 1m / 5m / 15m / 1h / 24h and are persisted on the delivery row, so they survive a restart. After the budget is exhausted — or immediately on a non-429 4xx — the delivery is dead-lettered and appears in GET /webhooks/:id/dead-letters for an explicit redeliver or discard. After 10 consecutive terminal failures the registration's circuit OPENS: nothing further is sent (queued events are still sequenced and dead-lettered, so the stream stays gapless), and delivery resumes only when the tenant calls POST /webhooks/:id/circuit/reset. Tenants subscribed to webhook.registration.circuit_opened on ANOTHER registration are notified when this happens.
Rotation
POST /webhooks/:id/rotate-secret generates a new secret and returns it once (plaintext, never stored). The OLD secret keeps signing alongside the new one — a second v1= value appears in the header — for an overlap window (default 24h, max 168h via ?overlapHours=). A consumer verifying against EITHER secret it has configured (old or new) keeps matching until it rolls over, instead of every delivery failing the instant rotation happens.
Testing an endpoint before going live
POST /webhooks/:id/test (dashboard: the "Send test event" button on the webhook's row) sends ONE synthetic delivery through the real signed transport — same HMAC signing, same headers, same HTTP client — so you can verify a brand-new endpoint (URL correctness, TLS, firewall rules, your signature verification code) before any real compliance event reaches it. It is marked "test": true in the SIGNED body and carries an X-CMDash-Test: true header, so your receiver can never mistake it for a real event — check the signed body copy, the header is a convenience only, same rule as X-CMDash-Replay. A test send is NOT queued, sequenced, retried or dead-lettered, and does not count toward the circuit breaker's consecutive-failure threshold — iterating on a new endpoint cannot trip open (or leave dead-lettered clutter behind on) a registration you have not gone live on yet. The response reports success, the HTTP status, latency, and (truncated) response body from a single attempt — it is not persisted to the delivery log.
Replay
POST /webhooks/:id/logs/:logId/replay (Owner or Admin role, like every other webhook administration write) re-sends a previously logged delivery as a single fresh attempt, freshly signed, against the webhook's CURRENT url. The destination is always resolved server-side from the tenant's own registration — a replay request never carries a URL — so it cannot be turned into a request to an arbitrary address. Each replay is also recorded in the audit trail, independent of the delivery log's own (shorter) retention.
POST /webhooks/:id/replay replays a WINDOW rather than a single entry: send exactly one of fromSequence, fromDate or eventIds[], plus an optional limit (default 100, hard cap 500; truncated: true in the response means run it again to continue). Replayed deliveries go out one at a time, paced, in sequence order, each carrying its ORIGINAL sequence and eventId — so they cannot flood your endpoint and your own deduplication recognises them. Only one replay runs per webhook at a time, and a replay is refused while the circuit is open. The replay horizon is the delivery log's 30-day retention window: payloads older than that are gone, and the API returns zero rows rather than pretending otherwise.
Delivery logs
Each delivery-log entry (GET /webhooks/:id/logs, and the dashboard's delivery history table) carries the receiving endpoint's own HTTP response — status code, response BODY (truncated to 4 KB — this is YOUR endpoint's output, not a CMDash artifact, so it is capped defensively rather than trusted to stay small), and the attempt's wall-clock latency in milliseconds. Before this, a failed delivery showed only the HTTP status — "it returned 400" — with no way to see why your own endpoint rejected it. All three are null for a row that never reached the network (a circuit-open dead-letter) or whose attempt threw before a response arrived (DNS failure, timeout, connection reset).
Delivery-log entries (which carry your event payloads, response bodies included) are removed after 30 days by a daily job.