---
title: "Calls, abandons and recoveries by dealer, campaign and local day"
method: GET
path: "/voice/call-tracking/report"
tags: ["voice"]
---

# Calls, abandons and recoveries by dealer, campaign and local day

`GET /voice/call-tracking/report`

## Request

- Base URL: `https://corebee.ai/api/v1`
- URL: `https://corebee.ai/api/v1/voice/call-tracking/report`
- Auth: HTTP bearer

One row per (dealer, campaign, LOCAL DAY) over a bounded window, plus a per-(dealer, campaign) rollup of the same window. Requires the voice:bridge scope.

⛔ THIS FEATURE IS GATED AND A 404 IS THE GATE, NOT A TYPO. Two conditions are checked before any database work: a server-wide master flag, and a REQUIRED per-organization allowlist. An unset or empty allowlist means NOBODY, never everybody. Both failures answer the identical 404, so a caller cannot tell "the feature is off" from "you are not on the list", that is the intended posture, not an omission. A window with no calls in it is a 200 carrying an empty days array, so a 404 never means "nothing happened".

⛔ THE WINDOW IS A RANGE OF INSTANTS; THE ROWS ARE CALENDAR DAYS IN THE DEALER'S OWN TIMEZONE. These are not the same thing and the difference shows up at both ends of every pull. `from` and `to` filter on the instant a call started, in UTC. Each returned row is then bucketed by that instant rendered in the dealer's IANA timezone (call_tracking_numbers.dealer_timezone, resolved through the ICU database so DST needs no special-casing on your side). So a window of from=2026-08-01 over a dealer in America/Los_Angeles opens at 17:00 on JULY 31 local, and the first row you get back can legitimately be dated 2026-07-31 and be a partial day. The `timezone` field on every row states which zone that row was bucketed in; a dealer with no timezone configured falls back to a server default (UTC unless the operator set one), which is a provisioning gap rather than a reason to drop the call. If you are reconciling day totals with a dealer, pad the window by a day at each end and discard the edges.

DEFAULTS AND BOUNDS. Omitting `to` means now; omitting `from` means 30 days before now. A from later than to is a 400, and a window wider than 92 days is a 400 rather than a slow success, the report scans per-call rows before collapsing them, so an "all history" pull is refused instead of served badly. A date that does not parse is a 400 naming the two accepted spellings. A bare YYYY-MM-DD is read as MIDNIGHT UTC, so send a full ISO-8601 instant with an offset if you mean a local midnight.

⛔ THERE IS NO PAGINATION, AND `truncated` IS HOW YOU FIND OUT YOU LOST DATA. The underlying scan stops at 5000 calls. When it hits that ceiling `truncated` comes back true and EVERY NUMBER BELOW IT IS AN UNDERCOUNT, there is no cursor to follow and no page two. The remedy is a narrower window or a partner_dealer_id / partner_campaign_id filter, not a retry. Treat truncated:true as an error in your pipeline, not as a footnote.

ONLY TRACKED CALLS APPEAR, AND TEST TRAFFIC DOES NOT. Ordinary voice-bridge calls are structurally excluded (the report joins through the tracking number, so a call with no tracking number cannot enter it). Numbers flagged as test are excluded too: our end-to-end harness dials a real partner organization on purpose, and without that exclusion a robot call nobody answered would be reported to you as one of your dealers missing a lead.

⛔ `recoveries` IS ZERO ON EVERY ROW TODAY, BY CONSTRUCTION AND NOT BY MEASUREMENT, and `recoveries_wired: false` at the top of the response is the field that says so. The abandon → SMS → call-back corridor is held closed pending a legal decision on consent, and nothing links a recovery conversation back to a tracked call yet. Read a 0 here as "not wired", never as "we won nobody back". When it becomes true the counts start meaning what their name says; until then, do not chart this field.

⛔ `dealer_voicemail` IS ALSO EFFECTIVELY ZERO, FOR A DIFFERENT AND MORE MISLEADING REASON. That bucket is populated only by an answering-machine verdict on the dealer leg, and premium answering-machine detection is deliberately OFF in the current product: the forward is dialled without it so the consumer does not wait through the analysis window. With it off we cannot prove a human answered, so A FORWARD THAT WENT TO A DEALERSHIP VOICEMAIL BOX IS COUNTED IN `connected`. If your dealers dispute a connected count, this is the first thing to check, and it is a product setting rather than a defect in the count.

`dealer_no_answer` IS ONE BUCKET FOR FOUR OUTCOMES. No answer, busy, unreachable and rejected are distinguishable only while the call is live; the durable record does not keep the carrier cause, so they are indistinguishable at reporting time and share one column. `abandoned` is the CONSUMER hanging up before the two legs were joined, which is a different event from the dealer not picking up, and the two are never merged.

THE FOUR BUCKETS ALWAYS SUM TO calls_total: connected + abandoned + dealer_no_answer + dealer_voicemail. If your own arithmetic disagrees, you have summed across days that were bucketed in different timezones.

`calls_scanned` IS NOT THE SUM OF calls_total. It counts the call rows this request read; a row whose start instant cannot be parsed is dropped rather than misfiled under some arbitrary day, so the sum of the day rows can be lower. Use calls_scanned to reason about the ceiling, and the day rows for anything you show a dealer.

avg_forwarded_seconds IS AN HONEST NULL, NOT A ZERO. It averages the billed dealer-leg seconds over the CONNECTED calls in that bucket that already have a cost row, so a bucket where nothing has been rated yet reports null rather than a 0 that reads as "they talked for no time". Those seconds come from a SECOND lookup keyed on this window's dealer legs, and that lookup is best-effort: if it does not come back the report still returns 200 with every avg_forwarded_seconds null. A null across the whole response, on a window you know contains connected calls, means the durations were not retrieved, not that the calls were silent. Narrower windows are more likely to carry durations.

