> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# KPI alerts on business metrics

> Define self-serve threshold and anomaly alert rules over CSAT, NPS, CES, LLM spend, and AI containment — evaluated on a schedule, fired into your dashboard notifications with a deep link to the metric.

# KPI alerts on business metrics

Define org-scoped alert rules over the cross-pillar KPIs your ops team owns: **CSAT**, **NPS**, **CES**, **LLM spend**, and **AI containment**. When the metric breaks your threshold — or deviates sharply from its own learned baseline — the rule fires an in-app notification with a deep link straight to the metric page.

This is the business-KPI counterpart to [Usage & delivery anomaly alert rules](/guides/usage-anomaly-alert-rules), which covers SMS delivery rate, outbound volume, and spend. Use both: usage alerts watch the transport; KPI alerts watch the customer experience and AI cost.

Rules are **read-only** over your survey and agent-conversation data. The only writes are alert-rule state and the notification it fires — a rule never sends outbound traffic.

## When business-KPI alerting matters

* **CSAT / NPS / CES drops** — satisfaction moves silently; by the time someone reads the survey report, a broken flow or a bad agent has eroded scores for days. A threshold rule on the trailing window catches the slide the same day.
* **LLM-spend ceilings** — AI agent costs scale with usage, and a retry loop or a runaway prompt can multiply spend in hours. Cap the trailing total and act before the budget conversation.
* **AI containment dropoffs** — when containment slips, humans pick up conversations the agent used to resolve. That's staffing pressure you can see in this number before you feel it in the queue.

For every metric, decide between two trigger styles: a fixed threshold ("tell me when CSAT falls below 80%") or an anomaly detector ("tell me when CSAT dives relative to its own recent norm"). The rest of this page explains how each evaluates.

## The two alert types

### Threshold mode (default for any rule)

Aggregates the metric over a trailing window of `window_days` (1–90, default 7) and compares it to your fixed threshold with your comparator:

* `lt` / `lte` — "must stay above X" (e.g. CSAT below 80%, containment below 70%).
* `gt` / `gte` — "must stay below X" (e.g. LLM spend above \$250/day window).

NPS is evaluated in points, CSAT and containment in percent, CES on its score scale, and LLM spend in USD — thresholds are entered on the metric's native unit.

### Anomaly mode

Compares the newest complete UTC day against a learned 28-day baseline using a z-score classifier. You set no threshold: "unusual for your account" is the trigger, just like the usage-side anomaly rules. For CSAT/NPS/CES/containment it flags a **drop**; for LLM spend, a **spike**. Days with no data are excluded from the baseline rather than zero-filled, and a data-less current day reports `insufficient_data` instead of firing a false alarm.

Pick **threshold** when there's a contractual or budgeted number (SLA CSAT, spend cap). Pick **anomaly** when you don't know the right number yet and want the metric to tell you when it moves unusually.

## Create a rule in the dashboard

1. Open **Insights → Alerts** in the dashboard (or go directly to `/insights/alerts`).
2. Click **Create rule**.
3. Name the rule (e.g. "Weekly CSAT below 80%").
4. Pick the **Metric** — CSAT, NPS, CES, LLM spend, or AI containment.
5. Pick the **Trigger** — Threshold or Anomaly.
6. For threshold rules, set the **Condition** comparator and the numeric **Threshold**. Anomaly rules skip this — the baseline replaces it.
7. Set the **Window (days)** the threshold aggregates over (anomaly rules always compare daily against the 28-day baseline; the field is carried but the evaluation is daily).
8. Toggle **Enabled** and save.

The rules table shows each rule's current value, last status, and an Enabled toggle; the **Evaluate now** button runs the same evaluation the scheduler runs, on demand. Below the table, the **Fired alerts** feed lists the last 50 notifications with a **View metric** link into the breached surface.

## Endpoints

All endpoints are tenant-scoped under the analytics surface, use the standard `{ "data": ..., "meta": ... }` envelope, and persist rules on your organization's settings.

| Method   | Path                                  | Purpose                                                                                                  |
| -------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `GET`    | `/analytics/kpi-alert-rules`          | List your rules with evaluator state (`last_status`, `last_value`, `last_evaluated_at`, `last_fired_at`) |
| `POST`   | `/analytics/kpi-alert-rules`          | Create a rule (returns `201`)                                                                            |
| `PATCH`  | `/analytics/kpi-alert-rules/{id}`     | Update name, comparator, threshold, window, cooldown, or `enabled`                                       |
| `DELETE` | `/analytics/kpi-alert-rules/{id}`     | Delete a rule                                                                                            |
| `GET`    | `/analytics/kpi-alert-rules/events`   | Fired-alert feed, newest first, capped at the last 50 events                                             |
| `POST`   | `/analytics/kpi-alert-rules/evaluate` | Evaluate all of your tenant's rules now                                                                  |

