Skip to Content

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.

HeaderValue
Content-Typeapplication/json, unless the destination sets its own
User-AgentCloudQuery/AlertNotification/<VERSION>
X-CloudQuery-SignaturePresent 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

FieldTypeDescription
schema_versionstringcq.notification.v1. Reject a version you do not recognize
event_idstring (UUID)This delivery. Identical on every retry — see Retries and deduplication
statusstringtriggered or resolved
cycle_atstringWhen the evaluation ran, RFC 3339 in UTC
insightobjectThe insight that fired
ruleobjectThe notification rule that routed it
resourcesarrayThe resources this notification is about
resource_countintegerHow many resources this notification covers, before any truncation
truncatedbooleanTrue when resources holds fewer entries than resource_count

insight

FieldTypeDescription
idstringInsight identifier
titlestringHuman-readable title
categorystringFor example Security
sourcestringWhere the insight came from
severitystringNormalized severity
urlstringLink to the insight in the platform
resource_countintegerTotal 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

FieldTypeDescription
idstring (UUID)Rule identifier
namestringRule name as configured

resources

FieldTypeDescription
idstringResource identifier, an ARN or provider ID
namestringResource name, falling back to id
typestringFor example aws_s3_bucket
accountstringCloud account
regionstringCloud region
cloudstringCloud provider
tagsobjectTag keys to values. {} when untagged
appsarrayObjects with id and name. [] when none
environmentsarrayEnvironment names. [] when none
ownerstring or nullNull 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
FieldMeaning
tUnix timestamp, in seconds, of this attempt
v1Lowercase 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 4xx responses 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:

  1. 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.
  2. Replace the secret on the destination.
  3. 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

Was this page helpful?

Last updated on