> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# Analytics and reports

Jelliu analyzes every call and text conversation your agents handle: outcome, sentiment, frustration, response latency, talk share, cost, and the fields each agent is configured to extract. The analytics API exposes those numbers as aggregates you can pull into your own BI, CRM or reporting stack.

There are four surfaces, each for a different job:

| Surface              | Base path          | Use it for                                                                                                                           |
| -------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| **Dashboard bundle** | `/api/dashboard`   | One request with the workspace overview, agents, campaigns, today's call count and the agent leaderboard.                            |
| **Analytics**        | `/api/analytics/*` | Windowed KPIs and time series for calls and text conversations, per campaign or per agent.                                           |
| **Reports**          | `/api/reports`     | Computed reports over an explicit date range: ROI, campaign comparison, cost per call, agent performance, contact funnel.            |
| **Exports**          | `/api/exports`     | Asynchronous CSV, JSON or JSONL files of raw rows: calls, contacts, conversations, daily analytics, agent actions and the audit log. |

## How it works

```mermaid
flowchart LR
  A[Call or conversation ends] --> B[Post-call analysis]
  B --> C[(Calls and conversations<br />outcome, sentiment, telemetry,<br />criteria, extracted fields)]
  C --> D["/api/dashboard"]
  C --> E["/api/analytics/*"]
  C --> F["/api/reports"]
  C --> G["/api/exports job"]
  G --> H[Worker writes file]
  H --> I["/api/exports/:id/download"]
```

Analytics and reports are computed from the rows Jelliu stores for each call and conversation. Two consequences follow from that:

* **Numbers appear after analysis.** A call counts in volume as soon as it exists, but its outcome, sentiment and extracted fields only appear once post-call analysis has written them.
* **Averages are over the rows that carry the measurement.** A call with no sentiment score does not pull the average toward zero; it is simply not in the sample. Most responses include the sample size next to the average (`sentimentCallCount`, `sampleSize`, `sentimentSampleSize`) so you can tell a fleet-wide signal from a three-call one.

**`null` means "no data", never zero.** Sentiment runs from `-1` to `1`, where `0` is a real, neutral reading. When nothing in the window was scored, analytics endpoints return `avgSentiment: null`, not `0`. Treat `null` as "not measured" in your charts. (The generated reports under `/api/reports` are the exception: they report `0` when there is no data.)

## Authentication and access

All analytics routes are reads, so a `read` key is enough for every `GET` on this page. The exceptions:

| Operation                                                                                     | Key needed |
| --------------------------------------------------------------------------------------------- | ---------- |
| Every `GET` under `/api/dashboard`, `/api/analytics`, `/api/reports/types` and `/api/exports` | `read`     |
| `POST /api/reports` (generate a report)                                                       | `write`    |
| `POST /api/analytics/events`, `POST /api/analytics/events/batch`                              | `write`    |
| `POST /api/exports` and `DELETE /api/exports/{id}`                                            | `write`    |
| Creating, reading or downloading an export of type `audit_log` or `agent_actions`             | `full`     |

