Appearance
Insights: operational anomaly detection
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
| Capability | Availability |
|---|---|
GET /v1/insights/anomalies, GET /v1/insights/series, POST /v1/insights/anomalies/{id}/ack | Published in core v0.101.0 (core#1983; ADR #243) |
The insights_anomaly_k sensitivity key | Published 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:
metric | Harmful direction | Meaning |
|---|---|---|
contact_rate | down | Person-answered calls over dials placed |
abandon_rate | up | Abandoned calls over person-answered calls |
cost_per_contact | up | Outbound-minute cost (USD) over person-answered calls |
gate_block_rate | up | Compliance-gate refusals over refusals plus dials placed; one series per gate |
media_timeout_rate | up | Answered 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 forcontact_rate, person-answered calls forabandon_rateandcost_per_contact, answered calls formedia_timeout_rate, and refusals plus dials placed forgate_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| Operation | dd_ key | Dashboard login (ddu_) |
|---|---|---|
| List anomalies, read a series | Yes; read is null | Yes; read reflects your acknowledgement |
unread=true | 400 (a key has no inbox) | Yes |
| Acknowledge | 403 | Yes |
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=50Filters: 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:00Zmetric 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-loginThe 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.