## Query parameters

- `from` string
- `to` string
- `partner_dealer_id` string
- `partner_campaign_id` string

## Response `200`

OK

- object
  - `data` object, required
    - `range` object, required: The window actually used, after defaults were applied, as ISO-8601 instants. Read it back rather than assuming your input was used verbatim, an omitted bound is filled in here.
      - `from` string, date-time, required
      - `to` string, date-time, required
    - `filters` object, required: The filters as received, null where you sent none. Echoed so a cached response can be identified without keeping the request around.
      - `partner_dealer_id` string, nullable, required
      - `partner_campaign_id` string, nullable, required
    - `calls_scanned` integer, required: Call rows read for this window AFTER the truncation ceiling was applied. NOT the sum of calls_total: a row with an unparseable start instant is dropped instead of being misfiled into a day.
    - `truncated` boolean, required: True when the scan hit its 5000-call ceiling, which makes every count below an UNDERCOUNT. There is no cursor: narrow the window or filter. False means the window was read whole.
    - `recoveries_wired` boolean, required: False while the abandon → recovery corridor is closed, which it is. While false, every `recoveries` value is 0 because nothing can populate it, not because nothing was recovered.
    - `days` CallTrackingReportDay[], required: One row per (dealer, campaign, local day), sorted by day, then dealer id, then campaign id, so two pulls of the same window diff cleanly.
      - `day` string, required: YYYY-MM-DD in `timezone`, the DEALER's calendar day, not UTC and not yours. The first and last day of a window are routinely partial for this reason.
      - `timezone` string, required: The IANA zone this row was actually bucketed in. Never null: it is the dealer's configured zone when they have one, otherwise the server fallback (UTC unless an operator set another). Two rows in one response can carry different zones, so do not sum across them by day string.
      - `partner_dealer_id` string, nullable, required: Your dealer id as recorded on the tracking number. Null when the number carries none, those calls are still counted, in their own row, rather than dropped.
      - `partner_campaign_id` string, nullable, required: Your campaign id, same nullability and same reason.
      - `campaign_label` string, nullable, required: Human label for display only. The ids are the grouping key; the label is the first non-null one seen in the bucket, so it is not a stable identifier and must not be joined on.
      - `calls_total` integer, required: Tracked calls that STARTED on this local day. Calls generated.
      - `connected` integer, required: The two legs were actually joined, including very short connects. ⛔ ALSO INCLUDES a forward that was answered by a dealership voicemail box, because answering-machine detection is off, see the path description.
      - `abandoned` integer, required: The CONSUMER hung up before the legs were joined, while it was ringing, or while the dealer was hearing the whisper. Never merged with the dealer failing to pick up.
      - `dealer_no_answer` integer, required: The DEALER did not take it and it was not voicemail: no answer, busy, unreachable and rejected all land here. The carrier cause is not kept on the durable record, so those four cannot be separated after the fact.
      - `dealer_voicemail` integer, required: An answering-machine verdict on the dealer leg. ⛔ EFFECTIVELY ALWAYS 0 while premium answering-machine detection is off, which is the current product decision; those calls are counted in `connected` instead.
      - `recoveries` integer, required: Abandoned calls a later recovery conversation won back, attributed to the day of the ORIGINAL call. ⛔ 0 on every row while recoveries_wired is false, read it as "not wired", never as "none".
      - `avg_forwarded_seconds` number, nullable, required: Mean billed dealer-leg seconds over the CONNECTED calls in this bucket that have a rated cost row, to one decimal. Null is an honest unknown, nothing rated yet, or the duration lookup did not return, and is deliberately not 0, which would read as "they talked for no time". Ring-outs are excluded from the mean rather than folded in as zeros.
    - `totals` CallTrackingCampaignTotals[], required: The same numbers rolled up per (dealer, campaign) over the whole window. Derived FROM the day rows, so it can never disagree with them.
      - `partner_dealer_id` string, nullable, required
      - `partner_campaign_id` string, nullable, required
      - `campaign_label` string, nullable, required
      - `days` integer, required: ⛔ THE NUMBER OF DAY ROWS THAT CONTRIBUTED, NOT THE LENGTH OF THE WINDOW. A campaign that took calls on 4 days of a 30-day window reports 4. Dividing calls_total by this gives calls per ACTIVE day, which is usually not the average you meant.
      - `calls_total` integer, required
      - `connected` integer, required
      - `abandoned` integer, required
      - `dealer_no_answer` integer, required
      - `dealer_voicemail` integer, required
      - `recoveries` integer, required

## Other responses

- `400`: Validation error
- `401`: Invalid or missing credential
- `403`: Forbidden (missing scope, or resource not owned by the credential org)
- `404`: Not found (code not_found), the call-tracking feature is not open to this credential. EITHER the server-wide master flag is off, OR your organization is not on the per-organization allowlist the feature requires. The two are answered identically on purpose and no database is touched either way. This status never means "no calls matched": an empty window is a 200 carrying days: [].
- `429`: Rate limit exceeded (code rate_limit_exceeded). The ceiling is 100 requests per minute per API key. The response carries Retry-After (seconds) plus X-RateLimit-Limit / X-RateLimit-Remaining / X-RateLimit-Reset, and repeats the seconds in the body as retry_after.
- `500`: Error

---

[API](https://skmtc.dev/corebee/apis/corebee-api.md) · [All operations](https://skmtc.dev/corebee/apis/corebee-api/llms.txt) · [OpenAPI document](https://skmtc.dev/corebee/apis/corebee-api/revisions/a48ea543b4dc?raw)