See [Authentication](/authentication#scopes) for how scopes map to roles.

## Common query parameters

Most analytics endpoints share the same window and filters:

**`days`** `integer` — default: 30

Size of the window, counted back from now, from `1` to `365`. An out-of-range or non-numeric value falls back to `30` rather than failing.

---

**`direction`** `string` — default: all

`inbound`, `outbound` or `all`. Applies to call metrics.

---

**`campaignId`** `string (uuid)`

Scope every call metric to one campaign.

---

**`agentId`** `string (uuid)`

Scope every call metric to one agent. The agent is the one that actually handled the call, so manual calls and calls from reassigned campaigns are attributed correctly.

---

**`timezone`** `string`

IANA timezone such as `America/Bogota`, used to bucket days and hours. An unknown timezone falls back to UTC.

---

A malformed `campaignId` or `agentId` (not a UUID) returns `400 VALIDATION_FAILED` with the message `Invalid query parameters`. Filters are never silently dropped.

## Dashboard bundle

`GET /api/dashboard` returns what the dashboard's home page needs in one request.

**`tz`** `string` — default: UTC

Timezone used to decide what "today" means for `callsToday`.

---

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/api/dashboard?tz=America/Bogota" \
  -H "Authorization: Bearer $JELLIU_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/dashboard?tz=America/Bogota', {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data } = await res.json();
console.log(data.callsToday, data.overview?.successRate);
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.get(
    "https://api.jelliu.co/api/dashboard",
    params={"tz": "America/Bogota"},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
data = res.json()["data"]
print(data["callsToday"], (data["overview"] or {}).get("successRate"))
```

| Field          | Type             | Description                                                                                                                                   |
| -------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `overview`     | object or `null` | The 30-day workspace overview (same shape as [`/api/analytics/overview`](#workspace-overview)). `null` if that section could not be computed. |
| `agents`       | array            | The workspace's agents.                                                                                                                       |
| `campaigns`    | array            | The workspace's campaigns.                                                                                                                    |
| `callsToday`   | integer          | Calls created today, as a calendar day in `tz`.                                                                                               |
| `agentRanking` | array            | 30-day per-agent metrics (same shape as [`/api/analytics/agent-ranking`](#agent-ranking)).                                                    |

Read per-agent performance from `agentRanking`, not from the metric fields on the `agents` list: those list fields are placeholders and are not computed here.

## Call analytics

### Workspace overview

`GET /api/analytics/overview` accepts `days`, `direction`, `campaignId` and `agentId`.

```json
{
  "data": {
    "totalInteractions": 1240,
    "completedInteractions": 982,
    "successes": 214,
    "successRate": 21.79,
    "avgDurationSeconds": 143,
    "avgSentiment": 0.31,
    "sentimentCallCount": 911,
    "totalAgents": 4,
    "totalCampaigns": 6,
    "activeCampaigns": 2,
    "totalContacts": 5120,
    "convertedContacts": 388,
    "dncContacts": 41,
    "channelBreakdown": [
      {
        "channel": "voice",
        "category": "sales",
        "interactions": 1240,
        "completed": 982,
        "successes": 214,
        "successRate": 21.79,
        "successRateLabel": "conversionRate",
        "avgDurationSeconds": 143,
        "excludedMetrics": []
      }
    ],
    "outcomeBreakdown": { "sale_closed": 214, "rejected": 402, "callback_scheduled": 96 }
  }
}
```

* Rates such as `successRate` are **percentages from 0 to 100**, rounded to two decimals.
* A "success" depends on the campaign category (a sale for `sales`, a booking for `scheduling`, and so on). `successRateLabel` names it.
* `avgDurationSeconds` is `null` when no voice channel is involved, and `excludedMetrics` lists metrics that do not apply to a channel or category.

### Overview by direction

`GET /api/analytics/overview/by-direction?days=90` splits call metrics into `inbound`, `outbound` and `total`. Each has `totalCalls`, `avgDuration` (`null` without voice calls), `successRate`, `avgSentiment` and `sentimentCallCount`.

### Time series

| Endpoint                             | One row per                       | Fields                                                                                                                                                                   |
| ------------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /api/analytics/calls-per-day`   | day                               | `date` (`YYYY-MM-DD`), `total`, `completed`, `successes`, `rejected`, `avgSentiment`, `avgFrustration`, `avgDurationSeconds`, `avgLatencyMs`, `costUsd`, `interruptions` |
| `GET /api/analytics/calls-by-hour`   | hour of day (`0` to `23`)         | `hour`, `total`, `completed`, `successes`, `completionRate`, `successRate`, `avgSentiment`                                                                               |
| `GET /api/analytics/sentiment-trend` | day with at least one scored call | `date`, `avgSentiment`, `avgFrustration`, `avgSentimentLow`, `callCount` (scored calls that day, not the day's total)                                                    |

All three accept `days`, `direction`, `timezone`, `campaignId` and `agentId`. Days with no calls are **omitted**, not returned as zero rows; fill the gaps on your side if your chart needs a continuous axis.

**`cURL`**

```bash title="cURL"
curl -sS -G "https://api.jelliu.co/api/analytics/calls-per-day" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  --data-urlencode "days=14" \
  --data-urlencode "timezone=America/Mexico_City" \
  --data-urlencode "agentId=7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4"
```

**`Node.js`**

```javascript title="Node.js"
const params = new URLSearchParams({
  days: '14',
  timezone: 'America/Mexico_City',
  agentId: '7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4',
});
const res = await fetch(`https://api.jelliu.co/api/analytics/calls-per-day?${params}`, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data } = await res.json();
for (const day of data) {
  console.log(day.date, day.total, day.avgSentiment ?? 'n/a');
}
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.get(
    "https://api.jelliu.co/api/analytics/calls-per-day",
    params={
        "days": 14,
        "timezone": "America/Mexico_City",
        "agentId": "7c1d9f30-5e2a-4a7e-9c8f-91a0b1c2d3e4",
    },
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
for day in res.json()["data"]:
    print(day["date"], day["total"], day["avgSentiment"])
```

```json
{
  "data": [
    {
      "date": "2026-09-13",
      "total": 88,
      "completed": 71,
      "successes": 17,
      "rejected": 22,
      "avgSentiment": 0.28,
      "avgFrustration": 0.19,
      "avgDurationSeconds": 131,
      "avgLatencyMs": 1180,
      "costUsd": 13.6412,
      "interruptions": 64
    },
    {
      "date": "2026-09-14",
      "total": 12,
      "completed": 9,
      "successes": 0,
      "rejected": 3,
      "avgSentiment": null,
      "avgFrustration": null,
      "avgDurationSeconds": 97,
      "avgLatencyMs": null,
      "costUsd": null,
      "interruptions": null
    }
  ]
}
```

### Conversation quality

`GET /api/analytics/quality` describes how calls went rather than how they ended. It accepts `days`, `direction`, `campaignId` and `agentId`. Every block carries its own `sampleSize`, because older calls do not have every measurement.

| Block                                        | Fields                                                                                                                                            |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| top level                                    | `windowDays`, `totalCalls`                                                                                                                        |
| `latency`                                    | `sampleSize`, `avgMs`, `p50Ms`, `p90Ms`, `maxMs` (agent response latency)                                                                         |
| `talk`                                       | `sampleSize`, `agentSeconds`, `userSeconds`, `agentShare` (percentage of spoken time that was the agent)                                          |
| `turns`                                      | `sampleSize`, `agentTurns`, `userTurns`, `interruptions`, `interruptionRate` (percentage of agent turns cut off), `interruptionsPerCall`          |
| `sentiment`                                  | `sampleSize`, `avg`, `frustrationSampleSize`, `avgFrustration`, `frustratedCalls` (frustration peaked at 0.5 or above), `lowSampleSize`, `avgLow` |
| `cost`                                       | `sampleSize`, `credits`, `usd`, `llmUsd`, `perCallUsd`, `perMinuteUsd`                                                                            |
| `successScore`                               | `sampleSize`, `avg`                                                                                                                               |
| `duration`                                   | `sampleSize`, `avgSeconds` (completed calls)                                                                                                      |
| `terminationReasons`, `languages`, `sources` | Up to 12 entries each, most frequent first: `{ reason, count }`, `{ language, count }`, `{ source, count }`                                       |

Cost figures in analytics describe what the calls consumed on the voice platform. They are operational telemetry, not your Jelliu invoice. For plan usage, see [Billing and usage](/platform/billing-and-usage).

### Agent ranking

`GET /api/analytics/agent-ranking` returns up to 50 agents, ordered by successes and then by call volume. It accepts `days`, `direction`, `campaignId` and `agentId`.

| Field                                                    | Description                               |
| -------------------------------------------------------- | ----------------------------------------- |
| `agentId`, `agentName`                                   | The agent.                                |
| `totalCalls`, `completed`, `successes`, `rejected`       | Counts in the window.                     |
| `conversionRate`                                         | Successes over completed calls, 0 to 100. |
| `rejectionRate`                                          | Rejections over all calls, 0 to 100.      |
| `avgSentiment`, `sentimentCallCount`, `avgFrustration`   | Sentiment and its sample size.            |
| `avgDurationSeconds`, `avgLatencyMs`, `latencyCallCount` | Duration and response latency.            |
| `interruptionRate`, `agentTalkShare`                     | Percentages, or `null` without data.      |
| `costUsd`, `costPerCallUsd`, `avgSuccessScore`           | Cost and the analysis success score.      |

### Campaign analytics

| Endpoint                                    | Returns                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/analytics/campaigns/{campaignId}` | One campaign: `campaignId`, `channel`, `category`, `totalInteractions`, `completedInteractions`, `successes`, `successRate`, `successRateLabel`, `averageDurationSeconds` (voice only), `avgSentiment`, `outcomeBreakdown`, `excludedMetrics`. Accepts `direction`.                                                                                                                                                    |
| `GET /api/analytics/top-campaigns`          | Campaigns ranked by success rate. Accepts `days`, `limit` (1 to 100, default 10), `direction` and `agentId`. Each row: `campaignId`, `campaignName`, `agentName`, `status`, `channel`, `category`, `totalInteractions`, `completed`, `successes`, `rejected`, `successRate`, `rejectionRate`, `successRateLabel`, `avgDurationSeconds`, `avgSentiment`, `avgFrustration`, `avgLatencyMs`, `costUsd`, `costPerCallUsd`. |

A campaign analytics response may include `_degraded`, a list of sections that could not be computed (`aggregates`, `outcomeBreakdown`). The rest of the response is still valid. An unknown campaign returns `404 CAMPAIGN_NOT_FOUND`.

### Evaluation criteria and extracted data

Agents can be configured with success criteria and data-collection fields. These two endpoints roll up the results across **both voice calls and text conversations**:

| Endpoint                                      | Returns                                                                                                                                                                            |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/analytics/analysis/criteria`        | `windowDays`, `agentId`, and `criteria`: one entry per criterion with `id`, `success`, `failure`, `unknown`, `total`, and `bySource.voice` / `bySource.text` with the same counts. |
| `GET /api/analytics/analysis/data-collection` | `windowDays`, `agentId`, and `fields`: one entry per field with `id` and `top`, the 10 most frequent values, each with `value`, `count`, `voice` and `text`.                       |

Both accept `days` (1 to 365, default 30) and `agentId`. Here an invalid `agentId` returns `400` with the message `Invalid agentId: expected a UUID`. A verdict that is not `success` or `failure` is counted as `unknown`, so totals always add up.

## Text conversation analytics

Call analytics read calls only. These endpoints cover WhatsApp, web chat, email, Instagram and Messenger threads:

| Endpoint                                           | Query                                 | Returns                                                                                                                                                   |
| -------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/analytics/conversations/overview`        | `days`, `channel`                     | `total`, `analysed`, `pending`, `notAnalysable`, `outcomes` (`success`, `failure`, `unknown`), `avgSentiment`, `sentimentSampleSize`, `byChannel`, `days` |
| `GET /api/analytics/conversations/criteria`        | `days`, `channel`                     | Per `criterion`: `success`, `failure`, `unknown`, `successRate`                                                                                           |
| `GET /api/analytics/conversations/sentiment-trend` | `days`, `timezone`                    | Per `day`: `avgSentiment`, `sentimentSampleSize`, `total`                                                                                                 |
| `GET /api/analytics/conversations/attention`       | `days`, `limit` (1 to 50, default 10) | The threads most worth a human's attention: `id`, `channel`, `summary`, `sentimentScore`, `outcome`, `createdAt`                                          |
| `GET /api/analytics/conversations/field/{field}`   | `days`                                | Distribution of one extracted field: `value`, `count`                                                                                                     |

* `channel` is one of `whatsapp`, `webchat`, `email`, `instagram`, `messenger`.
* `field` must be a simple identifier (a letter, then letters, digits or underscores, up to 64 characters).
* `pending` threads are still being analyzed and clear by themselves. `notAnalysable` threads will never be analyzed, so they are a permanent gap in the denominator.
* In the conversation criteria endpoint, `successRate` is a **ratio from 0 to 1** over decided verdicts only (`unknown` is excluded), or `null` if nothing was decided. This differs from the 0 to 100 percentages used by call analytics.
* In the field breakdown, a thread where the agent extracted the field but found no value is reported with the literal value `(sin dato)`.

Invalid parameters here return `400 VALIDATION_FAILED` with the message `Request validation failed` and an issue list in `details`.

## Page bundle

`GET /api/analytics/page-load` computes the whole analytics page in one request. It accepts `days`, `limit`, `direction`, `timezone`, `campaignId` and `agentId`, and returns:

| Field                                                                                                         | Same shape as                          |
| ------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `overview`                                                                                                    | `/api/analytics/overview`              |
| `overviewByDirection`                                                                                         | `/api/analytics/overview/by-direction` |
| `topCampaigns`                                                                                                | `/api/analytics/top-campaigns`         |
| `callsPerDay`, `callsByHour`, `sentimentTrend`                                                                | The time series above                  |
| `agentRanking`                                                                                                | `/api/analytics/agent-ranking`         |
| `quality`                                                                                                     | `/api/analytics/quality`               |
| `conversations.overview`, `conversations.byChannel`, `conversations.criteria`, `conversations.sentimentTrend` | The text conversation endpoints        |

Each section is computed independently. If one fails, it comes back as `null` (objects) or `[]` (lists) and the rest of the bundle is unaffected, so check for `null` before reading nested fields. The campaign filter is not applied to the `conversations` sections, because text threads do not belong to campaigns.

## Campaign KPIs

Each campaign category has business KPIs derived from the data the agent collects (for example `revenue_total` and `avg_ticket` for sales, `appointments_booked` for scheduling, `nps_average` for surveys).

| Endpoint                                         | Query                                 | Returns                                                                                                                                                                 |
| ------------------------------------------------ | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/analytics/kpi/{campaignId}`            | `days`                                | `category`, `kpis` (each `key`, `label`, `value`, `count`, `unit`, optional `trend`), and `breakdown` (distribution items with `label`, `value`, `count`, `percentage`) |
| `GET /api/analytics/kpi/{campaignId}/timeseries` | `kpi` (required, a KPI key), `days`   | Array of `date`, `value`, `count`                                                                                                                                       |
| `GET /api/analytics/kpi/{campaignId}/objections` | `days`, `limit` (1 to 50, default 10) | Array of `objection`, `count`, `percentage`, optional `trend`                                                                                                           |
| `GET /api/analytics/kpi/tenant-summary`          | `days`                                | `totalCalls`, `totalSuccessful`, `successRate`, `avgDuration`, `categorySummaries`                                                                                      |
| `GET /api/analytics/kpi/templates/{category}`    | none                                  | The data-collection fields and success evaluation suggested for a category                                                                                              |

`unit` is one of `currency`, `count`, `percentage`, `score` or `seconds`. `category` is one of `sales`, `support`, `scheduling`, `surveys`, `collections`, `retention`, `notifications`, `interview`, `language_assessment`, `personal`, `general`.

**`cURL`**

```bash title="cURL"
curl -sS -G "https://api.jelliu.co/api/analytics/kpi/0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90/timeseries" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  --data-urlencode "kpi=revenue_total" \
  --data-urlencode "days=30"
```

**`Node.js`**

```javascript title="Node.js"
const campaignId = '0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90';
const url = `https://api.jelliu.co/api/analytics/kpi/${campaignId}/timeseries?kpi=revenue_total&days=30`;
const res = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` },
});
const { data } = await res.json();
console.log(data);
```

**`Python`**

```python title="Python"
import os
import requests

campaign_id = "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90"
res = requests.get(
    f"https://api.jelliu.co/api/analytics/kpi/{campaign_id}/timeseries",
    params={"kpi": "revenue_total", "days": 30},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    timeout=30,
)
print(res.json()["data"])
```

## Custom events

You can record your own product events against the workspace and query them back.

| Method | Path                            | Body or query                                                                                                                                                                                                                 |
| ------ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/api/analytics/events`         | `event` (required, 1 to 200 characters: letters, digits, `.`, `_`, `-`, spaces), `properties` (object, up to 50 keys), `timestamp` (ISO 8601, defaults to now). Returns `202` with `{ "data": { "accepted": true } }`.        |
| `POST` | `/api/analytics/events/batch`   | `events`: 1 to 100 events of the same shape. Returns `202` with `accepted` and `count`. Limited to 30 requests per minute.                                                                                                    |
| `GET`  | `/api/analytics/events`         | `days` (1 to 90, default 7), `event` (exact name), `limit` (1 to 500, default 50), `offset`, or `cursor` (the `timestamp` of the last row you received, for keyset pagination; takes precedence over `offset`). Newest first. |
| `GET`  | `/api/analytics/events/summary` | `days` (1 to 90, default 7). Up to 100 `{ event_name, count }` rows, most frequent first.                                                                                                                                     |

Each event row has `id`, `tenant_id`, `user_id`, `event_name`, `properties`, `source`, `timestamp` and `created_at`.

## Reports

Reports compute a finished analysis over an explicit date range and return it inline.

`GET /api/reports/types` lists the available types:

| `type`                | What it computes                                                                                                            |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `roi_analysis`        | Minutes used, estimated cost, estimated cost of human agents for the same minutes, savings and ROI.                         |
| `campaign_comparison` | Campaigns ranked by success rate, with calls, duration, sentiment and cost.                                                 |
| `cost_per_call`       | Daily cost per call and cost per minute, with totals.                                                                       |
| `agent_performance`   | Agents ranked by success rate, with outcome breakdowns, duration and sentiment.                                             |
| `contact_funnel`      | Contacts by status (`pending`, `called`, `converted`, `dnc`, plus any other status) with percentages and a conversion rate. |

### Generate a report

`POST /api/reports`

**`type`** `string` — required

One of the types above.

---

**`period`** `string` — required

`daily`, `weekly`, `monthly`, `quarterly` or `custom`. A label for the report; the range is set by `date_from` and `date_to`.

---

**`date_from`** `string` — required

ISO 8601 date-time with offset (`2026-08-01T00:00:00-05:00`) or a date (`2026-08-01`).

---

**`date_to`** `string` — required

Same format. The range cannot exceed one year.

---

**`campaign_ids`** `string[] (uuid)`

Restrict `roi_analysis` and `campaign_comparison` to these campaigns. `contact_funnel` uses only the first ID. `cost_per_call` and `agent_performance` ignore it.

---

**`format`** `string` — default: json

Accepts `json` or `pdf`. Reports are always returned as JSON.

---

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/reports" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "campaign_comparison",
    "period": "monthly",
    "date_from": "2026-08-01T00:00:00Z",
    "date_to": "2026-08-31T23:59:59Z"
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/reports', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    type: 'campaign_comparison',
    period: 'monthly',
    date_from: '2026-08-01T00:00:00Z',
    date_to: '2026-08-31T23:59:59Z',
  }),
});
const { data } = await res.json();
for (const c of data.campaigns) {
  console.log(c.rank, c.campaignName, c.successRate);
}
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/reports",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "type": "campaign_comparison",
        "period": "monthly",
        "date_from": "2026-08-01T00:00:00Z",
        "date_to": "2026-08-31T23:59:59Z",
    },
    timeout=60,
)
for c in res.json()["data"]["campaigns"]:
    print(c["rank"], c["campaignName"], c["successRate"])
```

```json
{
  "data": {
    "type": "campaign_comparison",
    "period": { "from": "2026-08-01T00:00:00Z", "to": "2026-08-31T23:59:59Z" },
    "campaigns": [
      {
        "campaignId": "0f3c8b52-6d1a-4e2f-9b7c-4a5e6d7f8a90",
        "campaignName": "Renovaciones agosto",
        "totalCalls": 812,
        "successRate": 24.1,
        "avgDurationSeconds": 151,
        "avgSentiment": 0.34,
        "totalCost": 285.62,
        "rank": 1
      }
    ]
  }
}
```

### Report shapes

| `type`                | `data` fields (besides `type` and `period`)                                                                                                                                                 |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `roi_analysis`        | `totalCalls`, `successfulCalls`, `avgDurationSeconds`, `totalMinutesUsed`, `costPerMinute`, `totalCost`, `estimatedHumanAgentCost`, `savingsVsHuman`, `roiPercent`, `costPerSuccessfulCall` |
| `campaign_comparison` | `campaigns[]`: `campaignId`, `campaignName`, `totalCalls`, `successRate`, `avgDurationSeconds`, `avgSentiment`, `totalCost`, `rank`                                                         |
| `cost_per_call`       | `timeSeries[]`: `date`, `totalCost`, `callCount`, `costPerCall`, `costPerMinute`; `totals`: `totalCost`, `totalCalls`, `avgCostPerCall`, `avgCostPerMinute`                                 |
| `agent_performance`   | `agents[]`: `agentId`, `agentName`, `totalCalls`, `successRate`, `avgDurationSeconds`, `avgSentiment`, `outcomeBreakdown`, `rank`                                                           |
| `contact_funnel`      | `stages[]`: `stage`, `count`, `percentage`; `totalContacts`, `conversionRate`                                                                                                               |

Costs and ROI in reports are **estimates**: they multiply minutes by a platform-wide cost per minute and compare against a reference hourly rate for human agents. Use them for trends and comparisons, not as billing figures.

A plain date such as `"2026-08-31"` means the **start** of that day, so a range ending on `"2026-08-31"` excludes August 31. To include the whole last day, send a date-time such as `"2026-08-31T23:59:59Z"` or the next day's date.

## Exports

Exports produce a file of raw rows asynchronously. You create a job, poll it until it completes, then download the file.

```mermaid
stateDiagram-v2
  [*] --> pending: POST /api/exports (202)
  pending --> processing: worker picks up
  processing --> completed: file written (expires in 72 h)
  processing --> failed: error recorded
  completed --> [*]: DELETE /api/exports/:id
  failed --> [*]: DELETE /api/exports/:id
```

### Export types and filters

| `type`          | Columns                                                                                                                                 | Supported `filters`                                                    |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `calls`         | `id`, `phone_number`, `status`, `outcome`, `duration_seconds`, `sentiment_score`, `summary`, `started_at`, `ended_at`, `call_direction` | `date_from`, `date_to`, `campaign_id`, `agent_id`, `status`, `outcome` |
| `contacts`      | `name`, `phone_number`, `email`, `status`, `call_attempts`, `last_called_at`, `campaign_name`                                           | `date_from`, `date_to`, `campaign_id`, `agent_id`, `status`            |
| `conversations` | `id`, `channel`, `status`, `created_at`, `agent_name`, `message_count`                                                                  | `date_from`, `date_to`, `agent_id`, `status`                           |
| `analytics`     | One row per day and campaign: `date`, `campaign_name`, `total_calls`, `avg_duration_seconds`, `success_rate`, `avg_sentiment`           | `date_from`, `date_to`, `campaign_id`, `agent_id`, `status`            |
| `agent_actions` | Every tool call an agent made, with parameters (redacted), outcome, duration and cost attribution                                       | `date_from`, `date_to`, `agent_id`, `status` (the action outcome)      |
| `audit_log`     | The workspace audit log                                                                                                                 | `date_from`, `date_to`                                                 |

A filter the type cannot apply is **rejected**, never ignored:

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Export type \"conversations\" cannot filter by campaign_id. Supported filters for \"conversations\": date_from, date_to, agent_id, status."
  }
}
```

### Create an export

`POST /api/exports`

**`type`** `string` — required

`calls`, `contacts`, `conversations`, `analytics`, `agent_actions` or `audit_log`.

---

**`format`** `string` — default: csv

`csv`, `json` (one array) or `jsonl` (one JSON object per line; best for large files and SIEM ingestion).

---

**`filters`** `object`

`date_from` and `date_to` (ISO 8601 date-times), `campaign_id`, `agent_id` (UUIDs), `status`, `outcome`. Only the filters supported by the type.

---

#### Create the job

**`cURL`**

```bash title="cURL"
curl -sS -X POST "https://api.jelliu.co/api/exports" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "calls",
    "format": "csv",
    "filters": {
      "date_from": "2026-09-01T00:00:00Z",
      "date_to": "2026-09-14T23:59:59Z",
      "outcome": "sale_closed"
    }
  }'
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/exports', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.JELLIU_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    type: 'calls',
    format: 'csv',
    filters: {
      date_from: '2026-09-01T00:00:00Z',
      date_to: '2026-09-14T23:59:59Z',
      outcome: 'sale_closed',
    },
  }),
});
const { data: job } = await res.json(); // 202
console.log(job.id, job.status); // "pending"
```

**`Python`**

```python title="Python"
import os
import requests

