# SMS stats API

Every number on the [Metrics dashboard](/docs/guides/sms/tracking-and-metrics) comes from the SMS stats API, and you can read the same aggregates yourself to build your own dashboard, feed a warehouse, or wire a health check into your alerting. The endpoints are read-only and workspace-scoped, and they need an API key with read access to the `sms` scope. They read the same aggregation the dashboard does, so a value you fetch matches what the dashboard shows for the same range.

Typed methods ship in the [TypeScript](/docs/sdks/typescript), [Python](/docs/sdks/python), [PHP](/docs/sdks/php), and [Go](/docs/sdks/go) SDKs under `sms.stats`, the [`bird` CLI](/docs/cli) exposes them as `bird sms stats`, and an agent reaches them through the `sms_stats_*` [MCP tools](/docs/ai/mcp-server). Full request and response schemas are in the [API reference](/docs/api/reference/get-sms-stats-summary).

## The aggregate and the time series

Three endpoints cover the top of the dashboard:

- **`GET /v1/sms/stats/summary`** returns one aggregate row for the whole window: the lifecycle counts (accepted, sent, delivered, undelivered, failed, rejected, expired), the derived `delivery_rate` and `failure_rate`, and the processing, delivery, and total latency percentiles (p50, p95, p99). Pass `compare=previous_period` and the response also includes the preceding equal-length window and the change from it.
- **`GET /v1/sms/stats/daily`** and **`GET /v1/sms/stats/hourly`** return the lifecycle counts one row per day or per hour. Rates and latency are whole-window figures, so read those from `/summary` rather than per bucket.

Every rate comes back as a **fraction between 0 and 1**, so a `delivery_rate` of `0.9739` is 97.39%. A rate whose denominator is zero is **`null`**, which is how a period that accepted nothing reports `delivery_rate` rather than reading `0`. And figures attribute by **send time, not event time**: a delivery confirmed today for a message accepted yesterday counts against yesterday. A recent window therefore under-reports `delivered` while its delivery reports are still arriving, and its counts only ever grow toward the truth, so treat the last few hours as provisional rather than final.

## Choosing the window

`from` and `to` take either a calendar day (`YYYY-MM-DD`) or an RFC 3339 instant, and which forms an endpoint accepts differs:

| Endpoint   | Bounds                      | Maximum window                     |
| ---------- | --------------------------- | ---------------------------------- |
| `/summary` | Both days, or both instants | 365 days, or 720 hours on instants |
| `/daily`   | Calendar days               | 365 days                           |
| `/hourly`  | RFC 3339 instants           | 720 hours (30 days)                |

Instant bounds are hour-grain, which is what makes a rolling "last 24 hours" a single request. On `/summary`, mixing a day with an instant returns a `422`.

Set `timezone` to an IANA identifier such as `America/New_York` to have day and hour boundaries, and the defaults used when you omit `from` and `to`, computed in that zone instead of UTC. While `timezone` is set, `from` and `to` must not include a UTC offset of their own.

## Breakdowns

Seven endpoints slice the same delivery numbers by one dimension:

- **Where it went**: `/countries` (the destination country) and `/carriers` (the carrier that handled it).
- **What you sent**: `/originators` (the sender address it went out from), `/categories`, and `/tags`.
- **How it ended**: `/statuses` (one row per lifecycle status with activity) and `/error-codes`.

All of them live under `/v1/sms/stats/`. Rows come back ranked descending by a `sort` metric and capped at `limit` (default 50, maximum 200), alongside `total`, the number of distinct values the dimension held in the window, which is how you know a capped result is the top slice of something larger. Rows whose sort metric is a rate with a zero denominator go last.

`sort` defaults to `accepted` on the volume breakdowns and to `failed` on `/error-codes`, which is the metric that breakdown exists to rank by. `/error-codes` groups by Bird's own normalized failure reason rather than a raw carrier code, and that value is the same one the `error_code` filter on [`GET /v1/sms/messages`](/docs/api/reference/list-sms-messages) takes, so a row joins straight to the messages behind it, which is the usual next step after spotting a spike.

`/tags` counts only tagged messages, and a message carrying several tags is counted once under each, so its rows do not sum to the period total. Reconcile it against itself, not against `/summary`.

`/statuses` is the odd one out: its rows carry a status and a count rather than the full delivery block, because a status _is_ the outcome and there is nothing further to break down.

## Received messages

Six more endpoints under `/v1/sms/stats/inbound/` count what your numbers received rather than what you sent: `/summary`, `/daily` and `/hourly` for the totals and the series, and `/countries`, `/operators` and `/numbers` for the breakdowns. They take the same window and timezone parameters as their outbound counterparts.

Two things differ from the outbound family. Each row carries a plain `received` count, with no delivery block or rates: an inbound message either arrived or did not, so there is no lifecycle to aggregate. And `/operators` **excludes messages whose sending operator the carrier did not report**, so its rows can sum to less than `/inbound/summary` for the same period; reconcile against the summary, not against the operator rows.

## Next steps

- [Metrics](/docs/guides/sms/tracking-and-metrics): the dashboard these numbers render
- [SMS log](/docs/guides/sms/sms-log): the per-message view behind the aggregates
- [Events](/docs/guides/sms/events): the per-message event stream the aggregates are built from