# Webhook reference

> Every inbound webhook adapter — URL forms, the fields each one reads, how service, severity, status and identity are derived, the response counters, correlation rules and the exact error strings.

_Reference · Updated 2026-10-27 · Describes release 0.1.164_

Exact facts about the inbound webhook plane, one section per adapter. For the walkthroughs that set a sender up, read [Alerts from your observability platform](/help/external-observability.md) and [Alert sources and intake rules](/help/setup-alert-sources.md); this page is what those pages point at when a field, a mapping or an error string matters.

## URL forms and authentication {#url-forms-and-authentication}

```
POST https://<your-host>/webhook/<source>/<workspace-slug>
POST https://<your-host>/webhook/<source>/<workspace-slug>/<token>
```

`<source>` is one of the adapter names below. `alertmanager`, `generic` and `change` are their own routes; every other source is served by one shared adapter route, except `kubernetes-audit`, whose only path form ends in the cluster name and takes the cluster's own audit token.

The credential is the workspace's webhook token, read in this order; the first one present wins:

| Form | Header or path | Note |
|---|---|---|
| Bearer header (preferred) | `Authorization: Bearer <token>` | What Alertmanager's `http_config.authorization` and Robusta's sink `authorization` produce. Headers never land in access or proxy logs. |
| Token header | `X-Webhook-Token: <token>` | For senders without bearer support. |
| Path segment | `/webhook/<source>/<workspace-slug>/<token>` | Kept for compatibility and demonstrations; the only form an Amazon SNS subscription or an Azure Monitor action group can send. Lands in access logs. |

Only the SHA-256 hash of the token is stored. A bad slug, a disabled workspace, a workspace that has never minted a token and a wrong token all answer the same `401 invalid webhook credentials`, so nothing about the workspace leaks to whoever posts at it. Minting a new token (**Alert intake & tokens → Webhook ingest → Mint webhook token**, `pillars.manage`) invalidates the previous one immediately; there is no overlap window.

A token that travels in one of the platform's own intake URLs — the path segment after the slug, or a `token=` query value — is redacted as `[REDACTED:secret]` before an alert, a change or a finding is stored.

## Rate limit {#rate-limit}

A per-workspace token bucket over **alert items** (or change events), not HTTP requests: one payload may carry a whole storm. The limit is `aiops_ingest_rate_per_min` (platform default 1200 items per minute, one minute of burst; a workspace value of `0` or less switches it off). When the bucket is dry the **whole payload** is refused with `429` and a `Retry-After` header in seconds, so a sender that honours `Retry-After` redelivers everything and nothing is silently dropped. The refusal is audited as `ingest.rate_limited` at most once a minute per workspace.

## Response body {#response-body}

Every alert endpoint answers the same counter set, whatever the source. The two scanner-specific counters are present as `0` for ordinary alert sources, so never assert on an exact key set.

| Field | Meaning |
|---|---|
| `received` | Alert items the adapter produced from the payload. `0` with a `200` means the adapter recognised none of its fields. |
| `alerts_created` | New alert rows. `alerts_created` ≈ `received` together with `incidents_created` growing 1:1 is the signature of failed attribution: every alert is opening its own incident. |
| `deduped` | Items whose fingerprint is already active on an open incident. `deduped > 0` on a repeat delivery proves the identity is stable. |
| `suppressed` | Items that joined an existing incident through their grouping label or a fold. Large during a burst on one service. Tickets are counted under `joined` instead. |
| `joined` | Tickets (ServiceNow, Jira, Azure DevOps) that joined an existing incident — a ticket about a service the platform is already alerting on. The ticket is kept as evidence on that incident, never dropped. |
| `risk_suppressed` | Scanner findings held down by a live risk acceptance (Security Hub / Defender only). |
| `scoped_out` | Scanner findings outside the workspace tag scope (`workspace_scope`, platform-staff setting). |
| `stale_resends` | Security Hub / Defender re-sends observed before the recorded fix: counted on the register row, no incident opened. |
| `ticket_echo` | Tickets the platform itself filed, coming back through a ticket webhook — the create, every update and the resolve, whoever resolved it: counted, never a second incident. |
| `alerts_resolved` | Alert rows a `resolved` item flipped. Proves the recovery matched the firing identity — but only the alert row. |
| `incidents_created` | Ids of the incidents this delivery opened. |
| `incidents_resolved` | Ids of the incidents this delivery closed. Stays empty while any other alert on the incident still fires, and while a person holds the row (`escalated`, `awaiting_approval`, `remediating`) — that case records the clear and leaves the close to the person. The counter to assert on when testing recovery wiring. |
| `investigations` | Ids of the analysis runs this delivery started. |