res = requests.post(
    "https://api.jelliu.co/api/exports",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"},
    json={
        "type": "calls",
        "format": "csv",
        "filters": {
            "date_from": "2026-09-01T00:00:00Z",
            "date_to": "2026-09-14T23:59:59Z",
            "outcome": "sale_closed",
        },
    },
    timeout=30,
)
job = res.json()["data"]  # 202
print(job["id"], job["status"])  # "pending"
```

The response is `202 Accepted` with the job:

```json
{
  "data": {
    "id": "3e1f0a9c-7b2d-4c6e-8f10-2a3b4c5d6e7f",
    "tenant_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
    "user_id": "user_2abcDEF123",
    "type": "calls",
    "format": "csv",
    "status": "pending",
    "filters": { "date_from": "2026-09-01T00:00:00Z", "date_to": "2026-09-14T23:59:59Z", "outcome": "sale_closed" },
    "row_count": null,
    "file_size_bytes": null,
    "file_url": null,
    "error": null,
    "started_at": null,
    "completed_at": null,
    "expires_at": null,
    "created_at": "2026-09-14T16:02:11.412Z"
  }
}
```

#### Poll until it finishes

`GET /api/exports/{id}` returns the same object. Poll every few seconds until `status` is `completed` or `failed`. When completed, `row_count`, `file_size_bytes`, `completed_at` and `expires_at` are set. When failed, `error` explains why. `file_url` is internal bookkeeping: always download through the endpoint below.

`GET /api/exports?limit=50&offset=0` lists jobs, newest first (`limit` 1 to 100).

#### Download the file

`GET /api/exports/{id}/download` streams the file as an attachment named `{type}_export_{id}.{csv|json|jsonl}`, with `Content-Type` `text/csv`, `application/json` or `application/x-ndjson`.

**`cURL`**

```bash title="cURL"
curl -sS -L "https://api.jelliu.co/api/exports/3e1f0a9c-7b2d-4c6e-8f10-2a3b4c5d6e7f/download" \
  -H "Authorization: Bearer $JELLIU_API_KEY" \
  -o calls.csv
