Analytics
Six read-only endpoints over one workspace-local date range. They answer how many conversations happened, how fast somebody replied, when the week is busy, who carried the load, and how a campaign performed.
What is here
Every route takes the same from and to range and echoes it back in range. There is no cursor and no page object: an over-long window is refused rather than paged.
All six require analytics.read, which owner, admin and manager hold. Both credentials work — see authentication.
The range
| Parameter | Values | Description |
|---|---|---|
| from, to | YYYY-MM-DD | Inclusive dates in the WORKSPACE's timezone, not UTC and not the caller's. Last seven days is to minus six, not minus seven. |
| granularity | day, hour | Timeseries only: day or hour. The hourly cap is tighter than the daily one. |
Ask for the caps before you build a range
GET /v1/analytics/limits publishes max_range_days, max_hourly_range_days, the timezone, the granularities and the agent and campaign caps. An over-long window is refused with a 422 naming the cap in details.issues — it is never quietly narrowed — so a picker that does not ask first can only discover the ceiling by hitting it.
curl https://whats.azzamkh.sa/api/v1/analytics/limits \
-H "Authorization: Bearer $WA_API_KEY"
# 200 OK — every number here is DEPLOYMENT configuration and is only
# knowable from this route. Read them; never hardcode them.
# {
# "timezone": "Asia/Riyadh",
# "default_range_days": ...,
# "max_range_days": ...,
# "max_hourly_range_days": ...,
# "granularities": ["day", "hour"],
# "max_agents": ...,
# "max_campaigns": ...
# }
curl "https://whats.azzamkh.sa/api/v1/analytics/summary?from=2026-08-08&to=2026-08-14" \
-H "Authorization: Bearer $WA_API_KEY"
# 200 OK — a null average is NOT a zero: it means measured is 0.
# {
# "range": {
# "from": "2026-08-08", "to": "2026-08-14",
# "timezone": "Asia/Riyadh", "days": 7, "max_range_days": 180
# },
# "conversations": { "opened": 214, "closed": 198, "reopened": 11 },
# "messages": { "inbound": 1902, "outbound": 2411 },
# "first_response": {
# "measured": 187, "average_ms": 214000, "max_ms": 3600000
# },
# "resolution": { "measured": 0, "average_ms": null, "max_ms": null }
# }range on every response carries from, to, timezone, days and max_range_days. Render the server's range, not the one you asked for.
The responses
Summary
| Field | Description |
|---|---|
| conversations.opened | Conversations opened in the range. |
| conversations.closed | CLOSURES in the range — see the counting note below. |
| conversations.reopened | Conversations reopened in the range. |
| messages.inbound, messages.outbound | Inbound and outbound message counts. |
| first_response, resolution | First-response and resolution times, each as measured, average_ms and max_ms. |
A null average is not a zero
average_ms and max_ms are null exactly when measured is 0. Coalescing them to 0 prints a reply time of zero milliseconds for a workspace nobody replied in. And measured is a SAMPLE SIZE: show it beside the average, because an average over three conversations is not the same claim as one over three thousand.
Timeseries
| Field | Description |
|---|---|
| bucket, date, hour | The bucket key, its date, and its hour when granularity is hour. |
| conversations_opened/closed/reopened | Opened, closed and reopened in that bucket. |
| messages_in, messages_out | Inbound and outbound in that bucket. |
| first_response, resolution | The same duration summaries, per bucket. |
The series is DENSE: it already contains its zeros, on purpose. A sparse series draws a straight line across a quiet weekend, which reads as activity that did not happen. Never gap-fill it, never re-bucket it and never re-sort it.
Heatmap
Exactly 168 cells — seven weekdays by twenty-four hours — each with messages_in, messages_out and conversations_opened, plus a max block to scale against. The zeros are already there.
Agents
One row per member, with conversations assigned, replies sent, closures and a first-response summary. It is CAPPED rather than paginated: the rows are sorted then cut, and truncated tells you the answer is genuinely the top of the table rather than an arbitrary page.
Campaign funnels
| Field | Description |
|---|---|
| funnel.queued/sent/delivered/read/failed | Cumulative counts, so queued is at least sent is at least read. |
| rates.delivery | delivered divided by sent. |
| rates.read | read divided by delivered. |
| rates.failure | failed divided by queued. |
Each rate has a different denominator
Rates are FRACTIONS in 0 to 1, and null means the denominator was zero — not that the rate was zero. This funnel is CUMULATIVE, counted from timestamps; the campaign's own stats are DISJOINT, counted from current state. Do not reconcile them.
How things are counted
- The closed figure counts CLOSURES, from an append-only trail: a thread closed twice counts twice, and a reopen no longer erases the closure it undid. So closed MAY EXCEED opened. Label it closures, or it reads as a bug.
- Series and heatmap are complete as delivered. Anything you add — a gap fill, a re-sort, a re-bucket — is a number nobody counted.
- Every duration carries its own
measured. Two averages with wildly different sample sizes are not comparable, and only the response can tell you that.
Errors that matter here
| Code | HTTP | When it happens |
|---|---|---|
| VALIDATION_ERROR | 422 | A malformed date, a reversed range, or a window longer than the cap. details.issues names which. |
| FORBIDDEN | 403 | The caller does not hold analytics.read. |
Every code and its status is on the errors page.