> ## 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.

# Anomaly examples — worked API samples over the anomaly-insight ledger

> Copy-pasteable request/response pairs for the anomaly-insight ledger — the per-scope totals, the day-bucketed trendline, and the row-level history — plus a Node orbit.request snippet that turns each call into an ops decision pass.

# Anomaly examples — worked API samples over the anomaly-insight ledger

The anomaly-insight ledger is the platform's append-only record of what its
own detectors have raised against your account traffic — spend spikes,
toll-fraud blocks, SIM-swap flags, webhook-delivery anomalies. This page is
the API-samples counterpart of
[Anomaly detection — reading usage and delivery anomalies](/guides/session-anomaly-alerts-reading),
which walks the same data through the dashboard. Everything below is real:
the three endpoints, the exact envelope shape, and the query parameters are
the ones the platform serves today.

All three endpoints are tenant-scoped GETs under `/api/v1/insights` and
return the standard Orbit envelope: `data` carries the payload, `meta`
carries the request id. Reads are idempotent and safe to poll; there is no
object to create and no decision to post from your side — triage
(acknowledge / dismiss / resolve) happens on the Insights → overview Fraud
alerts card, not over these endpoints.

## Sample 1 — list anomalies by scope (the issues listing)

The by-scope endpoint answers "is anything wrong right now?" with one
aggregate row per forensics scope (`wallet`, `webhook`). This is the
listing your poller should hit first, because it is the cheapest way to
decide whether a deeper page through `/history` is even needed.

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/insights/anomaly-insights/by-scope?days=7&scope=wallet" \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "window": { "from": "2026-08-29T00:00:00.000Z", "to": "2026-09-05T00:00:00.000Z", "days": 7 },
    "scopes": [
      { "scope": "wallet", "total": 6, "open_count": 2, "high_or_critical_count": 1, "latest_detected_at": "2026-09-04T03:14:00.000Z" },
      { "scope": "webhook", "total": 0, "open_count": 0, "high_or_critical_count": 0, "latest_detected_at": null },
      6
    ],
    "all_total": 6,
    "truncated": null
  },
  "meta": { "request_id": "req_9f4c1a2b3d4e5f607182" }
}
```

Read the envelope like this:

* **`scopes`** always carries `wallet` and `webhook` aggregate rows plus
  the all-scopes total, regardless of your `scope=` filter; the filter only
  narrows which rows fed the aggregate.
* **`open_count`** is the decision input — anything above zero means a
  detector raised something nobody has triaged yet.
* **`high_or_critical_count`** tells you the entry is a same-day item,
  not a queue-for-review one (severity ranks `low → medium → high →
  critical`).
* **`truncated`** is `null` on a healthy window. When a busy window runs
  into the bounded row scan, it carries `{ "row_scan_bound": N }` and you
  should page through `/history` instead of trusting the totals for that
  window — the platform surfaces the bound rather than silently
  undercounting.

The accepted parameters are the same on all three endpoints: `days`
(default 30, maximum 365), an explicit `from`/`to` ISO datetime pair (which
overrules `days`), and `scope=wallet|webhook`.

## Sample 2 — the trendline (decision pass over day buckets)

The timeseries endpoint is the "blip or pattern?" pass. Day buckets are
zero-filled across the window — a day with no detections returns `0`,
never a missing day — so a consumer can plot it without gap handling and a
poller can compare "today" against a clean baseline of zeros.

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/insights/anomaly-insights/timeseries?days=7&scope=wallet" \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "window": { "from": "2026-08-29T00:00:00.000Z", "to": "2026-09-05T00:00:00.000Z", "days": 7 },
    "scope": "wallet",
    "buckets": [
      { "day": "2026-08-29", "scope": "wallet", "total": 0 },
      { "day": "2026-08-30", "scope": "wallet", "total": 0 },
      { "day": "2026-08-31", "scope": "wallet", "total": 1 },
      { "day": "2026-09-01", "scope": "wallet", "total": 0 },
      { "day": "2026-09-02", "scope": "wallet", "total": 0 },
      { "day": "2026-09-03", "scope": "wallet", "total": 0 },
      { "day": "2026-09-04", "scope": "wallet", "total": 6 }
    ],
    "truncated": null
  },
  "meta": { "request_id": "req_7a2b4c6d8e0f1a2b3c4d" }
}
```

One tall day against a flat baseline is a burst (a campaign, an attack
wave); a rising staircase is a drift — misconfiguration or sustained abuse.
That distinction is the decision this sample exists to pass back upstream:
the by-scope call says *something is open*, and the trendline says *whether
it is new or sustained*.

## Sample 3 — the history rows (the full envelope split by webhook scope)

