# Reports API - Message Stats

Use the message stats reports to retrieve the full report for one message (totals, rates, bounce and preference breakdowns, links, email clients, unsubscribe reasons, polls, and A/B variants), or to compare several messages or a whole folder side by side with a combined total.

---

## Endpoints

| Method | URL | Description |
|---|---|---|
| GET | `https://e1.envoke.com/v2/reports/messages/{ID}` | Return the full report for one message. |
| GET | `https://e1.envoke.com/v2/reports/messages/compare` | Return totals and rates for several messages, or for every message in a folder, plus a combined block. |

Authentication is via HTTP Basic Auth using your API ID and API Key.

---

## Get a Single Message Report

```
GET https://e1.envoke.com/v2/reports/messages/{ID}
```

Returns one JSON object: the message, its ten [metrics](https://envoke-developers.pages.dev/v2/reports/fields/#fields) in `totals`, its six [rates](https://envoke-developers.pages.dev/v2/reports/fields/#rates) in `rates`, and the breakdowns below. Replace `{ID}` with the Envoke message ID. The `totals` are the same numbers the [message activity list](https://envoke-developers.pages.dev/v2/reports/messages/) returns for the message with the same activity window. To list the contacts behind a total, a bounce reason, or a link, use the [message contacts report](https://envoke-developers.pages.dev/v2/reports/message-contacts/).

The report is available for sent campaigns, automated messages, and campaigns that have started sending. A message that does not exist, belongs to another account, is deleted, or has not started sending (a draft or a scheduled message) returns `[]` with HTTP 200.

### Query Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `activity_from` | string | — | Count only activity on or after this date or time. See [Date parameters](https://envoke-developers.pages.dev/v2/reports/fields/#date-parameters). |
| `activity_to` | string | — | Count only activity on or before this date or time. |

Every count in the report, including the breakdowns, `links`, and `email_clients`, uses the window. `unsubscribe_reasons` and `polls` describe contacts as they are now and cannot be limited to a window, so when `activity_from` or `activity_to` is given both are `null` (see below). Any other parameter, including `sent_from` and `sent_to`, returns HTTP 400 (`Parameter 'sent_from' is not valid`).

### Response fields

| Field | Type | Description |
|---|---|---|
| `id`, `name`, `subject`, `type`, `sent_time`, `has_variants` | | As in the [message activity list](https://envoke-developers.pages.dev/v2/reports/messages/#response-fields). |
| `totals` | object | The ten metrics. |
| `rates` | object | The six rates. |
| `bounces` | array | Contacts per bounce reason. |
| `preference_changes` | array | Contacts per preference change. |
| `links` | array | Contacts per tracked link. |
| `email_clients` | object or null | Open records (not contacts) per email client. `null` for messages sent before June 2022. |
| `unsubscribe_reasons` | array or null | Contacts per reason given when unsubscribing. `null` when an activity window is given. |
| `polls` | array or null | Answers to each poll in the message; `[]` when the message has none. `null` when an activity window is given. |
| `variants` | array | One object per A/B version, always present when `has_variants` is `true`; `[]` when the message is not an A/B test, and when its A/B block no longer exists. |

### `bounces`

One entry per bounce reason, always all six, in this order. `contacts` is the number of distinct contacts with that reason:

| `type` | `label` | Codes |
|---|---|---|
| `hard_bounce` | `Hard bounce` | `2/12` |
| `soft_bounce` | `Soft bounce` | `2/11` |
| `deferred` | `Temporarily deferred` | `2/14` |
| `rejected` | `Rejected` | `2/15` |
| `invalid_address` | `Invalid email address` | `2/16` |
| `other` | `Other` | `2/10` (SMTP error) and the retired `2/13` |

A contact with two bounce reasons is in two entries, so the entries can add up to more than `totals.bounces`. On a few messages sent before 2015, bounce records with a retired sub-type count in `totals.bounces` but appear in no entry, and no `bounce_type` of the [message contacts report](https://envoke-developers.pages.dev/v2/reports/message-contacts/) selects them. `hard_bounces` covers `hard_bounce` and `invalid_address`; `soft_bounces` covers `soft_bounce`, `deferred`, `rejected`, and the SMTP-error part of `other`. The `type` values are stable keys; `label` is English display text and may change.

The contacts behind an entry: [message contacts](https://envoke-developers.pages.dev/v2/reports/message-contacts/) with `event=bounced&bounce_type={type}`.

### `preference_changes`

One entry per kind of preference change made from the message, always all six, in this order:

| `type` | `label` | Codes |
|---|---|---|
| `revoked_consent` | `Revoked consent` | `5/11` |
| `one_click_unsubscribe` | `One-click unsubscribe` | `5/30` |
| `spam_reported` | `Spam reported` | `5/12` and `5/13` |
| `subscription_changed` | `Subscription changed` | `5/17` |
| `language_changed` | `Language changed` | `5/16` |
| `express_consent_granted` | `Express consent granted` | `5/21` |

`totals.unsubscribes` is the distinct contacts across `revoked_consent` and `one_click_unsubscribe`; `totals.spam_complaints` equals `spam_reported`. Each entry equals the Sent message report row with the same label, except `spam_reported`, which combines two report rows. A one-click unsubscribe on a message sent with a primary subscription removes the contact from that subscription but records only `one_click_unsubscribe`, not `subscription_changed`. Nurture campaign changes (`5/18`) are not reported.

The contacts behind `revoked_consent` and `one_click_unsubscribe` together: [message contacts](https://envoke-developers.pages.dev/v2/reports/message-contacts/) with `event=unsubscribed`; behind `spam_reported`: `event=spam_reported`. The other three entries have no contact list.

### `links`

One entry per tracked link in the message with at least one click (inside the window, when one is given), most clicked first; a link nobody clicked has no entry:

| Field | Type | Description |
|---|---|---|
| `id` | string | The tracked link's ID. |
| `url` | string | The link as written in the message, with Envoke's tracking parameters (`utm_*`, `eid`) removed; `""` for the view-online and preferences links. Merge tags are not replaced, so `url` can be or contain a merge tag (`{@customfield-url_test}`) rather than a valid URL. |
| `title` | string | The link's anchor text, or a shortened URL when it has none, cut at 60 bytes (fewer characters when the text has accented or other non-ASCII characters); the view-online and preferences links have a fixed title. |
| `type` | string | `link` for a link in the content, including links to pages with Envoke forms; `view` for the view-online link; `pref` for the preferences and unsubscribe links. |
| `clicks` | integer | Distinct contacts who clicked this link. |

A contact who clicked two links is in both entries, so the entries can add up to more than `totals.clicks`. Poll answers are tracked as clicks and counted in `totals.clicks`, but are reported under `polls`, not here. When the same URL appears twice in a message, each copy has its own entry; the Sent message report merges them into one row whose count is the sum of the entries.

The contacts behind an entry: [message contacts](https://envoke-developers.pages.dev/v2/reports/message-contacts/) with `event=clicked&link_id={id}`.

### `email_clients`

The email client panel of the Sent message report, from the open records' user agents. Every figure in this block counts **open records**, one per contact per email client, not distinct contacts, so `known_opens` can be larger than `totals.opens`. It is the only part of the report that counts records rather than contacts.

| Field | Type | Description |
|---|---|---|
| `known_opens` | integer | Open records whose email client was recorded. |
| `machine_open_records` | integer | Open records made by Apple Mail Privacy Protection (user agent `Mozilla/5.0`), which Envoke classifies as machine opens. A contact has at most one such record per message, so this also equals the number of contacts with at least one machine open, including contacts who also opened in another email client. There is no machine-opens figure in `totals`. |
| `by_app` | array | `{ "name", "opens" }` per email client, always five entries: `Apple Mail` (including the machine-open records), `Gmail`, `Outlook`, and `Yahoo`, most opens first (ties in that order), then `Other` for every client Envoke does not recognise, always last and `0` when every client was recognised. |

`email_clients` is `null` for a message sent before 1 June 2022, when Envoke began recording user agents. Opens recorded with no user agent are in neither `known_opens` nor `by_app`, so `known_opens` can be smaller than the report's Opens row.

### `unsubscribe_reasons`

One entry per reason, in the order the preferences page lists them, for reasons at least one contact chose:

| Field | Type | Description |
|---|---|---|
| `reason` | string | `no_longer_interested`, `never_subscribed`, `too_many_emails`, `irrelevant`, or `other`. |
| `contacts` | integer | Contacts whose current consent was revoked from this message with this reason. |

This counts contacts **as they are now**: a contact who unsubscribed from this message and later gave consent again is no longer counted. Because it describes the present rather than activity at a time, `unsubscribe_reasons` is `null` when `activity_from` or `activity_to` is given. Giving a reason is optional, so the entries usually add up to less than `totals.unsubscribes`.

### `polls`

One entry per poll in the message; `[]` when it has none, and `null` when an activity window is given.

| Field | Type | Description |
|---|---|---|
| `id` | string | The poll's ID. |
| `name` | string | The poll question. |
| `anonymous` | boolean | `true` for an anonymous poll. The counts are still given; the contacts are not identifiable in Envoke. |
| `answers` | array | `{ "id", "text", "contacts" }` per answer, in the order the Sent message report lists them: by the answer's sort position, then alphabetically by text. Envoke stores poll answers without a sort position, so this is normally alphabetical, not the order of the answers in the message. `contacts` is the number of contacts who currently hold that answer. |

Poll answers are stored on the contact, so the counts describe contacts **as they are now**, not activity: every contact in the account who holds the answer today is counted, however they came to hold it. For that reason `polls` is `null` when `activity_from` or `activity_to` is given.

### A/B tests

For an A/B test, `has_variants` is `true`, the message's `subject` is `null` for a subject-line test, `totals` and `rates` cover every version together (including the contacts sent the winning version after the test), and `variants` has one object per version, each with its own subject or sender. `variants` is always included when `has_variants` is `true`; there is no parameter to request it or leave it out. It is `[]` when the message's A/B block no longer exists, although `has_variants` is still `true`. For a test that does not vary the subject (preheader, sender, or content), `subject` is the stored subject, which can be the literal placeholder `{@ab_test}` (see [Messages](https://envoke-developers.pages.dev/v2/reports/fields/#messages)).

| Field | Type | Description |
|---|---|---|
| `id` | string | The version's ID. |
| `name` | string | `A`, `B`, `C`, ... in the order of the versions, as in the Sent message report's tabs. |
| `test_type` | string | What the test varies: `subject`, `preheader`, `sender`, or `content`. |
| `subject` | string or null | This version's subject line, for a subject test; otherwise `null`. |
| `sender` | string or null | This version's sender as `Name <email>`, for a sender test; otherwise `null`. |
| `winner` | boolean | `true` for the version Envoke chose as the winner; `false` for every version while the test is running, and when the test ended without a winner being recorded. |
| `totals` | object | The ten metrics for the contacts who were sent this version during the test. |
| `rates` | object | The six rates for those contacts. |

Each version counts the contacts sent that version, as the Sent message report's version tabs do: the contacts assigned to it during the test phase and, for the winning version, the contacts sent the winner after the test. So the winner's `sends` is usually the larger figure, and the versions' `sends` usually add up to `totals.sends`. They can add up to less, because contacts sent the message outside the versions' assignment (overrides and resends, for example) are in `totals` but in no version, and to more on old tests where a contact was recorded under two versions. A version's metrics relate to the Version A or Version B tab of the Sent message report (`.variant_stats[n].stats` in `stats.json`) exactly as the message's `totals` relate to the Combined tab: `sends` and `clicks` are equal, `opens` and `bounces` are less than or equal, and so on per the [glossary](https://envoke-developers.pages.dev/v2/reports/fields/#field-details).

### Response

```json
{
  "id": "2004",
  "name": "Pirates Opening Night",
  "subject": null,
  "type": "campaign",
  "sent_time": "2026-05-14 09:30:00",
  "has_variants": true,
  "totals": {
    "sends": 386,
    "delivered": 379,
    "bounces": 7,
    "hard_bounces": 4,
    "soft_bounces": 3,
    "opens": 201,
    "clicks": 64,
    "unsubscribes": 2,
    "spam_complaints": 0,
    "skips": 3
  },
  "rates": {
    "delivered": 0.9819,
    "open": 0.5303,
    "click": 0.1689,
    "click_to_open": 0.3184,
    "bounce": 0.0181,
    "unsubscribe": 0.0052
  },
  "bounces": [
    { "type": "hard_bounce", "label": "Hard bounce", "contacts": 3 },
    { "type": "soft_bounce", "label": "Soft bounce", "contacts": 1 },
    { "type": "deferred", "label": "Temporarily deferred", "contacts": 1 },
    { "type": "rejected", "label": "Rejected", "contacts": 0 },
    { "type": "invalid_address", "label": "Invalid email address", "contacts": 1 },
    { "type": "other", "label": "Other", "contacts": 1 }
  ],
  "preference_changes": [
    { "type": "revoked_consent", "label": "Revoked consent", "contacts": 1 },
    { "type": "one_click_unsubscribe", "label": "One-click unsubscribe", "contacts": 1 },
    { "type": "spam_reported", "label": "Spam reported", "contacts": 0 },
    { "type": "subscription_changed", "label": "Subscription changed", "contacts": 1 },
    { "type": "language_changed", "label": "Language changed", "contacts": 0 },
    { "type": "express_consent_granted", "label": "Express consent granted", "contacts": 3 }
  ],
  "links": [
    { "id": "88125", "url": "https://example.org/tickets/pirates", "title": "Get your tickets", "type": "link", "clicks": 39 },
    { "id": "88126", "url": "https://example.org/shows/pirates-of-penzance", "title": "About the show", "type": "link", "clicks": 18 },
    { "id": "88129", "url": "", "title": "Envoke Preferences / Unsubscribe", "type": "pref", "clicks": 4 },
    { "id": "88128", "url": "", "title": "Envoke View Message in Browser", "type": "view", "clicks": 3 }
  ],
  "email_clients": {
    "known_opens": 214,
    "machine_open_records": 47,
    "by_app": [
      { "name": "Apple Mail", "opens": 88 },
      { "name": "Gmail", "opens": 64 },
      { "name": "Outlook", "opens": 41 },
      { "name": "Yahoo", "opens": 7 },
      { "name": "Other", "opens": 14 }
    ]
  },
  "unsubscribe_reasons": [
    { "reason": "too_many_emails", "contacts": 1 }
  ],
  "polls": [
    {
      "id": "61",
      "name": "Which performance will you attend?",
      "anonymous": false,
      "answers": [
        { "id": "7301", "text": "Friday 7:30 p.m.", "contacts": 48 },
        { "id": "7302", "text": "Saturday matinee", "contacts": 31 },
        { "id": "7303", "text": "Saturday 7:30 p.m.", "contacts": 22 }
      ]
    }
  ],
  "variants": [
    {
      "id": "5121",
      "name": "A",
      "test_type": "subject",
      "subject": "Opening night: The Pirates of Penzance",
      "sender": null,
      "winner": false,
      "totals": {
        "sends": 96,
        "delivered": 94,
        "bounces": 2,
        "hard_bounces": 1,
        "soft_bounces": 1,
        "opens": 49,
        "clicks": 14,
        "unsubscribes": 1,
        "spam_complaints": 0,
        "skips": 0
      },
      "rates": {
        "delivered": 0.9792,
        "open": 0.5213,
        "click": 0.1489,
        "click_to_open": 0.2857,
        "bounce": 0.0208,
        "unsubscribe": 0.0104
      }
    },
    {
      "id": "5122",
      "name": "B",
      "test_type": "subject",
      "subject": "Curtain up on Pirates, {fname}!",
      "sender": null,
      "winner": true,
      "totals": {
        "sends": 97,
        "delivered": 95,
        "bounces": 2,
        "hard_bounces": 1,
        "soft_bounces": 1,
        "opens": 58,
        "clicks": 19,
        "unsubscribes": 0,
        "spam_complaints": 0,
        "skips": 0
      },
      "rates": {
        "delivered": 0.9794,
        "open": 0.6105,
        "click": 0.2,
        "click_to_open": 0.3276,
        "bounce": 0.0206,
        "unsubscribe": 0
      }
    }
  ]
}
```

### Errors

| Response | Cause |
|---|---|
| `[]` with HTTP 200 | The message does not exist, belongs to another account, is deleted, or has not started sending. |
| HTTP 400, `Parameter 'sent_from' is not valid` | A parameter other than `activity_from` and `activity_to`. |
| HTTP 400, `Invalid 'activity_from': '...' (expected YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)` | A bad date (see [Date parameters](https://envoke-developers.pages.dev/v2/reports/fields/#date-parameters)). |
| HTTP 404, `404 Not Found` | `{ID}` is not a number. |

---

## Compare Messages

```
GET https://e1.envoke.com/v2/reports/messages/compare
```

Returns the totals and rates of several messages side by side, and a `combined` block for the set. Give the set either as message IDs or as a folder:

- `message_id[]=2001&message_id[]=2004` — the listed messages. Each must be a sent campaign or an automated message in your account. A repeated ID counts once: the message appears once and `message_count` counts it once.
- `group_id=31` — every sent campaign and automated message filed in the folder or in any of its subfolders, at any depth, as in the folder report in Envoke.

The per-message `totals` and `rates` are the same numbers the [single report](#get-a-single-message-report) and the [message activity list](https://envoke-developers.pages.dev/v2/reports/messages/) return for each message with the same activity window.

With an activity window, messages launched after `activity_to` are left out of the set, exactly as the list leaves them out: a folder's messages launched later are in neither `messages` nor `combined`, and a `message_id` launched later is dropped without an error.

### Query Parameters

| Parameter | Type | Default | Description |
|---|---|---|---|
| `message_id` | integer or array | — | One or more message IDs, at most 100. Not with `group_id`. |
| `group_id` | integer | — | One message folder ID; its subfolders are included. Not with `message_id`. |
| `activity_from` | string | — | Count only activity on or after this date or time. |
| `activity_to` | string | — | Count only activity on or before this date or time. |

Exactly one of `message_id` and `group_id` is required. Any other parameter returns HTTP 400.

### The combined block

`combined.totals` counts **distinct contacts across the whole set**: a contact sent two of the messages counts once in `sends`, a contact who opened both counts once in `opens`. So `combined.totals.sends` is the number of different people the set reached, and it is less than or equal to the sum of the messages' `sends`. The same holds for every metric:

| Metric | Combined value |
|---|---|
| `sends`, `bounces`, `hard_bounces`, `soft_bounces`, `opens`, `clicks`, `unsubscribes`, `spam_complaints`, `skips` | Contacts with a matching record for any message in the set. |
| `delivered` | Lifetime: combined `sends` − combined `bounces`. With an activity window: contacts who, for at least one message in the set, were sent it inside the window and have no bounce for it (see [`delivered`](https://envoke-developers.pages.dev/v2/reports/fields/#delivered)); never negative. |

`combined.rates` are computed from `combined.totals` with the formulas in [Rates](https://envoke-developers.pages.dev/v2/reports/fields/#rates), so they are rates of the set's reach, not averages of the messages' rates. `combined.message_count` is the number of messages in the set, after the launch cut-off when an activity window is given.

A/B tests count as one message each; their versions are not shown.

### Response

`messages` is in the same order as the [message activity list](https://envoke-developers.pages.dev/v2/reports/messages/#ordering-and-pagination): newest `sent_time` first, then highest `id`.

```json
{
  "messages": [
    {
      "id": "2004",
      "name": "Pirates Opening Night",
      "sent_time": "2026-05-14 09:30:00",
      "totals": {
        "sends": 386,
        "delivered": 379,
        "bounces": 7,
        "hard_bounces": 4,
        "soft_bounces": 3,
        "opens": 201,
        "clicks": 64,
        "unsubscribes": 2,
        "spam_complaints": 0,
        "skips": 3
      },
      "rates": {
        "delivered": 0.9819,
        "open": 0.5303,
        "click": 0.1689,
        "click_to_open": 0.3184,
        "bounce": 0.0181,
        "unsubscribe": 0.0052
      }
    },
    {
      "id": "2001",
      "name": "Spring Season Announcement",
      "sent_time": "2026-04-15 10:00:00",
      "totals": {
        "sends": 412,
        "delivered": 403,
        "bounces": 9,
        "hard_bounces": 5,
        "soft_bounces": 4,
        "opens": 188,
        "clicks": 57,
        "unsubscribes": 4,
        "spam_complaints": 1,
        "skips": 6
      },
      "rates": {
        "delivered": 0.9782,
        "open": 0.4665,
        "click": 0.1414,
        "click_to_open": 0.3032,
        "bounce": 0.0218,
        "unsubscribe": 0.0097
      }
    }
  ],
  "combined": {
    "message_count": 2,
    "totals": {
      "sends": 702,
      "delivered": 686,
      "bounces": 16,
      "hard_bounces": 9,
      "soft_bounces": 7,
      "opens": 331,
      "clicks": 108,
      "unsubscribes": 6,
      "spam_complaints": 1,
      "skips": 9
    },
    "rates": {
      "delivered": 0.9772,
      "open": 0.4825,
      "click": 0.1574,
      "click_to_open": 0.3263,
      "bounce": 0.0228,
      "unsubscribe": 0.0085
    }
  }
}
```

Here 96 contacts were sent both messages, so combined `sends` is 702, not 798, and the combined `open` rate (0.4825) is not the average of the two messages' rates.

A folder that exists but holds no sent or automated messages returns `messages` `[]` and a `combined` block with `message_count` `0`, every total `0`, and every rate `null`.

### Errors

Invalid requests return HTTP 400 with the validation envelope. Unknown message IDs are all listed:

```json
{
  "success": false,
  "result_data": {
    "errors": [
      "No message found for 'message_id': 2098",
      "No message found for 'message_id': 2099"
    ]
  },
  "result_text": "Validation failed, see 'result_data' -> 'errors'"
}
```

| Error | Cause |
|---|---|
| `One of the parameters 'message_id' or 'group_id' must be provided` | Neither was given. |
| `Only one of the parameters 'message_id' or 'group_id' may be provided` | Both were given. |
| `No message found for 'message_id': 2099` | The message does not exist, belongs to another account, is deleted, or is not a sent campaign or automated message. One error per ID. |
| `No message group found for 'group_id': 39` | The folder does not exist or belongs to another account. |
| `Too many messages to compare: 140 (maximum 100)` | More than 100 message IDs, or a folder holding, with its subfolders, more than 100 sent and automated messages (counted before the activity window's launch cut-off). |
| `Invalid 'message_id': 'abc'` | A `message_id` or `group_id` value that is not a whole number. |
| `Parameter 'group_id' accepts one value` | `group_id` sent as an array. |
| `Parameter 'sent_from' is not valid` | Any parameter other than those above. |

The shared date errors also apply.

---

## Examples

### Full report for one message

```bash
curl -X GET -gs -u "$API_ID:$API_KEY" \
  "https://e1.envoke.com/v2/reports/messages/2004"
```

### One message's activity in its first week

```bash
curl -X GET -gs -u "$API_ID:$API_KEY" \
  "https://e1.envoke.com/v2/reports/messages/2004?activity_from=2026-05-14&activity_to=2026-05-20"
```

With a window, `unsubscribe_reasons` and `polls` are `null`; every other part of the report counts only that week's activity.

### Compare two messages

```bash
curl -X GET -gs -u "$API_ID:$API_KEY" \
  "https://e1.envoke.com/v2/reports/messages/compare?message_id%5B%5D=2001&message_id%5B%5D=2004"
```

### Every message in a folder, May activity only

```bash
curl -X GET -gs -u "$API_ID:$API_KEY" \
  "https://e1.envoke.com/v2/reports/messages/compare?group_id=31&activity_from=2026-05-01&activity_to=2026-05-31"
```

### A message that is not found

```bash
curl -X GET -gs -u "$API_ID:$API_KEY" \
  "https://e1.envoke.com/v2/reports/messages/2099"
```

Response:

```json
[]
```

---

*Note: The curl examples above are formatted for readability. All requests must use properly encoded URLs.*

*Full reference, OpenAPI description, and Postman collection: [developers.envoke.com](https://developers.envoke.com).*
