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
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:
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
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_contactis not a call outcome. You will never see it in a call’soutcomefield, in acall.completedwebhook, or in theoutcomefilter of an export. It exists only in aggregates. The call’s ownoutcomestill 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.
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.
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.

