---
title: "Get engagement by email client"
method: GET
path: "/v1/email/stats/clients"
tags: ["email-stats"]
---

# Get engagement by email client

`GET /v1/email/stats/clients`

## Request

- Base URL: `https://us1.platform.bird.com`
- URL: `https://us1.platform.bird.com/v1/email/stats/clients`
- Auth: one of:
  - HTTP bearer
  - API key in cookie `bird_session`

Returns engagement counts (opens and clicks) for the requested period, grouped by the email client, operating system, or device type they were recorded from. Use it for the classic view of opens by mail client, for example the share of opens from Apple Mail compared with Gmail and Outlook. The reading environment is only known from open and click events, so rows have engagement counts but no delivery counts or rates.

Use `group_by` to choose the facet: `email_client` (the default), `os`, or `device_type`. Each row fills in the facet you chose and leaves the other two `null`. Rows are ranked by the `sort` metric, `unique_opens` by default, and paginated with the requested `limit` (50 by default, 200 at most).

Rows are computed against event time rather than send time. The window can span at most 365 days. Ask for more and you get a `422`.

## Query parameters

- `starting_after` string
- `ending_before` string
- `from` string, date
- `to` string, date
- `timezone` string
- `category` string
- `group_by` 'email_client' | 'os' | 'device_type'
- `sort` 'opens' | 'opens_non_prefetched' | 'unique_opens' | 'unique_opens_non_prefetched' | 'clicks' | 'unique_clicks': Metric to rank rows by, applied descending. Shared by the engagement-only breakdowns (locations, email clients), which report open and click counts but no rates because delivery events provide no per-dimension denominator.
- `limit` integer

## Headers

- `X-Workspace-Id` string
- `X-Organization-Id` string

## Response `200`

Per-client engagement breakdown for the requested period.

- EmailStatsByClientResponse: Per-client engagement breakdown for the requested period, grouped by the requested `group_by` facet, ranked by the `sort` metric (default `unique_opens`) descending and paginated with the requested `limit` (default 50, max 200).
  - `period` EmailStatsPeriod, required: The date range this response was actually computed against. Echoed back so clients can render the period without tracking it themselves and so cached responses can be keyed by what was queried.
    - `from` string, date, required: Inclusive start date the response covers (YYYY-MM-DD).
    - `to` string, date, required: Inclusive end date the response covers (YYYY-MM-DD).
    - `data_as_of` string, date-time, nullable: The instant the statistics in this response are current to: events recorded up to roughly this time are reflected, while more recent events may not be yet. Statistics are served from a rolling aggregation that refreshes every few seconds, so a response reflects data from up to a few seconds ago. Use this field to label data freshness (for example "as of 14:03") rather than assuming the numbers are to-the-second. Null when the freshness boundary is not being reported.
  - `data` EmailClientStatsPoint[], required: Client breakdown rows, ranked by the `sort` metric (default `unique_opens`) descending. Empty when no opens or clicks with a detected client occurred in the period.
    - `email_client` string, nullable, required: The mail client this row aggregates (for example `Gmail`, `Apple Mail`, `Outlook`). Populated only when `group_by=email_client`. Null otherwise.
    - `os` string, nullable, required: The operating system this row aggregates (for example `iOS`, `Android`, `Windows`, `macOS`). Populated only when `group_by=os`. Null otherwise.
    - `device_type` string, nullable, required: The device type this row aggregates (for example `mobile`, `desktop`, `tablet`). Populated only when `group_by=device_type`. Null otherwise.
    - `engagement` EmailEngagementCounts, required: Open and click counts for a breakdown row whose dimension is resolved from engagement events only. `opens`, `opens_non_prefetched`, and `clicks` count each event. The same recipient opening or clicking more than once counts each time. The `unique_*` fields count distinct recipients instead, so a recipient who opened five times only counts once there. Rates and unsubscribe counts are not included here. A per-dimension delivered count is unavailable as a rate's denominator, and an unsubscribe event has none of the information this breakdown is grouped by, so it cannot be placed on a row.
      - `opens` integer, required: Distinct open events, counting repeat opens from the same recipient and opens auto-fetched by inbox privacy features (such as Apple Mail Privacy Protection and the Gmail image proxy).
      - `opens_non_prefetched` integer, required: Distinct open events excluding those auto-fetched by inbox privacy features. Same event-counting semantics as `opens`, with prefetched opens removed.
      - `unique_opens` integer, required: Distinct recipients who opened at least once, including opens auto-fetched by inbox privacy features.
      - `unique_opens_non_prefetched` integer, required: Distinct recipients who opened at least once, excluding opens auto-fetched by inbox privacy features.
      - `clicks` integer, required: Distinct click events, counting repeat clicks from the same recipient.
      - `unique_clicks` integer, required: Distinct recipients who clicked at least once.
  - `total` integer, required: Total number of distinct values of the requested `group_by` facet with activity in the period, regardless of `limit`. Pass `next_cursor` as `starting_after` to request the next page.
  - `next_cursor` string, nullable, required: Cursor for the next page. Pass back as `starting_after` to advance forward. `null` when no next page exists.
  - `prev_cursor` string, nullable, required: Cursor for the previous page. Pass back as `ending_before` to step backward. `null` when no previous page exists.
  - `refresh_cursor` string, nullable, required: Refresh anchor, the first row of this response. Pass back as `ending_before` to fetch what precedes it in the current sort order. On a newest-first sort those are the items that have appeared since; on any other sort they are the items that sort earlier, so refreshing such a list means re-fetching it instead. Non-`null` whenever `data` is non-empty; `null` only on an empty page. Distinct from `prev_cursor`.

## Other responses

- `400`: Bad request
- `401`: Authentication required
- `403`: Insufficient permissions
- `404`: Resource not found
- `422`: The request has invalid field values, violates a business rule, or carries a query parameter the endpoint does not declare. Field validation errors use `type: validation_error` and include the affected fields in `details`. Business-rule errors identify the failed rule in `type`.
- `429`: Rate limit exceeded
- `500`: Internal server error
- `503`: The service is temporarily unavailable. If `Retry-After` is present, wait for that delay before retrying; otherwise, retry with exponential backoff. Reuse the same idempotency key and request when retrying a mutation.

---

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