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

# Counting and coverage

Every rate this API returns is a fraction, and a fraction is only as honest as the three things behind it: what went in the numerator, what went in the denominator, and how much of the window was measured at all. This page is the contract for those three, so that a number you pull into your own dashboard means the same thing there as it does here.

Three rules, and the rest of this page is why:

* A call that connected but never became a conversation is **its own bucket**, counted neither as a success nor as a refusal.
* Every rate ships next to the **coverage** it was computed over, because a rate over three calls and a rate over three thousand look identical once they are a percentage.
* A **day** is your workspace's day, not the UTC day.

## Calls that never became a conversation

> **Note**
>
> **Since 2026-09-16.** A call that connected and ended before it was a conversation is reported under `no_effective_contact`, a bucket disjoint from success and from rejection. It used to be reported as a rejection.

Someone picks up, hears half a word and hangs up. They have not refused anything, they have not been sold anything, and they have not been served. Post-call analysis still runs on that call and still stamps an outcome on it — usually `rejected`, because the sale did not close — and until this change the reporting layer took that at face value. Two things went wrong at once:

* The outcome breakdown showed a refusal for somebody who never heard the offer.
* That same call sat in the success-rate denominator, so a campaign that converted two thirds of the people it actually spoke to could report a conversion rate in the teens.

### The rule

A call has **no effective contact** when any of these is true:

| Test                                    | Threshold          |
| --------------------------------------- | ------------------ |
| Rounded call duration was shorter than  | **30 seconds**     |
| The transcript carried fewer than       | **8 turns**        |
| Counted user speech carried fewer than  | **3 spoken turns** |
| Counted agent speech carried fewer than | **3 spoken turns** |

It is an **`or`, not an `and`**, and that is deliberate. Calls that run well past 30 seconds while carrying almost no turns are common and are exactly the ones that look most like real conversations in a table: hold music, a voicemail greeting the agent talked over, a line left open after the handset was put down. An `and` would let precisely those through.

Raw turns are counted from the stored transcript. Speech counts additionally require words from each side; filler and tool-only turns do not establish an exchange. Duration is rounded to whole seconds before comparison: 29.5 seconds becomes 30. A missing duration or transcript counts as zero. Historical rows whose speech counts are still missing use only the duration and raw-turn checks until backfilled; missing speech counts are not measured zeroes.

### Where it shows up

