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

# Worked analytics queries

> KPI summary with filters, cursor-paged breakdown rows, prebuilt scheduled-report reads, the zeroed-period shape, and validation/role errors.

## Worked analytics queries

Every operation below is documented on this page with its parameters, but the
bodies you branch on — the KPI envelope, the breakdown rows, the totals line —
are easiest to learn as one reader chain: **pick a metric/summary endpoint →
filter by period and channel → page through the breakdown rows → read the
totals row.** The samples below walk that chain end to end and show the full
response envelope at each step.

Scope of this overlay:

* Per the language note above, these samples show cURL and TypeScript — the
  two most-requested languages. The other four tabs appear on the endpoint
  blocks themselves.
* The full endpoint catalogue with every filter combination lives at the
  [stats API guide](/guides/stats-api-guide); recurring email delivery of the
  same data (schedule, recipients, cadence) is covered in
  [scheduled reports](/guides/scheduled-reports). This overlay stays
  envelope-accurate: request, response, and the errors that decide what you
  branch on — nothing else.

All analytics reads are tenant-scoped and read-only. They degrade to a zeroed
summary or an empty rows array — still a `200` — during a transient storage
blip, so a poll loop or an always-on dashboard never flashes a `5xx`; design
your polling against that contract.

### 1. Pull a KPI summary

`GET /api/v1/analytics/messages` returns the headline KPIs for a period —
sent, delivered, failed, read, the derived rates, and the average delivery
time — plus a bucketed time series for charting. Filter the window with
`start_date` / `end_date` (or a `days` lookback) and narrow to one channel
with `channel`.

<CodeGroup>
  ```bash cURL theme={null}
  curl -G "https://api.orbit.devotel.io/api/v1/analytics/messages" \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    --data-urlencode "start_date=2026-08-01" \
    --data-urlencode "end_date=2026-08-02" \
    --data-urlencode "channel=whatsapp" \
    --data-urlencode "group_by=day" \
    --data-urlencode "status=delivered"
  ```

  ```typescript Node.js theme={null}
  const url = new URL("https://api.orbit.devotel.io/api/v1/analytics/messages");
  url.searchParams.set("start_date", "2026-08-01");
  url.searchParams.set("end_date", "2026-08-02");
  url.searchParams.set("channel", "whatsapp");
  url.searchParams.set("group_by", "day");
  url.searchParams.set("status", "delivered");

  const res = await fetch(url, {
    headers: { "X-API-Key": process.env.ORBIT_API_KEY! },
  });
  const body = await res.json();
  // Chart `data.time_series`; show the totals row from `data.totals`.
  console.log(body.data.totals.delivery_rate);
  ```
</CodeGroup>

```json 200 theme={null}
{
  "data": {
    "totals": {
      "total_sent": 17150,
      "total_delivered": 16899,
      "total_failed": 251,
      "total_read": 12042,
      "delivery_rate": 98.54,
      "read_rate": 71.26,
      "avg_delivery_time_ms": 1840
    },
    "time_series": [
      {
        "period": "2026-08-01T00:00:00.000Z",
        "period_date": "2026-08-01",
        "total_sent": 2450,
        "total_delivered": 2416,
        "total_failed": 34,
        "total_read": 1720,
        "delivery_rate": 98.61,
        "read_rate": 71.19,
        "avg_delivery_time_ms": 1795
      }
    ],
    "group_by": "day"
  },
  "meta": {
    "request_id": "req_9f2c1a7b",
    "timestamp": "2026-08-02T12:00:00.000Z"
  }
}
```

Adopt the same envelope shape everywhere on this page: all counters live
under `data.totals`, chartable buckets under `data.time_series` (or a named
breakdown array), and the echoed granularity under `data.group_by`. The
cost counterpart — `GET /api/v1/analytics/costs` — answers with the same
`totals` + `time_series` envelope plus a `by_channel` breakdown.

### 2. Page a breakdown series

Breakdown endpoints return the counters you just read, re-sliced per
dimension — channel, destination country, or error code behind failures.
Page through the list endpoints that carry a cursor (for example goals):
keep the same filters, pass the previous page's `nextCursor` back as
`cursor`, and stop when it comes back `null`.

`GET /api/v1/analytics/goals`

<CodeGroup>
  ```bash cURL theme={null}
  curl -G "https://api.orbit.devotel.io/api/v1/analytics/goals/" \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    --data-urlencode "limit=2"
  ```

  ```typescript Node.js theme={null}
  const all = [];
  let cursor: string | undefined;
  do {
    const url = new URL("https://api.orbit.devotel.io/api/v1/analytics/goals/");
    url.searchParams.set("limit", "2");
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, {
      headers: { "X-API-Key": process.env.ORBIT_API_KEY! },
    });
    const body = await res.json();
    all.push(...body.data.items);
    cursor = body.data.nextCursor ?? undefined;
  } while (cursor);
  ```
</CodeGroup>

Page 1:

```json 200 theme={null}
{
  "data": {
    "items": [
      {
        "id": "goal_7Fb2c1a7b",
        "name": "Checkout completed",
        "type": "pixel_fire",
        "enabled": true,
        "total_conversions": 312,
        "total_revenue_cents": 1559688
      },
      {
        "id": "goal_4Dc8e2b91",
        "name": "Demo booked",
        "type": "webhook_hit",
        "enabled": true,
        "total_conversions": 57,
        "total_revenue_cents": 0
      }
    ],
    "total": 3,
    "nextCursor": "cur_8Kw2mQ1z"
  },
  "meta": {
    "request_id": "req_9f2c1a7c",
    "timestamp": "2026-08-02T12:00:10.000Z"
  }
}
```

Page 2 — pass `cursor=cur_8Kw2mQ1z`; when this page's `nextCursor` is `null`
you have the full set:

```json 200 theme={null}
{
  "data": {
    "items": [
      {
        "id": "goal_9Ae1f03cc",
        "name": "Upgrade started",
        "type": "manual",
        "enabled": false,
        "total_conversions": 12,
        "total_revenue_cents": 12000
      }
    ],
    "total": 3,
    "nextCursor": null
  },
  "meta": {
    "request_id": "req_9f2c1a7d",
    "timestamp": "2026-08-02T12:00:11.000Z"
  }
}
```

The fixed-shape breakdowns — `GET /api/v1/analytics/messages/by-channel`,
`/by-country`, and `/messages/errors` — are single-response reads with no
cursor; each returns its full ranked array (`channels`, `countries`,
`errors`) so you render them directly.

### 3. Prebuilt report read (and the empty-period shape)

`GET /api/v1/analytics/scheduled-reports` lists the recurring reports
already scheduled for your organization — the prebuilt views the scheduler
emails on a cadence — with each report's type, frequency, recipients, and
its last- and next-send timestamps.

<CodeGroup>
  ```bash cURL theme={null}
  curl -G "https://api.orbit.devotel.io/api/v1/analytics/scheduled-reports" \
    -H "X-API-Key: dv_live_sk_your_key_here" \
    --data-urlencode "limit=50"
  ```

  ```typescript Node.js theme={null}
  const url = new URL(
    "https://api.orbit.devotel.io/api/v1/analytics/scheduled-reports",
  );
  url.searchParams.set("limit", "50");
  const res = await fetch(url, {
    headers: { "X-API-Key": process.env.ORBIT_API_KEY! },
  });
  console.log(await res.json());
  ```
</CodeGroup>

```json 200 theme={null}
{
  "data": {
    "items": [
      {
        "id": "rpt_7c1f0a",
        "name": "Weekly deliverability",
        "type": "deliverability",
        "frequency": "weekly",
        "recipients": ["ops@acme.com"],
        "enabled": true,
        "timezone": "America/New_York",
        "last_sent_at": "2026-07-26T13:00:00.000Z",
        "next_send_at": "2026-08-02T13:00:00.000Z"
      }
    ],
    "total": 1,
    "nextCursor": null
  },
  "meta": {
    "request_id": "req_9f2c1a7e",
    "timestamp": "2026-08-02T12:00:12.000Z"
  }
}
```

**The empty period is a zeroed summary, not an error.** Choose a window with
no traffic (or hit the degraded path during a storage blip) and the KPI
endpoint still returns `200` with zeroed totals and an empty series — test
your client against this shape so a quiet period never parses as a failure:

```json 200 theme={null}
{
  "data": {
    "totals": {
      "total_sent": 0,
      "total_delivered": 0,
      "total_failed": 0,
      "total_read": 0,
      "delivery_rate": 0,
      "read_rate": 0,
      "avg_delivery_time_ms": 0
    },
    "time_series": [],
    "group_by": "day"
  },
  "meta": {
    "request_id": "req_9f2c1a7f",
    "timestamp": "2026-08-02T12:00:13.000Z"
  }
}
```

### 4. Errors

Two branches cover the mistakes callers actually hit on this surface.

**Validation — unrecognized filter value.** Query params are validated
before any query runs. A failed validation returns `422 VALIDATION_ERROR`
with `error.details.issues` listing each rejected field — for example a
`group_by` outside the allowed set (`hour` / `day` / `week` / `month`):

```json 422 theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "status": 422,
    "message": "Invalid query parameters",
    "details": {
      "issues": [
        {
          "field": "group_by",
          "message": "Invalid enum value. Expected 'hour' | 'day' | 'week' | 'month', received 'minute'"
        }
      ]
    }
  },
  "meta": {
    "request_id": "req_9f2c1a80",
    "timestamp": "2026-08-02T12:00:14.000Z"
  }
}
```

**Role — reads are gated to specific roles.** The analytics reads on this
page require an org role of owner, admin, developer, or viewer (writes such
as creating goals or scheduled reports narrow to owner, admin, and
developer); a key presented without one of those roles is rejected with
`403 INSUFFICIENT_PERMISSIONS` and a message naming the accepted roles:

```json 403 theme={null}
{
  "error": {
    "code": "INSUFFICIENT_PERMISSIONS",
    "status": 403,
    "message": "This action requires one of: owner, admin, developer, viewer"
  },
  "meta": {
    "request_id": "req_9f2c1a81",
    "timestamp": "2026-08-02T12:00:15.000Z"
  }
}
```

Treat both as terminal, not retriable: the `422` needs a corrected filter
before you re-send, and a `403` will not succeed until the role changes.