```

**`Node.js`**

```javascript title="Node.js"
import { writeFile } from 'node:fs/promises';

const BASE = 'https://api.jelliu.co/api/exports';
const headers = { Authorization: `Bearer ${process.env.JELLIU_API_KEY}` };
const jobId = '3e1f0a9c-7b2d-4c6e-8f10-2a3b4c5d6e7f';

let job;
do {
  await new Promise((r) => setTimeout(r, 3000));
  job = (await (await fetch(`${BASE}/${jobId}`, { headers })).json()).data;
} while (job.status === 'pending' || job.status === 'processing');

if (job.status === 'failed') throw new Error(job.error);

const file = await fetch(`${BASE}/${jobId}/download`, { headers });
await writeFile('calls.csv', Buffer.from(await file.arrayBuffer()));
console.log(`Saved ${job.row_count} rows`);
```

**`Python`**

```python title="Python"
import os
import time
import requests

BASE = "https://api.jelliu.co/api/exports"
headers = {"Authorization": f"Bearer {os.environ['JELLIU_API_KEY']}"}
job_id = "3e1f0a9c-7b2d-4c6e-8f10-2a3b4c5d6e7f"

while True:
    time.sleep(3)
    job = requests.get(f"{BASE}/{job_id}", headers=headers, timeout=30).json()["data"]
    if job["status"] not in ("pending", "processing"):
        break