The history endpoint returns the raw ledger rows, newest first, paged at up
to 100 entries per request. Filter it to `scope=webhook` and you get the
inbound-side family — webhook-delivery and phone-number anomalies — which
is exactly the webhook shape your receiver-side tooling cares about:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/insights/anomaly-insights/history?days=7&scope=webhook&limit=10" \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "window": { "from": "2026-08-29T00:00:00.000Z", "to": "2026-09-05T00:00:00.000Z", "days": 7 },
    "scope": "webhook",
    "items": [
      {
        "id": "a2f7c1e94d0b43aa8e1c",
        "category": "webhook_delivery_failure_burst",
        "severity": "high",
        "status": "open",
        "scope": "webhook",
        "source": "webhook_delivery_guard",
        "summary": "Delivery failures spiked on one endpoint — 42 failures in 10 minutes.",
        "channel": null,
        "detected_at": "2026-09-04T18:40:00.000Z",
        "triaged_at": null,
        "resolved_at": null,
        "details": { "endpoint_id": "whk_01H…" }
      }
    ],
    "total": 1
  },
  "meta": { "request_id": "req_3d5e7f9a1b2c4d6e8f0a" }
}
```

Every row is scrub-on-read: `details` carries only tenant-safe hints (an
endpoint id, a destination mask, a reason code) — provider-internal
payloads never survive the read path. `channel` is `null` on webhook-scope
rows and carries the spend channel (`sms`, `whatsapp`, `email`, `rcs`,
`voice`, `agents`, or `all`) on wallet-scope rows. The row `status` moves
`open → acknowledged → resolved` as your team works it on the Insights →
overview card, so polling `/history` also tells you what is already being
handled. The full triage workflow is covered in
[Debug inbound carrier webhooks](/guides/inbound-webhook-debugging); this
page covers the anomaly ledger, not the per-webhook debug records, so
nothing here duplicates that guide.

### The consistent 200 / 422 envelope

All three endpoints share one envelope contract:

* **200** — `data` carries the payload shown above, `meta` carries the
  `request_id`. An empty window is a valid answer: empty `items`, zeroed
  buckets, zeroed `open_count` — an empty ledger is the healthy account
  state, not an error.
* **422** — the same standard Orbit validation envelope every endpoint
  returns on a malformed query (`days` above 365, a `scope` outside
  `wallet|webhook`, an unparseable `from`/`to`). Fix the parameter and
  re-send; nothing about the response shape changes.

## Sample 4 — Node SDK (orbit.request) — the trigger-or-pass decision call

The Node SDK exposes the platform-generic
[`orbit.request`](/sdk/node) method, which carries any of these GETs with
the retry, idempotency, and timeout posture the SDK applies to everything.
The snippet below is the pass this page actually ships: poll the by-scope
aggregate, pass on a quiet window, and only page through `/history` when
something is genuinely open.

```typescript theme={null}
import { Orbit } from "@devotel-orbit/node";

const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY! });

type ByScopeRow = {
  scope: "wallet" | "webhook";
  total: number;
  open_count: number;
  high_or_critical_count: number;
  latest_detected_at: string | null;
};

type ByScopeResponse = {
  data: {
    window: { from: string; to: string; days: number };
    scopes: ByScopeRow[] | number[];
    all_total: number;
    truncated: { row_scan_bound: number } | null;
  };
};

type HistoryResponse = {
  data: {
    items: Array<{
      id: string;
      category: string;
      severity: string;
      status: string;
      scope: "wallet" | "webhook";
      summary: string;
      detected_at: string;
    }>;
    total: number;
  };
};

async function anomalyDecisionPass(days = 7) {
  const byScope = await orbit.request<ByScopeResponse>(
    "GET",
    `/insights/anomaly-insights/by-scope?days=${days}&scope=wallet`,
  );

  if (byScope.data.truncated) {
    // Never trust aggregate totals on a truncated window — page /history.
    return pageHistory(days);
  }

  const wallet = byScope.data.scopes[0] as ByScopeRow;
  if (wallet.open_count === 0) {
    return { action: "pass", reason: "no open wallet anomalies" };
  }

  // Something is open — pull the rows and hand them to the on-call lane.
  const history = await orbit.request<HistoryResponse>(
    "GET",
    `/insights/anomaly-insights/history?days=${days}&scope=wallet&limit=50`,
  );
  return {
    action: "trigger",
    open: wallet.open_count,
    critical: wallet.high_or_critical_count,
    items: history.data.items,
  };
}

async function pageHistory(days: number) {
  const history = await orbit.request<HistoryResponse>(
    "GET",
    `/insights/anomaly-insights/history?days=${days}&scope=wallet&limit=100`,
  );
  return { action: "trigger-paged", items: history.data.items };
}
```

The pass deliberately reads `truncated` before trusting the totals — that
is the one failure shape this API can otherwise hide, and the guard above
is the correct handling for it. `orbit.request` applies the SDK's retry
posture on transient 5xxs, so a poller loop around `anomalyDecisionPass`
is safe to run on a fixed cadence.

## See also

* [Anomaly detection — reading usage and delivery anomalies](/guides/session-anomaly-alerts-reading) — the dashboard walkthrough of the same ledger.
* [Usage and delivery anomaly alert rules](/guides/usage-anomaly-alert-rules) — author your own threshold rules on top of the automatic detectors.
* [Debug inbound carrier webhooks](/guides/inbound-webhook-debugging) — the per-webhook debug records, which the webhook-scope anomalies reference.
* [Insights dashboards](/guides/insights-dashboards) — the hub for every Insights surface.