| Field                        | Where                                                                                                           | What it holds                                                                                                     |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `noEffectiveContact`         | Overview, by-direction, per-day, per-hour, agent ranking, top campaigns, campaign analytics, tenant KPI summary | Count of **connected** calls (status `completed`) that never became a conversation                                |
| `effectiveCompleted`         | The same responses                                                                                              | The success-rate **denominator**: connected calls that were long enough and spoken enough to have gone either way |
| `no_effective_contact`       | A key inside `outcomeBreakdown`                                                                                 | Calls reclassified out of the outcome the analysis stamped on them                                                |
| `no_effective_contact_calls` | The `analytics` export                                                                                          | The same count, per day and campaign                                                                              |
| `suppressed`                 | `GET /api/analytics/analysis/criteria`                                                                          | Verdicts the threshold silenced — see [Coverage](#coverage-every-rate-carries-its-own-sample)                     |

`successRate`, `conversionRate` and the per-hour and per-campaign rates all divide by `effectiveCompleted`. `rejected` and `rejectionRate` count explicit refusals only, and a hang-up is not one.

> **Warning**
>
> The counter and the breakdown key are **not the same population**, and they can disagree on purpose.
>
> `noEffectiveContact` counts connected calls only (`status: "completed"`). The `no_effective_contact` key inside `outcomeBreakdown` covers every call that carries an outcome and fails the threshold, so it also absorbs rows that used to appear as `no_answer` or `voicemail`. A call whose `outcome` is still `null` is in neither: it has never been in the breakdown and does not arrive in it through this bucket.

### It applies to calls you have already read

The bucket is **derived when the row is read**, not stored on the call. Speech measurements are stored: migration `20260917000000_call_speech_turns` adds nullable phone-call counts, and browser voice stores counts in metadata. The speech-count backfill populates historical rows. Until then, missing counts retain the duration/raw-turn fallback. A backfill can therefore change historical figures even without a change to the thresholds.

Two consequences you will notice:

* **Your history moved.** A chart you drew last week over the same window will not match the one you draw today. The old numbers were not "before the fix" — they are simply gone, because there is no stored column holding them.
* **`no_effective_contact` is not a call outcome.** You will never see it in a call's `outcome` field, in a `call.completed` webhook, or in the `outcome` filter of an export. It exists only in aggregates. The call's own `outcome` still says whatever the analysis decided. See [Outcomes](/resources/calls#outcomes).

If you need the old behaviour for a like-for-like comparison, add `noEffectiveContact` back into whichever side of the fraction you were using before — the counts are reported precisely so that you can.

## Coverage: every rate carries its own sample

A rate hides its sample size. 25% over four calls and 25% over four thousand are the same two characters on a card, and the first one is not a measurement of anything. Jelliu therefore reports, beside each rate, how many rows actually carried the thing being measured.

| Field                                                                                                  | Denominator it discloses                                                                 |
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| `analysed` (`analysedCalls` in the tenant KPI summary)                                                 | Calls an evaluation actually ran on, out of `totalCalls` / `totalInteractions` / `total` |
| `sentimentCallCount`                                                                                   | Calls that carried a sentiment score, the denominator of `avgSentiment`                  |
| `sampleSize` (and `frustrationSampleSize`, `lowSampleSize`, `latencyCallCount`, `sentimentSampleSize`) | Rows carrying that particular measurement                                                |
| `effectiveCompleted`                                                                                   | Connected calls that could have gone either way, the denominator of every success rate   |
| `suppressed`                                                                                           | Criterion verdicts the no-effective-contact threshold silenced                           |

A call counts as **analysed** when its evaluation results are non-empty. A call with no analysis stores an empty object rather than a null, so "has a row" and "was evaluated" are different questions, and only the second one belongs in a coverage figure.

> **Note**
>
> **Read `analysed` before you trust a rate.** If `analysed` is far below `total`, the rate describes the analysed slice, not the window. Nothing is hidden and nothing is withheld — the figure is still computed and returned — but it is your job to decide whether a rate over 9 of 412 calls belongs on a dashboard.

### Suppressed criterion verdicts

`GET /api/analytics/analysis/criteria` returns one entry per criterion with `success`, `failure`, `unknown`, `total`, a `bySource` split, and `suppressed`.

`suppressed` counts verdicts that were **not** rolled into the other numbers because the call had no effective contact. A two-second call cannot have resolved a query or closed a sale, and a judge marking those `failure` fills the table with failures nobody could have avoided.

The exception is safety: a criterion about whether the agent said something untrue, or named the technology behind it, keeps counting whatever the call's length. A three-second call is exactly where that kind of slip happens.

Suppressed verdicts are reported rather than dropped, because `suppressed: 140` and `failure: 0` is how you tell "this criterion always passes" from "this criterion was never in a position to be judged". Text conversations have no duration and are never suppressed.

## A day is your workspace's day

Daily aggregates bucket by the calendar day in your **workspace's timezone**, not the UTC day. For a workspace at UTC-5 the UTC boundary falls at 19:00 local, so the last five hours of every working day were filed under tomorrow: an evening's calls left "today" and reappeared the next morning, and a report's busiest day was smeared across two rows. Every number was plausible, which is why it survived so long.

| Surface                                                                                             | What decides the day                        |
| --------------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `POST /api/reports` — a bare `date_from` / `date_to`, and the `cost_per_call` daily series          | Your workspace's timezone                   |
| `POST /api/exports` with `type: "analytics"` — the `date` column                                    | Your workspace's timezone                   |
| Campaign KPI rows, and KPI keys ending in `_today`                                                  | Your workspace's timezone                   |
| `GET /api/dashboard` — `callsToday`                                                                 | The `tz` query parameter, default UTC       |
| `/api/analytics/calls-per-day`, `calls-by-hour`, `sentiment-trend`, `conversations/sentiment-trend` | The `timezone` query parameter, default UTC |

The two halves of that table are not in conflict. Where you can name a zone, the zone you name is used and nothing is inferred. Where you cannot — a date in a report body, a column in an export, a stored KPI row — the workspace's own clock is the only sensible default, because it is the clock the person reading the report keeps.

A workspace with no timezone set, or one whose stored value is not a valid IANA identifier, falls back to `America/Bogota`. Set yours in the dashboard if that is not where your day starts.

> **Warning**
>
> **Stored KPI rows were not backfilled.** Rows written before 2026-09-15 keep the UTC day they were filed under, so a range spanning that date contains both kinds and older rows can sit one day off for workspaces west of UTC. Everything derived at read time — reports, the `analytics` export, the analytics time series — is on the new boundary for its whole history.

## What reports do differently

The generated reports under `POST /api/reports` count **connections**, not conversations, and they say so in their field names since 2026-09-16. They do not apply the no-effective-contact bucket: a report's denominator is dials and connections, which is a different and equally legitimate question — how much of your dialling reached a human at all.

Read a success rate from `/api/analytics`. Read a connection rate from `/api/reports`. The two are not supposed to match, and when one page said "success" for both, customers reconciled them against each other and asked which one was real.

See [Report field renames](/platform/analytics-and-reports#report-fields-were-renamed) for the old and new names and how long the old ones keep working.

## Related

#### [Analytics and reports](/platform/analytics-and-reports)

The endpoints, their fields and their limits.

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

Outcomes, statuses and what analysis writes on a call.

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

A campaign's category decides which outcomes count as a success.