Skip to navigation

Counting and coverage

What a Jelliu rate divides by, how much of the window it was measured over, and where a day begins.
View as Markdown

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

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:

TestThreshold
Rounded call duration was shorter than30 seconds
The transcript carried fewer than8 turns
Counted user speech carried fewer than3 spoken turns
Counted agent speech carried fewer than3 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

FieldWhereWhat it holds
noEffectiveContactOverview, by-direction, per-day, per-hour, agent ranking, top campaigns, campaign analytics, tenant KPI summaryCount of connected calls (status completed) that never became a conversation
effectiveCompletedThe same responsesThe success-rate denominator: connected calls that were long enough and spoken enough to have gone either way
no_effective_contactA key inside outcomeBreakdownCalls reclassified out of the outcome the analysis stamped on them
no_effective_contact_callsThe analytics exportThe same count, per day and campaign
suppressedGET /api/analytics/analysis/criteriaVerdicts the threshold silenced — see Coverage

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.

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.

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.

FieldDenominator it discloses
analysed (analysedCalls in the tenant KPI summary)Calls an evaluation actually ran on, out of totalCalls / totalInteractions / total
sentimentCallCountCalls that carried a sentiment score, the denominator of avgSentiment
sampleSize (and frustrationSampleSize, lowSampleSize, latencyCallCount, sentimentSampleSize)Rows carrying that particular measurement
effectiveCompletedConnected calls that could have gone either way, the denominator of every success rate
suppressedCriterion 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.

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.

SurfaceWhat decides the day
POST /api/reports — a bare date_from / date_to, and the cost_per_call daily seriesYour workspace’s timezone
POST /api/exports with type: "analytics" — the date columnYour workspace’s timezone
Campaign KPI rows, and KPI keys ending in _todayYour workspace’s timezone
GET /api/dashboard — callsTodayThe tz query parameter, default UTC
/api/analytics/calls-per-day, calls-by-hour, sentiment-trend, conversations/sentiment-trendThe 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.

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 for the old and new names and how long the old ones keep working.