Skip to content
Contents
Usage and account

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.

GET/v1/analytics/limitsGET/v1/analytics/summaryGET/v1/analytics/timeseriesGET/v1/analytics/heatmapGET/v1/analytics/agentsGET/v1/analytics/campaigns

All six require analytics.read, which owner, admin and manager hold. Both credentials work — see authentication.

The range

ParameterValuesDescription
from, toYYYY-MM-DDInclusive dates in the WORKSPACE's timezone, not UTC and not the caller's. Last seven days is to minus six, not minus seven.
granularityday, hourTimeseries 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
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

FieldDescription
conversations.openedConversations opened in the range.
conversations.closedCLOSURES in the range — see the counting note below.
conversations.reopenedConversations reopened in the range.
messages.inbound, messages.outboundInbound and outbound message counts.
first_response, resolutionFirst-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

FieldDescription
bucket, date, hourThe bucket key, its date, and its hour when granularity is hour.
conversations_opened/closed/reopenedOpened, closed and reopened in that bucket.
messages_in, messages_outInbound and outbound in that bucket.
first_response, resolutionThe 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

FieldDescription
funnel.queued/sent/delivered/read/failedCumulative counts, so queued is at least sent is at least read.
rates.deliverydelivered divided by sent.
rates.readread divided by delivered.
rates.failurefailed 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

CodeHTTPWhen it happens
VALIDATION_ERROR422A malformed date, a reversed range, or a window longer than the cap. details.issues names which.
FORBIDDEN403The caller does not hold analytics.read.

Every code and its status is on the errors page.