if job["status"] == "failed":
    raise RuntimeError(job["error"])

with requests.get(f"{BASE}/{job_id}/download", headers=headers, stream=True, timeout=300) as r:
    r.raise_for_status()
    with open("calls.csv", "wb") as f:
        for chunk in r.iter_content(chunk_size=65536):
            f.write(chunk)
print(f"Saved {job['row_count']} rows")
```

#### Clean up (optional)

`DELETE /api/exports/{id}` removes a `completed` or `failed` job and returns `204`. Deleting a job that is still `pending` or `processing` returns `400` with `Can only delete completed or failed export jobs`.

### Export limits

| Limit                      | Value                                                                                                                                                                                             |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Rows per file              | 100,000. A larger result is truncated, and the completed job's `error` field reads `Results truncated to 100000 rows`. Narrow the date range and export in slices.                                |
| Jobs in progress           | 3 per workspace (`pending` plus `processing`). A fourth returns `429 RATE_LIMIT_EXCEEDED`: `Too many in-progress exports (max 3). Wait for an existing export to finish before starting another.` |
| File lifetime              | 72 hours after completion.                                                                                                                                                                        |
| Creating and deleting jobs | Share the 10 requests per minute budget for configuration changes. See [Rate limits](/rate-limits).                                                                                               |

`audit_log` and `agent_actions` exports contain your compliance record. Creating, reading **and** downloading them requires a `full` key, checked against the job's type on every request, so a lower-scoped key cannot download a file an administrator created.

A job that is not completed yet returns `404` with `Export file is not ready for download`. An expired file returns `410` with `Export file has expired`, or `404` with `Export file has expired or is no longer available`. Create a new export in either case.

## Errors

| Status | Code                  | When                                                                                                          |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------- |
| `400`  | `VALIDATION_FAILED`   | Invalid query or body: bad UUID, unsupported export filter, report range over a year, invalid KPI category.   |
| `403`  | `FORBIDDEN`           | The key's scope does not cover the request, including `audit_log` and `agent_actions` exports without `full`. |
| `403`  | `BILLING_ERROR`       | The workspace plan does not include the analytics, report or export feature. All current plans include them.  |
| `404`  | `CAMPAIGN_NOT_FOUND`  | The campaign does not exist in your workspace.                                                                |
| `404`  | `NOT_FOUND`           | Unknown export job, or the file is not ready or no longer available.                                          |
| `410`  | `NOT_FOUND`           | The export file expired.                                                                                      |
| `429`  | `RATE_LIMIT_EXCEEDED` | A rate limit, or 3 exports already in progress. Honor `Retry-After`.                                          |

See [Errors](/errors) for the envelope.

## Limits and caching

* **Rate limits.** Analytics endpoints are limited to 30 requests per minute per workspace, on top of the general limit. Use `/api/analytics/page-load` or `/api/dashboard` instead of many individual calls. See [Rate limits](/rate-limits).
* **Freshness.** Aggregates are cached on the server for up to a few minutes and responses carry `Cache-Control: private, max-age=30, stale-while-revalidate=900`. A call that just ended may take a short while to show up. Do not poll analytics faster than once a minute.
* **Conditional requests.** `overview`, `agent-ranking`, `quality`, `top-campaigns` and `/api/dashboard` return a weak `ETag`. Send it back in `If-None-Match` to receive `304 Not Modified` when nothing changed.
* **Windows.** `days` is capped at 365 for analytics and at 90 for custom events. Reports cover at most one year.

For real-time pipelines, do not poll analytics. Subscribe to the `call.completed` webhook, which carries each call's outcome, sentiment, summary and extracted data as soon as analysis finishes, and aggregate on your side. See [Webhooks](/webhooks).

## Related

#### [Webhooks](/webhooks)

Receive `call.completed` with analysis results as it happens.

#### [Calls](/resources/calls)

Read individual calls, transcripts and analysis.

#### [Campaigns](/resources/campaigns)

Campaign categories decide what counts as a success.

#### [Billing and usage](/platform/billing-and-usage)

Plan usage, limits and minutes.