Skip to content

Insights: operational anomaly detection ​

Español: Insights: detección de anomalías operativas.

This guide covers the anomaly evidence the platform records on its own and the three API routes that read and acknowledge it. It describes the API only; it does not imply that a dashboard screen exists.

Availability ​

CapabilityAvailability
GET /v1/insights/anomalies, GET /v1/insights/series, POST /v1/insights/anomalies/{id}/ackPublished in core v0.101.0 (core#1983; ADR #243)
The insights_anomaly_k sensitivity keyPublished in core v0.101.0 (core#1983; ADR #243)

The three routes and their schemas are included in the vendored OpenAPI document and generated in the API Reference.

What it detects ​

The evaluator runs every 15 minutes for every tenant. It is always on: there is no activation key, it only reads and writes evidence, and it never takes part in a dialing decision.

It watches five metrics, in hourly buckets, for each campaign and for the whole tenant:

metricHarmful directionMeaning
contact_ratedownPerson-answered calls over dials placed
abandon_rateupAbandoned calls over person-answered calls
cost_per_contactupOutbound-minute cost (USD) over person-answered calls
gate_block_rateupCompliance-gate refusals over refusals plus dials placed; one series per gate
media_timeout_rateupAnswered legs closed with MEDIA_TIMEOUT over answered calls

The rule is zscore.v1. For each hourly bucket it takes the previous 7 days as the baseline (the current bucket excluded) and computes the mean and standard deviation. The threshold is mean ± k · max(stddev, floor), and only the harmful side of each metric can open an anomaly. The floor is 0.01, except for cost_per_contact, where it is the larger of 5 % of the mean and 0.01. It keeps a nearly constant series from alerting on noise.

Sample floors ​

A bucket is not evaluated unless it has enough data:

  • At least 30 in the bucket's denominator (sample_n): dials placed for contact_rate, person-answered calls for abandon_rate and cost_per_contact, answered calls for media_timeout_rate, and refusals plus dials placed for gate_block_rate.
  • At least 24 baseline buckets behind it (baseline_n).

Below either floor no row is written. GET /v1/insights/series reports the floors as min_sample and min_baseline and returns band: null while the baseline is short.

opened and resolved ​

Evidence is append-only. An anomaly is its opened row, carrying the observed value, the baseline, the threshold, k, the sample sizes and the hourly series. It is resolved when a later resolved row cites it, which the evaluator writes when the metric is back inside the threshold. In the API this is state: "open" or state: "resolved", and resolution holds the closing row (null while open). Rows are never updated or deleted.

Sensitivity: insights_anomaly_k ​

k is a per-tenant key, written by a platform operator, not through the tenant API. It is an integer in tenths: the default is 30 (k = 3.0) and the accepted range is 30 to 200 (3.0 to 20.0). A value outside the range is refused at write time.

Raising it only silences a noisy tenant; it never enables anything, because the evaluator has no on/off key. Each anomaly records the k in force when it was opened.

Authentication and permissions ​

All routes are tenant-scoped. Send a dd_ API key or a dashboard login token:

http
Authorization: Bearer dd_example-not-a-real-key
Operationdd_ keyDashboard login (ddu_)
List anomalies, read a seriesYes; read is nullYes; read reflects your acknowledgement
unread=true400 (a key has no inbox)Yes
Acknowledge403Yes

Acknowledgement is per person: each user acknowledges for themselves, and one user's acknowledgement does not mark the anomaly read for anyone else.

List anomalies ​

http
GET /v1/insights/anomalies?state=open&metric=abandon_rate&limit=50

Filters: campaign_id, metric, state (open or resolved), unread (login only), limit (default 50, maximum 200) and cursor. Results are newest first; pass the previous next_cursor to page, and it is null on the last page.

json
{
  "anomalies": [
    {
      "id": "6f1c1f0e-8f4e-4c3e-9a61-0d7c1b6f2a10",
      "campaign_id": null,
      "metric": "abandon_rate",
      "gate": null,
      "rule": "zscore.v1",
      "direction": "up",
      "state": "open",
      "window_start": "2026-10-09T14:00:00Z",
      "window_end": "2026-10-09T15:00:00Z",
      "observed": 0.09,
      "baseline_mean": 0.02,
      "baseline_stddev": 0.01,
      "stddev_floor": 0.01,
      "threshold": 0.05,
      "k": 3.0,
      "sample_n": 412,
      "baseline_n": 160,
      "series": [],
      "opened_at": "2026-10-09T15:07:00Z",
      "resolution": null,
      "read": null,
      "acknowledged_at": null
    }
  ],
  "next_cursor": null
}

The values are illustrative; series is abbreviated here and, in a real response, lists the baseline buckets followed by the bucket that crossed, oldest first.

Read a series ​

http
GET /v1/insights/series?metric=contact_rate&from=2026-10-08T00:00:00Z&to=2026-10-09T00:00:00Z

metric is required. campaign_id narrows to one campaign; without it the series is the whole tenant. gate is required for gate_block_rate and refused for every other metric. from and to round down to the hour; to defaults to now, from to 48 hours before it, and the window may not exceed 14 days.

Each bucket carries its band (mean, stddev, stddev_floor, lower, upper, baseline_n), computed with the same arithmetic as the evaluator, so a chart drawn from it cannot disagree with the published threshold.

json
{
  "rule": "zscore.v1",
  "metric": "contact_rate",
  "campaign_id": null,
  "gate": null,
  "direction": "down",
  "k": 3.0,
  "min_sample": 30,
  "min_baseline": 24,
  "from": "2026-10-08T00:00:00Z",
  "to": "2026-10-09T00:00:00Z",
  "buckets": [
    {
      "start": "2026-10-08T00:00:00Z",
      "sample": 380,
      "numerator": 95,
      "denominator": 380,
      "value": 0.25,
      "band": {
        "mean": 0.24,
        "stddev": 0.02,
        "stddev_floor": 0.01,
        "lower": 0.18,
        "upper": 0.3,
        "baseline_n": 168
      }
    }
  ]
}

Acknowledge an anomaly ​

http
POST /v1/insights/anomalies/6f1c1f0e-8f4e-4c3e-9a61-0d7c1b6f2a10/ack
Authorization: Bearer ddu_example-not-a-real-login

The call has no body. It needs a dashboard login: a dd_ key receives 403. An id that is not an anomaly of your tenant (another tenant's, a resolved row, an invented id) receives 404. Acknowledging is idempotent: a second call answers 200 with the acknowledgement that already exists.

json
{
  "ack": {
    "anomaly_id": "6f1c1f0e-8f4e-4c3e-9a61-0d7c1b6f2a10",
    "user_id": "0b2a9f6e-2b53-4a7c-8f43-1c2d3e4f5a6b",
    "acknowledged_at": "2026-10-09T15:20:00Z"
  }
}

Not covered here ​

The anomaly.detected webhook event is not part of these routes; this guide does not describe one.

Nothing in these docs is legal advice — always confirm compliance posture with your own counsel.