Webhooks
Set up webhook destinations to receive cside security alerts via HTTP POST requests with HMAC signature verification.
How webhooks work
Webhooks let you receive cside notifications as HTTP POST requests to a URL you specify. When a trigger fires, cside sends a JSON payload to your webhook endpoint containing details about the event.
You can configure webhooks in three formats:
- JSON - standard JSON payload for custom integrations
- Slack - pre-formatted for Slack channels
- Discord - pre-formatted for Discord channels
To create Jira work items from webhooks on any plan, see Jira Automation.
Adding a webhook destination
To add a webhook as a destination in a notification config:
- Open the dashboard and navigate to Team Settings > Notifications
- Create a new notification config or edit an existing one
- Under Send To, click Add destination and select Webhook
- In the Configuration panel, fill in:
- URL: the URL where you want to receive webhook notifications (e.g.,
https://example.com/cside/notify) - Secret: a secret key used to sign webhook requests (used for HMAC verification)
- Format: choose JSON, Slack, or Discord
- URL: the URL where you want to receive webhook notifications (e.g.,
- Click Save or Save & Test
cside signs each request with the x-cside-signature header but cannot add custom headers. If your receiver authenticates with its own header, check whether it also accepts the token in the URL, as Jira Automation does.
The webhook payload
cside sends one HTTP POST per event, with Content-Type: application/json and an
x-cside-signature header (see HMAC verification). Each
payload carries a top-level event field that identifies the event type, a
timestamp, the team or domain the event belongs to, and an event-specific data
object.
The field that holds the timestamp differs per event: alert.created and the
digest events use timestamp, while security_headers.changed uses sent_at.
Read event first, then parse the rest of the payload according to the tables
below. Treat unknown fields as optional so that new fields do not break your
integration.
Every JSON payload also carries two human-readable fields, summary (a one-line
title) and description (multi-line details), both at the top level and
repeated inside data. Use them to fill in a ticket or chat message without
building your own text per event. They are repeated inside data because
Jira Automation treats a
top-level data key specially.
Event IDs
| ID | Sent by | Timestamp field | data object |
|---|---|---|---|
alert.created | Immediate triggers, and the Vulnerable Script Detected trigger on its immediate schedule | timestamp | Alert object |
security_headers.changed | Security Headers Changed | sent_at | Security headers change |
pci_report.available | PCI Report (daily or weekly) | timestamp | PCI report |
csp_block_digest | Script Blocked by CSP (daily or weekly) | timestamp | CSP block digest |
csp_policy_digest | CSP Policy Digest (daily or weekly) | timestamp | CSP policy digest |
vulnerable_script_digest | Vulnerable Script Detected (daily or weekly) | timestamp | Vulnerability digest |
outbound_connections_pending_review | Outbound Connections Pending Review (daily or weekly) | timestamp | Pending outbound connections |
Only alert.created, security_headers.changed and pci_report.available
honor the Slack and Discord formats. The other digest events are always
sent as JSON, whatever format the destination is configured with.
Save & Test sends a sample alert.created payload with the trigger_type
of the rule’s trigger, including for triggers that normally deliver a digest.
Its summary starts with [Test]. Security Headers Changed sends a sample
security_headers.changed payload instead. If your endpoint answers with an
error, the test result shows the HTTP status code it returned.
All timestamps are ISO 8601 strings in UTC. IDs are decimal strings.
alert.created
Sent for every alert that an immediate trigger raises: Script Threat Detected, High-risk Vulnerability, Managed Flagged, Malicious Tag Manager, Malicious Outbound Connection, and Vulnerable Script Detected when the rule is set to deliver immediately.
| Field | Type | Description |
|---|---|---|
event | "alert.created" | The event ID |
timestamp | string | When the webhook was sent |
domain_id | string | ID of the domain the alert fired on |
domain | string | Hostname of the domain the alert fired on |
trigger_type | string | The trigger that produced the alert, see the table below |
summary | string | One-line title, for example [cside] Malicious Script: {ruleset} on {domain}. The same text the Jira integration uses as the issue summary |
description | string | Plain-text explanation of the alert |
data | object | The alert object |
destination_id | string | ID of the webhook destination that received the event |
Values of trigger_type:
trigger_type | Trigger |
|---|---|
script_threat_detected_by_rules_engine | Script Threat Detected |
high_risk_vulnerability_detected | High-risk Vulnerability |
vulnerable_script_detected | Vulnerable Script Detected (immediate schedule) |
cside_managed_list_flagged | Managed Flagged |
malicious_tag_manager | Malicious Tag Manager |
malicious_outbound_connection | Malicious Outbound Connection |
Alert object
| Field | Type | Description |
|---|---|---|
summary | string | Same as the top-level summary |
description | string | Same as the top-level description |
domain | string | Same as the top-level domain |
trigger_type | string | Same as the top-level trigger_type |
target_type | "URL" | "HASH" | "IP" | "DOMAIN" | "HOSTNAME" | "VENDOR" | What target holds |
target | string | The subject of the alert: a script URL, a script hash, or the flagged IP, domain, or hostname |
action | "alert" | The action recorded for the alert. Currently always "alert"; treat it as a string to stay compatible with future actions |
ruleset_id | string | ID of the ruleset that matched |
ruleset_name | string | Name of the ruleset that matched |
ruleset_description | string | Description of the ruleset that matched |
script_urls | string[] | The script URLs that triggered the alert; can be empty |
vuln_cve_id | string | null | Vulnerability ID (CVE or GHSA); null for non-vulnerability alerts |
vuln_package | string | null | Affected package; null for non-vulnerability alerts |
vuln_severity | string | null | Reported severity such as HIGH; null for non-vulnerability alerts |
vuln_reference_url | string | null | Link to the vulnerability on osv.dev; null when there is no vuln_cve_id |
The four vuln_* fields are only populated for vulnerable_script_detected and
high_risk_vulnerability_detected. For every other trigger_type they are
null.
{
"event": "alert.created",
"timestamp": "2026-09-17T10:15:30.123456Z",
"domain_id": "1897425263682514945",
"domain": "checkout.example.com",
"trigger_type": "high_risk_vulnerability_detected",
"summary": "[cside] High-risk Vulnerability: CVE-2024-1234 on checkout.example.com",
"description": "cside detected a high-risk vulnerability on checkout.example.com: lodash. Severity: High.\n\nThis is a high-risk finding (malware or a high-severity vulnerability). Investigate it promptly: if it is malware, remove the script immediately and rotate any credentials it may have accessed; otherwise prioritize updating or removing the affected dependency.",
"data": {
"summary": "[cside] High-risk Vulnerability: CVE-2024-1234 on checkout.example.com",
"description": "cside detected a high-risk vulnerability on checkout.example.com: lodash. Severity: High.\n\nThis is a high-risk finding (malware or a high-severity vulnerability). Investigate it promptly: if it is malware, remove the script immediately and rotate any credentials it may have accessed; otherwise prioritize updating or removing the affected dependency.",
"domain": "checkout.example.com",
"trigger_type": "high_risk_vulnerability_detected",
"target_type": "URL",
"target": "https://cdn.example.com/lodash.js",
"action": "alert",
"ruleset_id": "ruleset_vulnerability_detected",
"ruleset_name": "Vulnerability Detected",
"ruleset_description": "A script with a known vulnerability was loaded",
"script_urls": ["https://cdn.example.com/lodash.js"],
"vuln_cve_id": "CVE-2024-1234",
"vuln_package": "lodash",
"vuln_severity": "HIGH",
"vuln_reference_url": "https://osv.dev/vulnerability/CVE-2024-1234"
},
"destination_id": "1897425263682514950"
}
security_headers.changed
Sent by Security Headers Changed when the security headers observed on a domain’s checkout pages differ between two consecutive observation windows.
| Field | Type | Description |
|---|---|---|
event | "security_headers.changed" | The event ID |
sent_at | string | When the webhook was sent |
team_id | string | ID of the team that owns the domain |
summary | string | [cside] Security headers changed on {domain} |
description | string | One line per changed header: - {header}: {from} -> {to} |
data.summary | string | Same as summary |
data.description | string | Same as description |
data.domain_id | string | ID of the domain |
data.domain | string | Hostname of the domain |
data.checkout_paths | string[] | The checkout-page selectors that were observed |
data.changes | object[] | One entry per header that changed |
data.changes[].header_name | string | The header, for example content-security-policy |
data.changes[].from_value | string | The previous value; empty when the header was added |
data.changes[].to_value | string | The new value; empty when the header disappeared |
data.changes[].from_date | string | Start of the observation window the previous value was seen in |
data.changes[].to_date | string | Start of the observation window the new value was seen in |
pci_report.available
Sent by PCI Report once the report for the period is ready, for the scoped
domains that have checkout paths configured. It is sent once per report; a
rejected delivery is not retried. Each request also carries an
x-cside-delivery-id header that stays the same if the same report is
delivered again, so you can deduplicate on it.
| Field | Type | Description |
|---|---|---|
event | "pci_report.available" | The event ID |
timestamp | string | When the webhook was sent |
organization_id | string | ID of the organization |
team_id | string | ID of the team |
summary | string | [cside / PCI DSS]: New PCI Compliance Report for {domains} (the first three domains, then “and N more”) |
description | string | A sentence naming the domains and dates, followed by the report link |
data.summary | string | Same as summary |
data.description | string | Same as description |
data.report_id | string | ID of the report |
data.report_url | string | Link to download the report from the cside dashboard |
data.domain_ids | string[] | IDs of the domains the report covers |
data.domains | string[] | Hostnames of the domains the report covers, in the same order |
data.starting_date | string | Start of the reporting period |
data.ending_date | string | End of the reporting period |
data.period | "daily" | "weekly" | "custom" | The length of the reporting period. custom is used for a report covering any other range |
csp_block_digest
Sent by Script Blocked by CSP with the Content Security Policy violation reports collected in the period. Nothing is sent when there were no violations.
| Field | Type | Description |
|---|---|---|
event | "csp_block_digest" | The event ID |
timestamp | string | When the webhook was sent |
organization_id | string | ID of the organization |
team_id | string | ID of the team |
summary | string | One-line title for the digest |
description | string | Multi-line breakdown of the digest |
data.summary | string | Same as summary |
data.description | string | Same as description |
data.period | "daily" | "weekly" | The rule’s schedule |
data.start_date | string | Start of the digest period |
data.end_date | string | End of the digest period |
data.domains | object[] | Per-domain counts, ordered by violation count |
data.domains[].domain_id | string | ID of the domain |
data.domains[].domain | string | Hostname of the domain |
data.domains[].violation_count | number | Violations recorded for the domain |
data.top_blocked_uris | string[] | The most frequently blocked URIs |
data.top_violated_directives | string[] | The most frequently violated directives |
data.total_violations | number | Total violations across all scoped domains |
csp_policy_digest
Sent by CSP Policy Digest for each scoped domain whose recommended Content Security Policy changed in the period. Nothing is sent when no domain changed.
| Field | Type | Description |
|---|---|---|
event | "csp_policy_digest" | The event ID |
timestamp | string | When the webhook was sent |
organization_id | string | ID of the organization |
team_id | string | ID of the team |
summary | string | One-line title for the digest |
description | string | Multi-line breakdown of the digest |
data.summary | string | Same as summary |
data.description | string | Same as description |
data.period | "daily" | "weekly" | The rule’s schedule |
data.domains | object[] | One entry per domain whose policy changed |
data.domains[].domain | string | Hostname of the domain |
data.domains[].domain_id | string | ID of the domain |
data.domains[].added | string[] | Hosts added to the recommended policy |
data.domains[].removed | string[] | Hosts removed from the recommended policy |
data.domains[].flagged | string[] | Removed hosts that are on cside’s managed threat list |
data.domains[].host_count | number | Number of hosts in the recommended policy |
data.domains[].policy_header | string | The full recommended Content-Security-Policy header value |
vulnerable_script_digest
Sent by Vulnerable Script Detected on a daily or weekly schedule with the routine (not high-risk) vulnerability findings from the period. Nothing is sent when there were no findings.
| Field | Type | Description |
|---|---|---|
event | "vulnerable_script_digest" | The event ID |
timestamp | string | When the webhook was sent |
organization_id | string | ID of the organization |
team_id | string | ID of the team |
summary | string | One-line title for the digest |
description | string | Multi-line breakdown of the digest |
data.summary | string | Same as summary |
data.description | string | Same as description |
data.period | "daily" | "weekly" | The rule’s schedule |
data.start_date | string | Start of the digest period |
data.end_date | string | End of the digest period |
data.domains | object[] | Per-domain counts |
data.domains[].domain_id | string | ID of the domain |
data.domains[].domain | string | Hostname of the domain |
data.domains[].vulnerability_count | number | Findings recorded for the domain |
data.top_vulnerabilities | object[] | The most frequent findings |
data.top_vulnerabilities[].vulnerability_id | string | Vulnerability ID (CVE or GHSA) |
data.top_vulnerabilities[].package | string | Affected package |
data.top_vulnerabilities[].severity | string | Reported severity |
data.top_vulnerabilities[].occurrence_count | number | How often the finding occurred |
data.total_vulnerabilities | number | Total findings across all scoped domains |
outbound_connections_pending_review
Sent by Outbound Connections Pending Review with the number of outbound hostnames your team has not yet approved in Resources inventory. Nothing is sent when nothing is pending.
| Field | Type | Description |
|---|---|---|
event | "outbound_connections_pending_review" | The event ID |
timestamp | string | When the webhook was sent |
organization_id | string | ID of the organization |
team_id | string | ID of the team |
summary | string | One-line title for the digest |
description | string | Multi-line breakdown of the digest |
data.summary | string | Same as summary |
data.description | string | Same as description |
data.period | "daily" | "weekly" | The rule’s schedule |
data.pending_hostname_count | number | Distinct outbound hostnames awaiting review |
data.domain_count | number | Number of scoped domains on which they were observed |
HMAC verification
Every webhook request includes an x-cside-signature header containing an HMAC SHA256 hash of the request body, signed with the secret you provided when creating the webhook destination.
You should always verify this signature to confirm the request came from cside and prevent replay attacks.
How to verify the signature
If you are using JavaScript, use our JavaScript package for built-in verification.
For other languages, compute the HMAC SHA256 hash of the raw request body using your webhook secret, then compare it to the x-cside-signature header value.
async function verifyHmac(body: string, signature: string, secret: string) {
const encoder = new TextEncoder();
const encodedBody = encoder.encode(body);
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signatureBuffer = await crypto.subtle.sign("HMAC", key, encodedBody);
const finalSig = Array.from(new Uint8Array(signatureBuffer))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
return signature.toLowerCase() === finalSig;
}fn verify_hmac(body: &str, signature: &str, secret: &str) -> Result<(), &'static str> {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "Invalid secret")?;
mac.update(body.as_bytes());
let result = mac.finalize();
let code_bytes = result.into_bytes();
let final_sig = hex::encode(code_bytes);
if signature.to_lowercase() == final_sig {
Ok(())
} else {
Err("Invalid signature")
}
}
// Add to Cargo.toml:
// [dependencies]
// hmac = "0.12"
// sha2 = "0.10"
// hex = "0.4"package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func verifyHmac(body, signature, secret string) bool {
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(body))
finalSig := hex.EncodeToString(h.Sum(nil))
return strings.ToLower(signature) == finalSig
}defmodule HmacVerifier do
def verify_hmac(body, signature, secret) do
final_sig =
:crypto.mac(:hmac, :sha256, secret, body)
|> Base.encode16(case: :lower)
String.downcase(signature) == final_sig
end
endimport hmac
import hashlib
import secrets
def verify_hmac(body: str, signature: str, secret: str) -> bool:
final_sig = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).hexdigest()
return signature.lower() == final_sigThanks for your feedback!