Change-carrying deliveries answer `{"recorded": N, "flagged_unauthorized": M}` instead — no alert counters (see [Change sources](#change-sources)). A source that has a change extractor but no alert adapter answers `{"recorded": 0}` when the payload yields no change event. An SNS `SubscriptionConfirmation` answers `{"confirmed": true|false}`.

## Canonical labels and workspace maps {#canonical-labels-and-workspace-maps}

Every adapter produces alert items of one shape: `fingerprint`, `status` (`firing` or `resolved`), `labels`, `annotations`, `starts_at` and the raw payload. The canonical labels every later stage reads:

| Label | Role |
|---|---|
| `service` | The correlation identity. Alerts sharing it join one incident; blast radius, topology folds and the suspect-change pin key on it. |
| `alertname` | The rule name; the grouping key when no `service` (and no `namespace`) is present. |
| `severity` | `critical`, `error`, `warning` or `info`. Everything else ranks below `info`. |
| `namespace` | Second grouping key, after `service`. |
| `resource`, `account`, `region`, `subscription`, `resource_group`, `resource_type`, `cluster`, `env` | Carried for the agent, the incident list filters and the tier rules; never grouping keys. |
| `criticality` | Stamped from the alert's own `criticality` / `business_criticality` label or the workspace `criticality_map` (`low`, `medium`, `high`, `mission-critical`); raises priority and the risk tier, never changes grouping. |

Four workspace settings rewrite labels before deduplication, in this order:

1. `label_map` `{"source_key": "canonical_key"}` renames label **keys**. The hook that makes a source with its own vocabulary correlate with everything else (for example `{"FunctionName": "service"}`).
2. `severity_map` `{"source_value": "canonical"}` rewrites the `severity` **value** when it matches exactly.
3. `source_service_labels` `{"<source>": ["<label>", …]}` (what the **Unidentified service — map this source** card saves) names the service of an alert from that source that still carries no `service`: the first listed label the alert carries is **copied** onto `service`; an alert's own service is never replaced, and no other source is touched.
4. `service_alias` `{"<alert service>": "<dependency-map service>"}` (the **Service aliases** card) is the last step: an alert whose `service` is listed takes the dependency map's name, so it keys, folds and reads its blast radius there; the original stays as `service_original`. One hop, never a chain (see [Service aliases](/help/storms-and-unmapped-signals.md#service-aliases)).

All four are workspace settings set with `PATCH /api/aiops/{tenant_id}` (`members.manage`); `source_service_labels` also has its card under **Alert intake & tokens → Intake rules → Mapped sources**, and `service_alias` its **Service aliases** card below it. The [Settings reference](/help/settings-reference.md) lists their shapes and checks.

## Identity, grouping and storms {#identity-grouping-and-storms}

**Dedupe** is by `fingerprint`: an item whose fingerprint is active on an open incident is counted as `deduped` and dropped, whatever its labels say. Every adapter below states what its fingerprint is made of.

**Grouping key** is the strongest of `service=<v>`, `namespace=<v>`, `alertname=<v>` the item carries, in that order, within one pillar; an item with none of them keys on `fingerprint=<fp>` and never groups. There is no time window: an alert joins the open incident with its key regardless of age. The first alert of an incident is `correlated`, later ones `suppressed`.

**Pillar routing**: `severity: security` routes to SecOps; a `finding_family` label routes `threat-detected` to SecOps and every other family to Compliance; a `source`, `labels.source` or `labels.tool` value of `guardduty`, `securityhub`, `falco`, `trivy`, `wazuh` or `crowdstrike` (case-insensitive) routes to SecOps; everything else is Incident and Problem. The pillar is part of the grouping bucket, so a Falco alert for `payments-api` never joins the telemetry incident for the same service.

**Grouping by the service graph**: an alert that would open (or reopen) an incident for a service whose dependents already have open incidents (opened or reopened inside `aiops_storm_group_window_s` + `aiops_storm_spread_window_s`, 900 s) opens it and takes theirs in, over edges allowed to fold (a caller's analysis waits up to `aiops_storm_group_window_s` for its dependency's alert, unless it is a P1); with `aiops_storm_agent` on (the default) one bounded turn later groups related incidents over every recorded edge. A burst of unrelated services opens one incident each (an alert name already firing on 3 services still joins their incident, `aiops_fold_shared_cause_threshold`); there is no storm threshold and no `__storm__` incident since 0.1.147. The rules and the review steps are in [Alert storm: grouped by the service graph](/help/storms-and-unmapped-signals.md#alert-storm-hold-and-split).

**Holds at ingest**: an alert naming no `service` opens its own held `unmapped=<fp>` incident when `aiops_unmapped_triage` is on; so does a ServiceNow, Jira or Azure DevOps ticket that does not name every field in `ticket_required_fields` (the platform asks on it once, see [Tickets that come in must name five fields](/help/ticket-systems.md#required-fields)); an alert with neither a service-ish label nor an `alertname` is held when `aiops_require_identity_labels` is on; a built-in detection warning rule opens its incident with no run. Held incidents wait in the triage queue for **Analyse**.

**Reopen**: when `aiops_reopen_window_h` is above `0`, an alert whose key matches an incident resolved inside the window reopens that incident instead of opening a new one; `aiops_flap_threshold` reopens inside the window mark it flapping. Storm and fingerprint keys never reopen, and neither does an incident a person closed as known noise.

**Priority** is derived at open and re-derived on every change of severity, blast radius or criticality: `critical` → P1, `error` → P2, `warning` → P3, anything else → P4; one level up (floor P1) when the computed blast radius reaches two services (failing dependents grouped in count since 0.1.147), and one more for a `mission-critical` resource. The owner a new incident opens with comes from `assignment_map` `{"<service glob>": "<assignee>"}`: the first pattern matching the alert's `service` (its `namespace` when there is no service).

## Alertmanager {#alertmanager}

`POST /webhook/alertmanager/<workspace-slug>` — the standard Alertmanager webhook body, the highest-fidelity path: labels and annotations pass through verbatim.

| Payload field | Canonical field |
|---|---|
| `alerts[].fingerprint`, else a hash of the labels | `fingerprint` |
| `alerts[].status`, else the top-level `status`, else `firing` | `status` |
| `alerts[].labels` | `labels` (verbatim) |
| `alerts[].annotations` | `annotations` (verbatim) |
| `alerts[].startsAt` | `starts_at` |

Configure the receiver with `send_resolved: true` and the token through `http_config.authorization` (type `Bearer`). A payload with no `alerts` list answers `200` with `"received": 0`.

## Generic {#generic}

`POST /webhook/generic/<workspace-slug>` — `{source, alerts: [{fingerprint, status, labels, annotations, message, startsAt}]}`. You control every canonical field.

| Payload field | Canonical field | Note |
|---|---|---|
| `source` (top level) | the recorded alert source **and** a pillar selector | Defaults to `generic`. The reserved value `builtin` is rewritten to `generic` (it names the platform's own detector). The security-tool names listed under [Identity, grouping and storms](#identity-grouping-and-storms) send the alert to SecOps. |
| `alerts[].fingerprint` | `fingerprint` | If omitted: a hash of `labels`, or of `annotations` when there are no labels. Send your own — a slug of `<service>:<condition>` is ideal. |
| `alerts[].status` | `status` | Defaults to `firing`. Send the recovery as the same fingerprint with `"status": "resolved"`. |
| `alerts[].labels` | `labels` | Verbatim. |
| `alerts[].annotations` | `annotations` | Verbatim. |
| `alerts[].message` | `annotations.message` | Convenience, when `annotations.message` is not already set. |
| `alerts[].startsAt` or `alerts[].starts_at` | `starts_at` | ISO-8601 or epoch seconds/milliseconds. The onset the suspect-change pin looks back from; without it the receipt time is used. |

## CloudWatch via SNS {#cloudwatch-via-sns}

`POST /webhook/cloudwatch-sns/<workspace-slug>[/<token>]` — the SNS notification envelope whose `Message` is the alarm JSON as a string; with `RawMessageDelivery` the alarm JSON may be the whole body. SNS cannot set a header, so the token rides in the path. The `SubscriptionConfirmation` handshake is answered automatically on **any** `/webhook/<source>` path; only an `https://sns.<region>.amazonaws.com` `SubscribeURL` is ever fetched.

| Alarm field | Canonical field | Note |
|---|---|---|
| `AlarmArn`, else `AlarmName` | `fingerprint` | Trigger and recovery share it. |
| `AlarmName` | `labels.alertname` | **Required**; a payload without it yields no alert. |
| `Trigger.Dimensions[].{name,value}` (or `Name`/`Value`) | `labels.<name> = <value>` | Every dimension becomes a label, verbatim. |
| `Trigger.Metrics[]` (anomaly-detection and metric-math alarms) | the dimension rows above | The metric the `ThresholdMetricId` band is computed over wins; else the first `MetricStat` with dimensions; else the first. |
| `service=<name>` in `AlarmDescription`, else dimension `ServiceName` or `service`, else the most specific resource-identifier dimension | `labels.service` | Identifier order: `InstanceId`, `VolumeId`, `DBInstanceIdentifier`, `DBClusterIdentifier`, `CacheClusterId`, `ClusterIdentifier`, `ClusterName`, `FunctionName`, `StateMachineArn`, `TableName`, `QueueName`, `TopicName`, `StreamName`, `BucketName`, `FileSystemId`, `TargetGroup`, `LoadBalancer`, `ApiName`, `DistributionId`, `ProjectName`, `PipelineName`, `JobQueue`, `DomainName`, `BrokerName`, `WorkGroup`, `DeliveryStreamName`, then `AutoScalingGroupName`. An Application Signals alarm with no dimensions takes the `eks:<cluster>` of its `Environment` expression. Category-only dimensions (StorageType, Currency) give no `service`: with the triage queue on (the default since 0.1.133) the alarm opens its own held `unmapped=<fingerprint>` incident; with it off it groups by `alertname`. |
| most specific identifier, else the first dimension value | `labels.resource` | Only when `resource` is not already set. |
| `Trigger.Namespace` | `labels.metric_namespace` | Deliberately not `namespace`: `AWS/EC2` is a metric taxonomy, not a failure domain. |
| `<Namespace>/<MetricName>` | `annotations.metric` | Which metric fired. |
| `AWSAccountId` | `labels.account` | |
| `AlarmArn` field 4 | `labels.region` | |
| dimension `InstanceId` with `AWSAccountId` and the `AlarmArn` region | `annotations.resource_arn` | `arn:aws:ec2:<region>:<account>:instance/<id>`, what a step runbook binds with `from: annotation:resource_arn`; not set when the alarm's metric belongs to another account. |
| `NewStateValue` | `status` | `OK` → `resolved`; `ALARM` and `INSUFFICIENT_DATA` → `firing`. |
| `AlarmDescription`, else the name | `annotations.summary` | Becomes the incident title. |
| `NewStateReason` | `annotations.message` | |
| `StateChangeTime` | `starts_at` | |
| `severity=<level>` in `AlarmDescription`, else `critical` | `labels.severity` | `high`, `warn` and `low` read as `critical`, `warning` and `info`; a dimension literally named `severity` overwrites both. |

Point the alarm's OK action at the same topic or nothing ever closes. Set `treat_missing_data` to `notBreaching` on metrics that legitimately go idle; the Probable cause card never calls a change proven good on an alarm that can be OK on silence.

## Azure Monitor {#azure-monitor}

`POST /webhook/azure-monitor/<workspace-slug>[/<token>]` — the common alert schema; the adapter reads `data.essentials` and a payload without it yields no alert. Dynamic-threshold rules send the same `essentials` and attribute the same way; `alertContext` is not read.

| Field | Canonical field | Note |
|---|---|---|
| `essentials.alertId`, else the rule name | `fingerprint` | |
| `essentials.alertRule` | `labels.alertname` | |
| `essentials.severity` | `labels.severity` | `Sev0`, `Sev1` → `critical`; `Sev2` → `warning`; `Sev3`, `Sev4` → `info`; anything else → `warning`. |
| `alertTargetIDs[0]`, last path segment | `labels.resource` | |
| subscription and resource group inside `alertTargetIDs[0]` | `labels.subscription`, `labels.resource_group` | |
| `providers/<ns>/<type>` inside `alertTargetIDs[0]` | `labels.resource_type` | For example `Microsoft.Compute/virtualMachines`. |
| the target resource name | `labels.service` | Only when the target is a real resource. A target that is only the rule's scope — a Log Analytics workspace, an Application Insights component (`microsoft.operationalinsights/workspaces`, `microsoft.insights/components`), the bare subscription — gets no `service`: with the triage queue on (the default since 0.1.133) it opens its own held `unmapped=<fingerprint>` incident; with it off it groups by `alertname`. |
| `essentials.monitorCondition` | `status` | `Resolved` → `resolved`; everything else → `firing`. |
| `essentials.description`, else the rule | `annotations.summary` | |
| `essentials.firedDateTime` | `starts_at` | |

Enable auto-resolve on the alert rule so `Resolved` is actually sent. For scope-targeted rules, `label_map: {"resource": "service"}` attributes at resource granularity.

## Grafana {#grafana}

`POST /webhook/grafana/<workspace-slug>` — Grafana unified alerting's Alertmanager-shaped body. Mapping as [Alertmanager](#alertmanager), with label values coerced to strings. A multi-dimensional rule emits one alert per series; the series label that identifies the workload must be mapped onto `service` in the rule or through `label_map`. Leave *Disable resolved message* off.

## Datadog {#datadog}

`POST /webhook/datadog/<workspace-slug>` — a monitor webhook with the custom payload template:

```json
{"alert_id": "$ALERT_ID", "id": "$ID", "alert_scope": "$ALERT_SCOPE",
 "title": "$ALERT_TITLE", "body": "$EVENT_MSG", "alert_type": "$ALERT_TYPE",
 "alert_transition": "$ALERT_TRANSITION", "tags": "$TAGS", "date": "$DATE"}
```

| Field | Canonical field | Note |
|---|---|---|
| `alert_id`, else `id`, plus `alert_scope` | `fingerprint` | `$ALERT_ID` is the monitor id; `$ALERT_SCOPE` is the alert group, so without it a multi-alert monitor's second group is deduped away and one group's recovery closes the incident for all. With no id at all, the labels are hashed. Tags never enter the fingerprint. |
| `title`, else `event_title` | `labels.alertname`, `annotations.summary` | A payload with no title and no id yields no alert. |
| `tags` — `key:value`, comma-separated string or list | `labels.<key> = <value>` | Where `service`, `env` and `team` come from. |
| `alert_type` | `labels.severity` | `error` → `critical`; `warning`, `warn` → `warning`; `info`, `success` → `info`; anything else → `warning`. A `severity:` tag is overwritten. |
| `alert_transition`, else `transition` | `status` | Contains `recovered` → `resolved`; else `firing`. |
| `date`, else `last_updated` | `starts_at` | Epoch milliseconds. |
| `body` | `annotations.message` | |

Enable the webhook on both the alert and the recovery transitions.

## Dynatrace {#dynatrace}

`POST /webhook/dynatrace/<workspace-slug>` — a problem notification from a **Custom integration** (or a Workflow) with the JSON template whose `{ImpactedEntities}` and `{ProblemDetailsJSONv2}` placeholders stay **unquoted**. A Dynatrace problem is already a correlated incident, so one problem is one alert, never one per entity.

| Field | Canonical field | Note |
|---|---|---|
| `PID`, else `ProblemID`, else the title | `fingerprint` | `dynatrace:<id>` — the same on `OPEN` and `RESOLVED`. |
| `State` (else `ProblemDetailsJSONv2.status`) | `status` | `RESOLVED`, `CLOSED` → `resolved`; everything else → `firing`. |
| `ProblemTitle` | `labels.alertname`, `annotations.summary` | No `PID`, no `ProblemID` and no title → no alert. |
| `ProblemSeverity` (else `severityLevel`) | `labels.severity` | `AVAILABILITY`, `ERROR` → `critical`; `PERFORMANCE`, `RESOURCE_CONTENTION`, `CUSTOM_ALERT` → `warning`; `MONITORING_UNAVAILABLE`, `INFO` → `info`; missing → `warning`. |
| `Tags` (`key:value`, comma-separated), else the v2 `entityTags` | `labels.<key> = <value>` | A `[context]` prefix is dropped; a tag with no value is ignored. |
| a `service:` tag, else the first impacted `SERVICE`, else `APPLICATION`, else the first impacted entity | `labels.service` | |
| `PID`, `ProblemID`, `ProblemImpact` | `labels.dynatrace_problem_id`, `labels.dynatrace_display_id`, `labels.dynatrace_impact` | |
| `ImpactedEntities` | `labels.impacted_entities` (names, capped at 300 chars), `labels.dynatrace_entity_type` (the first entity's type) | |
| `ProblemDetailsJSONv2.rootCauseEntity.name` | `labels.dynatrace_root_cause` | |
| `ProblemDetailsJSONv2.startTime` | `starts_at` | Epoch ms. |
| `ProblemURL`, `ProblemDetailsText` | `annotations.problem_url`, `annotations.details` (2,000 chars) | |

**Send test notification** posts the template with sample values and is answered `200` with `"received": 1, "alerts_created": 1` and one incident on service `MyService1`; resolve that incident by hand unless a `RESOLVED` for the same test `PID` follows. A `400 request body is not JSON` on this path usually means a string placeholder was left unquoted.

## Robusta {#robusta}

`POST /webhook/robusta/<workspace-slug>` — a `webhook_sink` finding, including the Kubernetes-native findings (CrashLoopBackOff, OOMKill, ImagePullBackOff) that never reach Alertmanager.

| Field | Canonical field | Note |
|---|---|---|
| `fingerprint`, else a hash of the labels | `fingerprint` | |
| `title`, else `aggregation_key` | `labels.alertname` | Neither → no alert. |
| `severity` | `labels.severity` | `high` → `critical`; `medium`, `low` → `warning`; `info`, `debug` → `info`; else `warning`. |
| `subject.labels` | `labels` | Merged in first, so an explicit `service` label wins. |
| `subject.name` | `labels.resource`, `labels.service` (fallback) | The workload name is the service unless labelled. |
| `subject.namespace`, `subject.node`, `subject.container`, `subject.kind`, `cluster_name` | `labels.namespace`, `labels.node`, `labels.container`, `labels.kind`, `labels.cluster` | |
| `description`, else the title; `subject.annotations` | `annotations.summary`, plus the subject annotations | |
| `starts_at` | `starts_at` | |

**Always `firing`**: Robusta emits no resolved transition, so closure comes from another source clearing, a remediation or a person.

## Security Hub {#security-hub}

`POST /webhook/securityhub/<workspace-slug>[/<token>]` — ASFF findings delivered by EventBridge (`detail.findings`), by SNS (a JSON string in `Message`), posted directly as `{"findings": [...]}`, or as a bare finding with an `Id` or `Title`. Findings are normalised into the compliance shape; one finding of a shape the platform does not model is skipped, not the whole delivery.

| Canonical field | Value |
|---|---|
| `fingerprint` | the finding `Id`, else `<control>:<resource ARN>` |
| `labels.service` | `control:<ControlId>` (for example `control:S3.9`); an AWS Config rule's finding has no control id and keys on its rule name, without a conformance pack's `-conformance-pack-<id>` suffix (`control:iam-user-mfa-enabled` — the same rule in two packs is one control); else `cloud-posture` — correlation is on the **control**: twelve buckets failing one control is one piece of work |
| `labels.alertname` | the control id, else the first 80 characters of the title |
| `labels.severity`, `labels.finding_family`, `labels.remediation_stance`, `labels.remediation_tier`, `labels.rule_kind` | the classifier's output (`remediation_stance` is `auto`, `human` or `ticket`; `rule_kind` defaults to `detective`) |
| `labels.account`, `region`, `resource`, `resource_type`, `product`, `fix_available`, `remediation_operation` | when the finding carries them |
| `annotations.summary`, `description` (2,000 chars), `recommended_action`, `finding_id`, `resource_arn`, `vendor_remediation` (1,000 chars), `vendor_remediation_url`, `cve_ids` (first 10), `cvss`, `vulnerable_packages` | `vulnerable_packages` names each vulnerable package once with its installed and fixed version, for example `stdlib (GOBINARY) 1.26.8 -> 1.27.2` (first 10). A re-sent finding refreshes these annotations on the alert already open, so the next analysis reads what the scanner reports now |
| `status` | `resolved` when the scanner reports the finding cleared (`RESOLVED`, `PASSED`); else `firing` |

The `finding_family` label routes a `threat-detected` family (GuardDuty) to SecOps and every other family to Compliance; an AWS Config rule is never a threat, whatever service its name mentions (`guardduty-enabled-centralized` checks that GuardDuty is on). Forward `PASSED` / `RESOLVED` too or the clear never arrives. The same findings can be pulled on a schedule instead (`aiops_scanner_sync_interval_s`, **Security posture → Posture → Scanner sync**); pull and webhook share the deduplication.

## Defender for Cloud {#defender-for-cloud}

`POST /webhook/defender/<workspace-slug>` — assessments or security alerts pushed by workflow automation (a Logic App HTTP action): a bare object, or a `findings`, `value`, `assessments` or `alerts` list, optionally under `data`. Normalised into the same shape as [Security Hub](#security-hub), so one compliance path serves both clouds. Keep the *Healthy* state in the automation's filter so the clear arrives.

## ServiceNow {#servicenow}

`POST /webhook/servicenow/<workspace-slug>` — an outbound REST message from a business rule. ServiceNow has no fixed webhook schema; the record is read from `record`, `resource` or the flat body, and each field may be a string or a `{display_value, value}` pair (name-like fields read the display value, `priority` and `state` read the code).

| Field | Canonical field | Note |
|---|---|---|
| `sys_id`, else `number` | `fingerprint` (`snow-<id>`) | Neither → no alert. |
| `number` | `labels.alertname` (`servicenow-<number>`) | |
| `cmdb_ci`, else `business_service` | `labels.service` | A ticket about a service already alerting **joins that incident**. |
| `priority` | `labels.severity` | `1` → `critical`; `2` → `error`; `3` → `warning`; `4`, `5` → `info`; else `warning`. |
| `state` | `status` | `6`, `7`, `resolved`, `closed` → `resolved`; else `firing`. |
| `short_description`, `description` (2,000 chars) | `annotations.summary`, `annotations.message` | |
| `number`/`sys_id`, `url`, 32-hex `sys_id` | `annotations.ticket_ref`, `ticket_url`, `ticket_api_id` | The incident adopts the originating ticket and writes its analysis back to it. |
| `correlation_id` matching `aiops:<incident id>` | `annotations.aiops_mark` | The platform's own ticket coming back: counted as `ticket_echo`, never a second incident. Include `correlation_id`, `correlation_display` and `sys_id` in the posted record. |
| `severity`; `priority`, else `impact` and `urgency`; `assigned_to`, else `assignment_group`; `watch_list` | `annotations.ticket_fields` | Which required fields the ticket names (the names only, never the people). |
| a `Field: value` line in `description` (`Service:`, `Severity:`, `Priority:`, `Assignee:`, `Notify:`) | `annotations.ticket_fields`; `Service:` → `labels.service` when there is no CI, `Severity:` → `labels.severity` | A value that starts with a placeholder (`TBD`, `n/a`, `none`, `-`) names nothing. |
| `opened_at` | `starts_at` | |

A record whose `sys_class_name` starts with `change_request`, or whose number starts with `CHG`, is a change and goes to the [change ledger](#change-sources) instead.

## Jira {#jira}

`POST /webhook/jira/<workspace-slug>` — `jira:issue_created` and `jira:issue_updated` only; comment events (the platform's own RCA write-back included) yield nothing.

| Field | Canonical field | Note |
|---|---|---|
| `issue.key` | `fingerprint` (`jira-<key>`), `labels.alertname` (`jira-<key>`) | No key → no alert. |
| `fields.components[0].name` | `labels.service` | |
| `fields.priority.name` | `labels.severity` | `highest` → `critical`; `high` → `error`; `medium` → `warning`; lower → `info`; else `warning`. |
| `fields.status.statusCategory.key` | `status` | `done` → `resolved`; else `firing`. |
| `fields.summary`, `fields.description` (plain text only, 2,000 chars) | `annotations.summary`, `annotations.message` | Atlassian Document Format descriptions are not carried. |
| `issue.key`, `<site>/browse/<key>` | `annotations.ticket_ref`, `ticket_url`, `ticket_api_id` | |
| a label `aiops-<incident id>` | `annotations.aiops_mark` | The platform's own ticket echo. |
| `fields.priority`, `fields.assignee`; a `Field: value` line in the description or a `field:value` label (`service:`, `severity:`, `notify:` …) | `annotations.ticket_fields` | Which required fields the issue names (the names only). A `Service:` line or `service:` label is `labels.service` when there is no component; a `Severity:` one is `labels.severity`. |
| `fields.created` | `starts_at` | |

An issue whose type name contains `change` goes to the [change ledger](#change-sources) instead.

## Azure DevOps {#azure-devops}

`POST /webhook/azure-devops/<workspace-slug>` — service hooks. `workitem.created` and `workitem.updated` are signals; `git.push` and `git.pullrequest.merged` are [changes](#change-sources); every other event type yields nothing.

`workitem.updated` is read from the work item as it now is (`resource.revision.fields`, id `resource.workItemId`). A state change is a signal. An edit of `System.Tags`, `System.AssignedTo`, `Severity`, `Priority`, `System.Description`, `Microsoft.VSTS.TCM.ReproSteps` or `System.AreaPath` only moves on a work item held for missing fields ([Tickets that come in must name five fields](/help/ticket-systems.md#required-fields)) and otherwise yields nothing; any other update — a comment, the platform's own write-back — yields nothing.

| Field | Canonical field | Note |
|---|---|---|
| `resource.id` (created), `resource.workItemId` else `revision.id` (updated) | `fingerprint` (`ado-<id>`), `labels.alertname` (`ado-<id>`) | |
| `Microsoft.VSTS.Common.Severity`, else `Microsoft.VSTS.Common.Priority` (first character) | `labels.severity` | `1` → `critical`; `2` → `error`; `3` → `warning`; `4` → `info`; else `warning`. |
| `System.AreaPath` (last segment) | `labels.area_path` | Select `area_path` under **Map this source** to make it the service. |
| a `service:<name>` tag, else a `Service: <name>` line in `System.Description` or *Repro Steps* (a Bug's body) | `labels.service` | No service field of its own; without either, map `area_path`, use `label_map` or the generic endpoint. |
| `Microsoft.VSTS.Common.Severity`, `Microsoft.VSTS.Common.Priority`, `System.AssignedTo`; a `notify:<team>` tag or a `Field: value` line in the description or *Repro Steps* | `annotations.ticket_fields` | Which required fields the work item names (the names only). |
| `System.Title`, `System.Description` (2,000 chars) | `annotations.summary`, `annotations.message` | |
| work item id, `_links.html.href` (updated: the resource's, else the revision's) | `annotations.ticket_ref`, `ticket_url`, `ticket_api_id` | |
| tag `aiops` plus a `#<8 hex>` title mark | `annotations.aiops_mark` | The platform's own ticket echo. |
| `System.CreatedDate` | `starts_at` | |
| `System.State` | `status` | `Done`, `Closed`, `Resolved` or `Removed` → `resolved`; anything else `firing`. |

## Change sources {#change-sources}

A change never opens an incident. These sources write rows to the change ledger — the answer to *what changed last on this service*, ranked on the incident's Probable cause card and flagged when it falls in a freeze window — and answer `{"recorded": N, "flagged_unauthorized": M}`. Rows dedupe per source and id, so a redelivered hook is harmless.

| Source | Events read | Row |
|---|---|---|
| `github`, `gitea` | push (`ref` + `commits`) and a merged pull request (`pull_request.merged` true) | `push` / `pr_merged`, repo `repository.full_name` |
| `gitlab` | Push Hook and a Merge request Hook with `action: merge` or `state: merged` | `push` / `pr_merged`, repo `project.path_with_namespace` |
| `azure-devops` | `git.push`, `git.pullrequest.merged` | `push` / `pr_merged`, repo `<project>/<repository>` |
| `argocd` | argocd-notifications body `{app, revision, status, url, at}` or the Application object | `sync` keyed `argocd:<app>@<revision>` (`:drift` suffix when `OutOfSync`), service = the Application name |
| `kubernetes-audit` | the kube-apiserver audit `EventList`; completed write verbs by non-`system:` users; a `401`/`403` response is a denied attempt | one row per write, service = the cluster named in the URL |
| `servicenow` | records with `sys_class_name` `change_request…` or a `CHG…` number | `ticket_change` keyed `snow:<sys_id>`, service = `cmdb_ci` |
| `jira` | issues whose type name contains `change` | `ticket_change` keyed `jira:<key>`, service = `customfield_service` |

A `servicenow` or `jira` change ticket accounts for a captured write (unauthorized-change detection) only when it arrives with the workspace's ITSM webhook token instead of the shared one; see [Changes](/help/changes-reference.md#unauthorized).

`kubernetes-audit` has its own path form, `/webhook/kubernetes-audit/<workspace-slug>/<token>/<cluster-name>`, because the API server's webhook client drops the query string; with the token in a header, put `-` in the token segment. Its credential is that cluster's audit token (`POST /api/aiops/{workspace}/k8s-audit-token/<cluster-name>`, see [the audit webhook](/help/change-webhook.md#kubernetes-on-prem-the-kube-apiserver-audit-webhook)), never the webhook token: what it posts is recorded as the platform's own audit trail. The shared form (`/webhook/kubernetes-audit/<workspace-slug>`, with or without `?cluster=`) answers `404`.

Service resolution for every change row: the event's own `service`, else the workspace `change_service_map` `{"<repo glob>": "<service>"}`, else unmapped (recorded, listed under `''`); `change_resource_map` `{"<resource glob>": "<service>" | ["<service>", …]}` only **adds** linked services.

### Your own change feed {#your-own-change-feed}

`POST /webhook/change/<workspace-slug>[/<token>]` with `{"source": "<name>", "events": [...]}`; each event carries `kind` (`push`, `pr_merged`, `deploy`, `sync` or `config`) and any of `service`, `repo`, `ref`, `sha`, `id`, `author`, `title`, `url`, `at`, `detail`. An empty or missing `events` list is `400 payload must carry a non-empty 'events' list`. Events count against the rate limit like alerts.

`kind: "config"` is a setting changed inside an application: `service` names the application, `id` is the application's own audit-row id (deduped per source, service and id), `detail` is `{key, old?, new?, resource_id?, service?}`. Values are protected by the key's **name** only: under a key that looks secret (password, secret, token, key, pass, pwd, dsn, auth, cert, salt, bearer) only `key`, `resource_id` and `service` are kept and `old`/`new` read `[not stored]`; a URL's `scheme://user:password@host` password is replaced in every kept value. Never send a credential in a change payload.

## Test alerts and receipts {#test-alerts-and-receipts}

Because the public endpoint answers one generic `401`, the way to learn whether a sender reached the platform is a **validation session**: **Alert intake & tokens → Send a test alert** mints a correlation id, the sample payload carries it as the `aiops_validation` label with service `aiops-validation`, and the ingest stamps a receipt onto the session in the same transaction as the alert. Samples exist for `alertmanager`, `generic`, `datadog` and `dynatrace`; any other source is `422 no sample for '<source>' yet`. A session expires after 24 hours.

| `receipt_state` | Meaning |
|---|---|
| `none` | No receipt observed yet — the request never passed token authentication, or has not arrived. |
| `accepted` | Received; a new incident opened. |
| `accepted_not_actioned` | Received and its incident opened, but the alert was held at ingest (no run started). Not reached by the shipped samples, which carry a service and an alert name. |
| `correlated` | Received; joined an incident already open for the same service. |
| `deduplicated` | An identical alert was already recorded; the receipt points at its incident. |

The page also reports `attribution_state` — `service`, `namespace`, `alertname` or `fingerprint` — naming which label grouped the alert; anything but `service` means real alerts from that sender will not join the right incident.

## Errors {#errors}

| Status | Body `detail` | Cause and fix |
|---|---|---|
| `401` | `invalid webhook credentials` | Wrong token, wrong slug, disabled workspace, or a workspace that has never minted a token — the message is the same for all four. Compare the slug and token with the **Webhook ingest** card; a header always wins over a path segment. |
| `404` | `unknown webhook source '<x>' (supported: argocd, azure-devops, azure-monitor, cloudwatch-sns, datadog, defender, dynatrace, gitea, github, gitlab, grafana, jira, kubernetes-audit, robusta, securityhub, servicenow)` | A source name the shared route does not know. `alertmanager`, `generic` and `change` are separate routes, so they are missing from the list even though they exist. |
| `400` | `request body is not JSON` / `request body must be a JSON object` | The shared route parses the body itself (SNS delivers JSON as `text/plain`). A string placeholder left unquoted in a template is the usual cause. |
| `400` | `payload must carry a non-empty 'events' list` | The change webhook received no events. |
| `400` | `SubscribeURL is not an https sns.<region>.amazonaws.com URL` | An SNS confirmation whose URL is not a genuine SNS endpoint is never fetched. |
| `422` | FastAPI validation error | `alertmanager` and `generic` are typed routes: a body that is not a JSON object fails validation with the standard `422` body instead of the `400` above. |
| `413` | `request body is larger than the <n>-byte webhook limit` | The body is over `aiops_webhook_max_body_bytes` (platform default 1 MiB; Alertmanager, Grafana and the generic intake accept 8 MiB because they send a whole alert group per notification; `kubernetes-audit` accepts 16 MiB because the API server batches events). The size is checked before the body is parsed, from `Content-Length` or, for a chunked upload, from the bytes received. Split the payload or send fewer alerts per notification (Alertmanager: `max_alerts` on the `webhook_configs` entry, which leaves the rest of the group out). The platform administrator can raise the cap with `AIOPS_WEBHOOK_MAX_BODY_BYTES`. The sender does not retry a `413`. |
| `429` | `ingest rate limit exceeded — retry after the indicated delay or raise settings.aiops_ingest_rate_per_min for this tenant` | The workspace bucket is dry. The `Retry-After` header says when to resend the whole payload. |
| `200` with `"received": 0` | — | The adapter recognised none of its fields: wrong source path for the payload shape, or the payload lacks the one required field (`AlarmName`, `data.essentials`, a Datadog title or id, a Dynatrace `PID`/`ProblemID`/title, a ticket number). |

A `413` can also come from an ingress or proxy in front of the platform before the request reaches it: ingress-nginx refuses bodies over its `proxy-body-size` (1 MiB by default).


## Next steps

- Next: [Settings reference](/help/settings-reference.md)
- [All Reference pages](/help/reference/llms.txt)
- [Documentation home](/help/)