A tenant can hold up to 50 rules. A scheduler sweep also evaluates every opted-in tenant continuously, so rules fire without you calling evaluate.

## Auth requirements

* **Reads** (`GET ...`, `GET .../events`) — a valid API key/JWT whose scopes include `analytics:read`.
* **Writes and evaluate-now** (`POST`, `PATCH`, `DELETE`, `POST .../evaluate`) — additionally require an org role of `owner`, `admin`, or `developer`. Create, update, and delete are recorded in the audit log with the acting user.

## Metric reference

| `metric`         | Label          | Unit                  | Direction the rule guards | Deep link               |
| ---------------- | -------------- | --------------------- | ------------------------- | ----------------------- |
| `csat`           | CSAT           | percent (e.g. `82.4`) | Guards a **drop**         | `/insights/surveys`     |
| `nps`            | NPS            | points (e.g. `35.0`)  | Guards a **drop**         | `/insights/surveys`     |
| `ces`            | CES            | score (e.g. `5.8`)    | Guards a **drop**         | `/insights/surveys`     |
| `llm_spend`      | LLM spend      | USD                   | Guards a **spike**        | `/insights/llm-spend`   |
| `ai_containment` | AI containment | percent               | Guards a **drop**         | `/insights/containment` |

Satisfaction metrics are computed from your survey responses with the same scoring the Surveys dashboard uses (NPS promoters minus detractors, CSAT/CES response averages); LLM spend and containment aggregate your agent conversations the same way the LLM-spend and Containment pages do — so an alert fires on exactly the numbers the dashboard shows.

## Field reference (create / update body)

| Field             | Type    | Constraints                   | Default                    |
| ----------------- | ------- | ----------------------------- | -------------------------- |
| `name`            | string  | 1–160 chars                   | required                   |
| `metric`          | enum    | one of the five metrics above | required                   |
| `mode`            | enum    | `threshold` or `anomaly`      | `threshold`                |
| `comparator`      | enum    | `gt`, `gte`, `lt`, `lte`      | required in threshold mode |
| `threshold`       | number  | on the metric's native unit   | required in threshold mode |
| `window_days`     | integer | 1–90                          | 7                          |
| `notify_channels` | array   | subset of `["in_app"]`        | `["in_app"]`               |
| `cooldown_hours`  | integer | 1–720                         | 24                         |
| `enabled`         | boolean |                               | `true`                     |

<Warning>
  **`notify_channels` currently accepts only `in_app`.** Alerts surface through the dashboard notification channel (the bell / Notification Center). To integrate elsewhere, poll `GET /analytics/kpi-alert-rules/events`.
</Warning>

## Notification lifecycle

Every evaluation tick updates each enabled rule's runtime fields (`last_evaluated_at`, `last_status`, `last_value`) — even in a breach. A fired-alert event is appended (feed capped at 50, newest first) and a notification pushed **only when**:

* the rule transitions **into** `breached`, or
* it stays in breach and the `cooldown_hours` re-notify window (default 24h) has elapsed.

The notification lands in the bell dropdown and the Notification Center with severity **warning**, titled `KPI alert: <your rule name>`, the breach sentence as the body, and a deep link to the metric surface (`/insights/surveys`, `/insights/llm-spend`, or `/insights/containment`). See [In-dashboard notifications and the Notification Center](/guides/in-dashboard-notifications) for triage semantics.

### Event payload

| Field                             | Meaning                                                                              |
| --------------------------------- | ------------------------------------------------------------------------------------ |
| `id`                              | Unique event id                                                                      |
| `rule_id`, `rule_name`            | Which rule fired                                                                     |
| `metric`                          | The rule's metric                                                                    |
| `value`                           | Metric value at fire time (`null` on insufficient data)                              |
| `threshold`, `comparator`, `mode` | The rule's configured trigger at fire time                                           |
| `message`                         | Human-readable sentence ("CSAT is 74.2%, below the 80% threshold over the last 7d.") |
| `deep_link`                       | Dashboard path to the metric surface                                                 |
| `fired_at`                        | ISO-8601 timestamp                                                                   |

## Worked examples

