Webhook receivers
The contract for anything consuming CloudQuery Platform notifications: an automation, an agent, or an incident tool.
The request
Each delivery is an HTTP POST to the destination’s URL.
| Header | Value |
|---|---|
Content-Type | application/json, unless the destination sets its own |
User-Agent | CloudQuery/AlertNotification/<VERSION> |
X-CloudQuery-Signature | Present when the destination has a signing secret — see Verify the signature |
Any custom headers on the destination, including secret headers, are sent as configured.
Your endpoint must be reachable from the public internet. Loopback, link-local, private, and cloud metadata addresses are refused, at save time and again when the connection is made.
The payload
A webhook receives the cq.notification.v1 envelope unless the rule’s body has been edited. An edited body sends whatever the template renders, so agree with whoever configures the rule before you rely on the schema below.
{
"schema_version": "cq.notification.v1",
"event_id": "1e0f7c4a-3b2d-4f5e-8a9b-0c1d2e3f4a5b",
"status": "triggered",
"cycle_at": "2026-08-23T10:00:00Z",
"insight": {
"id": "0f1e2d3c4b5a69788796a5b4c3d2e1f0",
"title": "S3 bucket publicly accessible",
"category": "Security",
"source": "aws",
"severity": "high",
"url": "https://app.cloudquery.io/insights/0f1e2d3c4b5a69788796a5b4c3d2e1f0",
"resource_count": 42
},
"rule": {
"id": "2f1a8d5b-4c3e-4a6f-9b0c-1d2e3f4a5b6c",
"name": "prod buckets"
},
"resources": [
{
"id": "arn:aws:s3:::example-prod-assets",
"name": "example-prod-assets",
"type": "aws_s3_bucket",
"account": "123456789012",
"region": "eu-west-1",
"cloud": "aws",
"tags": { "env": "prod", "team": "payments" },
"apps": [{ "id": "3a2b9e6c-5d4f-4b7a-8c1d-2e3f4a5b6c7d", "name": "checkout" }],
"environments": ["prod"],
"owner": "team-payments"
}
],
"resource_count": 1,
"truncated": false
}Top-level fields
| Field | Type | Description |
|---|---|---|
schema_version | string | cq.notification.v1. Reject a version you do not recognize |
event_id | string (UUID) | This delivery. Identical on every retry — see Retries and deduplication |
status | string | triggered or resolved |
cycle_at | string | When the evaluation ran, RFC 3339 in UTC |
insight | object | The insight that fired |
rule | object | The notification rule that routed it |
resources | array | The resources this notification is about |
resource_count | integer | How many resources this notification covers, before any truncation |
truncated | boolean | True when resources holds fewer entries than resource_count |
insight
| Field | Type | Description |
|---|---|---|
id | string | Insight identifier |
title | string | Human-readable title |
category | string | For example Security |
source | string | Where the insight came from |
severity | string | Normalized severity |
url | string | Link to the insight in the platform |
resource_count | integer | Total live violations for the insight |
resource_count appears twice and means two things. At the top level it is this notification’s batch. Under insight it is every resource still violating, including those an earlier notification already covered.
rule
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Rule identifier |
name | string | Rule name as configured |
resources
| Field | Type | Description |
|---|---|---|
id | string | Resource identifier, an ARN or provider ID |
name | string | Resource name, falling back to id |
type | string | For example aws_s3_bucket |
account | string | Cloud account |
region | string | Cloud region |
cloud | string | Cloud provider |
tags | object | Tag keys to values. {} when untagged |
apps | array | Objects with id and name. [] when none |
environments | array | Environment names. [] when none |
owner | string or null | Null when the resource has no ownership row |
A notification carries at most 50 resources, and fewer when the resources are large enough to exceed the payload’s size budget. Read truncated rather than comparing lengths against a fixed cap.
On status: resolved, resources holds the resources that cleared, under the same caps.
Treat the schema as additive: ignore fields you do not recognize rather than failing on them.
Verify the signature
Set a signing secret on the destination — Signing secret under Webhook signing — and every delivery to that destination carries:
X-CloudQuery-Signature: t=1755940800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bf| Field | Meaning |
|---|---|
t | Unix timestamp, in seconds, of this attempt |
v1 | Lowercase hex HMAC-SHA256 over t + . + the raw request body, keyed with your secret |
Sign the raw bytes you received. Parsing the JSON and re-serializing it changes whitespace and key order, and the signature will not match.
Each attempt is signed at the moment it is sent, so a retry carries a newer t and a different v1 over the same body.
Python
import hashlib
import hmac
import time
MAX_AGE_SECONDS = 300
def verify(signature_header: str, body: bytes, secrets: list[str]) -> bool:
"""Return True when body carries a valid signature from any of secrets."""
fields = {}
for part in signature_header.split(","):
key, _, value = part.partition("=")
fields[key.strip()] = value.strip()
timestamp, received = fields.get("t"), fields.get("v1")
if not timestamp or not received:
return False
try:
sent_at = int(timestamp)
except ValueError:
return False
if abs(time.time() - sent_at) > MAX_AGE_SECONDS:
return False
signed = timestamp.encode() + b"." + body
return any(
hmac.compare_digest(
received, hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
)
for secret in secrets
)Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_AGE_SECONDS = 300;
export function verify(signatureHeader, body, secrets) {
const fields = {};
for (const part of signatureHeader.split(',')) {
const i = part.indexOf('=');
if (i > 0) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim();
}
const { t, v1 } = fields;
if (!t || !v1) return false;
const sentAt = Number(t);
if (!Number.isFinite(sentAt)) return false;
if (Math.abs(Date.now() / 1000 - sentAt) > MAX_AGE_SECONDS) return false;
const received = Buffer.from(v1, 'hex');
return secrets.some((secret) => {
const expected = createHmac('sha256', secret).update(`${t}.`).update(body).digest();
return expected.length === received.length && timingSafeEqual(expected, received);
});
}body is a Buffer of the raw request bytes. In Express, use express.raw({ type: 'application/json' }) rather than express.json().
Test against a real signature
Send test notification on the destination signs its request with the stored secret, so you can check your verification code before wiring up a rule. The test body is the destination’s own web request body, not the cq.notification.v1 envelope — it exercises the signature, not the schema.
Reject replays
Nothing on the CloudQuery side rejects an old timestamp — that check belongs to you. Reject a delivery whose t is more than five minutes from your own clock, then deduplicate on event_id so a captured request cannot be replayed inside that window.
Retries and deduplication
Delivery is at-least-once. Assume you will see the same notification twice and make your handler idempotent.
- A delivery is attempted up to 4 times, with roughly 1, 5, and 25 minutes between attempts.
429,5xx, connection failures, and timeouts are retried.- Other
4xxresponses are terminal. A rejected payload or a wrong URL fails immediately rather than four times over half an hour. - Each attempt allows 15 seconds for a response.
event_id is stable across every attempt of one delivery. Use it as your idempotency key.
It identifies one (rule, destination) pair, so a rule fanning out to two webhooks produces two different event_id values for the same underlying event. To correlate across destinations, group on rule.id and cycle_at.
Return 2xx as soon as you have the payload and do your work asynchronously. A slow handler burns the 15-second budget and turns a successful notification into a retry.
Deliveries are rate limited per organization, so a cycle that produces many notifications arrives spread over time rather than at once.
Rotate the signing secret
The secret is stored encrypted and never returned. Replacing it is a plain write — there is no dual-signing period on the CloudQuery side.
For a rotation with no rejected deliveries:
- Add the new secret to your receiver alongside the old one, and accept a signature matching either. Both verification examples above take a list for this.
- Replace the secret on the destination.
- Remove the old secret from your receiver.
Without step 1, a delivery in flight during the swap fails signature verification. Retries cover a swap completed within about half an hour, since each attempt is signed with whatever secret is stored at that moment. Anything slower shows up as failed deliveries.
Security
A notification payload carries resource tags, owners, environments, and app names to whatever endpoint the rule names. Anyone who can configure a rule can route that data anywhere reachable.
- Keep secrets out of tags. Tag values are copied into notification payloads verbatim.
- Always set a signing secret on an endpoint that triggers automated action. Without one, a webhook body is an unauthenticated POST anyone who learns the URL can forge.
- Verify before you act, not after. Check the signature before parsing the payload into anything that drives a workflow.
- The destination URL is never templated, so a payload cannot redirect a delivery elsewhere.
Next steps
- Message content — send a custom body instead of the envelope
- Notification destinations — configure the endpoint and its signing secret
Last updated on