### CSAT threshold — guard a customer-experience floor

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/analytics/kpi-alert-rules \
  -H "Authorization: Bearer dv_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Weekly CSAT below 80%",
    "metric": "csat",
    "mode": "threshold",
    "comparator": "lt",
    "threshold": 80,
    "window_days": 7
  }'
```

```json theme={null}
{
  "data": {
    "id": "6f1c…",
    "metric": "csat",
    "metric_label": "CSAT",
    "unit": "percent",
    "mode": "threshold",
    "comparator": "lt",
    "threshold": 80,
    "window_days": 7,
    "enabled": true,
    "last_status": null
  },
  "meta": { … }
}
```

### NPS anomaly — catch a sudden promoter slide

No comparator or threshold: the rule learns the account's own baseline.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/analytics/kpi-alert-rules \
  -H "Authorization: Bearer dv_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "NPS anomaly",
    "metric": "nps",
    "mode": "anomaly"
  }'
```

### LLM-spend cap — guard the AI budget

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/analytics/kpi-alert-rules \
  -H "Authorization: Bearer dv_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "LLM spend over $100 in 24h",
    "metric": "llm_spend",
    "mode": "threshold",
    "comparator": "gt",
    "threshold": 100,
    "window_days": 1,
    "cooldown_hours": 12
  }'
```

### Containment anomaly — catch a handoff regression

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/analytics/kpi-alert-rules \
  -H "Authorization: Bearer dv_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "AI containment anomaly",
    "metric": "ai_containment",
    "mode": "anomaly"
  }'
```

### Update, evaluate, and read the feed

Update any field without recreating the rule:

```bash theme={null}
curl -X PATCH https://api.orbit.devotel.io/api/v1/analytics/kpi-alert-rules/6f1c… \
  -H "Authorization: Bearer dv_live_sk_…" \
  -H "Content-Type: application/json" \
  -d '{ "threshold": 85, "window_days": 14 }'
```

Evaluate now (optional — the scheduler evaluates on its own cadence):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/analytics/kpi-alert-rules/evaluate \
  -H "Authorization: Bearer dv_live_sk_…"
```

```json theme={null}
{ "data": { "tenantId": "acme", "rulesEvaluated": 4, "breached": 1, "fired": 1 }, "meta": { … } }
```

Read the fired feed:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/analytics/kpi-alert-rules/events \
  -H "Authorization: Bearer dv_live_sk_…"
```

```json theme={null}
{
  "data": {
    "items": [
      {
        "id": "evt_…",
        "rule_name": "Weekly CSAT below 80%",
        "metric": "csat",
        "status": "breached",
        "value": 74.2,
        "threshold": 80,
        "comparator": "lt",
        "mode": "threshold",
        "message": "CSAT is 74.2%, below the 80% threshold over the last 7d.",
        "deep_link": "/insights/surveys",
        "fired_at": "2026-08-31T12:00:00.000Z"
      }
    ],
    "total": 1
  },
  "meta": { … }
}
```

## Troubleshooting

**`422` says "comparator is required for a threshold rule".**
Threshold rules must carry both a `comparator` and a finite `threshold` at create time, and updates can't leave a threshold rule without either. Conversely, anomaly rules must **not** be modeled with a comparator — omit `comparator`/`threshold` entirely when `mode: "anomaly"`; the rule derives its trigger from the learned baseline. This mirrors the convention in [Usage & delivery anomaly alert rules](/guides/usage-anomaly-alert-rules): threshold mode requires comparator/threshold, anomaly mode rejects them.

**A rule's `last_status` reads `insufficient_data`.**
The metric could not be resolved in the window — usually zero survey responses (or zero agent conversations, for LLM spend / containment) in the window. For anomaly rules, fewer than five baseline days with data also reports `insufficient_data`. Evaluation reports no value rather than firing on a fabricated number, so give a new tenant some traffic before expecting anomaly coverage.

**The rule fires fewer notifications than expected.**
Cooldown semantics: while a rule stays in breach, it re-notifies only once per `cooldown_hours` (default 24h). New breach transitions always notify immediately.

**You want email or webhook instead of the dashboard notification.**
The only configured channel today is the dashboard notification (`notify_channels: ["in_app"]`). Poll the fired-event feed to integrate alerts into your own systems.

**The anomaly rule fires on a metric you consider normal.**
The detector flags deviation from your own recent baseline, not an absolute range. If a new baseline is genuinely your normal, the rule goes quiet once the 28-day window absorbs the shift; if you actually want a fixed boundary, switch to threshold mode.
