# Analytics and audit endpoints

> Read dashboard, deliverability, warmup, campaign, account, and usage analytics, plus your organization's audit trail.

Source: https://docs.ayinbox.com/api/reference/analytics/

The analytics endpoints expose the same rollups that power the dashboard: an org-wide overview, deliverability posture, warmup progress, per-campaign performance (with daily and hourly breakdowns and side-by-side comparison), mailbox health, and account usage. The audit endpoint returns your organization's activity trail ("who did what, when, from where"). All of these are read-only.

Every analytics route shares one auth gate: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`. The audit route uses the same org permission with a dedicated scope. See [permissions](/api/permissions/) for the scope reference and [authentication](/api/authentication/) for how to present credentials.

Dates are parsed as `YYYY-MM-DD` unless noted. Errors follow the standard `{error, message, code, request_id}` envelope documented in [error codes](/api/error-codes/).

Get dashboard analytics [#get-dashboard-analytics]

`GET /analytics/dashboard`

Returns the main dashboard overview for the active organization: aggregate stats, recent activity, top campaigns, account health, and a daily trend series. Sent and engagement totals include email steps only; completed wait and action steps do not inflate them. Account-health buckets are mutually exclusive and use the most severe current connection, sync, or warmup-reputation state. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

| Parameter      | In    | Type   | Description                                                                                                                              |
| -------------- | ----- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `period`       | query | string | One of `7d`, `30d`, `90d`, measured as UTC calendar days including today. Defaults to `7d`; any other value falls back to `7d`.          |
| `campaign_ids` | query | string | Optional. Comma-separated campaign ids (or the parameter repeated) to narrow the campaign sections to. At most `100`.                    |
| `folder_ids`   | query | string | Optional. Comma-separated campaign folder ids. Each one stands for the campaigns in that folder when the request is made. At most `100`. |

Filtering by campaign or folder [#filtering-by-campaign-or-folder]

Without `campaign_ids` and `folder_ids` the dashboard covers every campaign in the workspace. With either, the server resolves them to one set: the named campaigns plus every campaign currently in a named folder. A campaign named both ways, or sitting in two named folders, is counted once.

The filter narrows everything built from campaign sends: `overall_stats` (sends, opens, clicks, replies, bounces, their rates and `active_campaigns`), `daily_trend`, `top_campaigns` and `recent_activity`. It does not narrow `account_health`, `overall_stats.active_accounts` or `capacity_today`, which describe the workspace's mailboxes, or [direct mail](/guides/analytics/#direct-mail), which belongs to no campaign.

An id that does not belong to the active organization, or no longer exists, is dropped and never widens the filter: if nothing named belongs to the workspace, every campaign figure is zero. A malformed id, or more than `100` of either, is a `400`. A filtered response echoes what it was computed for in `scope`:

```json
{
  "scope": {
    "campaigns": [{ "id": "b1f2c3d4-0000-0000-0000-000000000001", "name": "Client A - Dentists" }],
    "folders": [{ "id": "f01d0000-0000-0000-0000-000000000004", "name": "Client A" }],
    "campaign_count": 6
  }
}
```

`campaigns` and `folders` list the requested ones that belong to the workspace; `campaign_count` is how many distinct campaigns they cover together. `scope` is absent when the request was not filtered.

Response [#response]

```json
{
  "period": "7d",
  "overall_stats": {
    "total_emails_sent": 1240,
    "total_opens": 612,
    "machine_opens": 88,
    "total_clicks": 143,
    "machine_clicks": 6,
    "total_replies": 57,
    "total_bounces": 9,
    "open_rate": 49.35,
    "click_rate": 11.53,
    "reply_rate": 4.6,
    "bounce_rate": 0.73,
    "active_campaigns": 4,
    "active_accounts": 12
  },
  "recent_activity": [
    {
      "type": "replied",
      "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
      "campaign_name": "Q2 outbound",
      "contact_email": "lead@example.com",
      "contact_id": "c0ffee00-0000-0000-0000-000000000002",
      "timestamp": "2026-06-11T14:02:11Z",
      "sender_id": "5e11d0c4-0000-0000-0000-000000000003",
      "sender_email": "alex@yourco.com"
    },
    {
      "type": "opened",
      "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
      "campaign_name": "Q2 outbound",
      "contact_email": "lead@example.com",
      "contact_id": "c0ffee00-0000-0000-0000-000000000002",
      "timestamp": "2026-06-11T13:40:02Z",
      "origin": { "client": "Apple Mail", "client_type": "app", "device_type": "mobile", "os": "iOS", "country_code": "US", "city": "Austin" }
    }
  ],
  "top_campaigns": [
    {
      "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
      "name": "Q2 outbound",
      "status": "active",
      "emails_sent": 820,
      "open_rate": 51.2,
      "click_rate": 12.1,
      "reply_rate": 5.0
    }
  ],
  "account_health": {
    "total_accounts": 12,
    "healthy_accounts": 10,
    "warning_accounts": 1,
    "error_accounts": 1
  },
  "daily_trend": [
    { "date": "2026-06-05", "sent": 160, "opens": 79, "clicks": 18, "replies": 7 }
  ]
}
```

An `opened` or `clicked` entry in `recent_activity` carries `origin` when the person's first open or click on that step was logged: the same client, device and location object a [contact timeline](/api/reference/contacts/#list-a-contacts-timeline) event carries.

Every entry names the mailbox the step was sent from in `sender_id` and `sender_email` (its send-as alias when it has one). A reply credits that mailbox even when it landed in the [shared reply inbox](/guides/mailboxes/#a-shared-reply-inbox) the sender's reply-to names.

Get deliverability dashboard [#get-deliverability-dashboard]

`GET /analytics/deliverability`

Returns the organization's deliverability posture for a time window: bounce, complaint, open, click, and reply counts and rates, suppression and dead-letter pressure, reply-intent breakdown, seed inbox-placement (overall and per recipient provider), warmup-derived placement per recipient mail host, an overall health band and a `0-100` composite score (both from the documented thresholds), a daily timeseries, and per-mailbox and per-campaign breakdowns. Requires an organization context. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

| Parameter | In    | Type   | Description                                                          |
| --------- | ----- | ------ | -------------------------------------------------------------------- |
| `from`    | query | string | Window start as an RFC 3339 timestamp. Defaults to 7 days ago (UTC). |
| `to`      | query | string | Window end as an RFC 3339 timestamp. Defaults to now (UTC).          |

Response [#response-1]

```json
{
  "from": "2026-06-05T00:00:00Z",
  "to": "2026-06-12T00:00:00Z",
  "events_total": 1380,
  "bounce_count": 9,
  "complaint_count": 1,
  "unsubscribe_count": 4,
  "reply_count": 57,
  "open_count": 612,
  "click_count": 143,
  "suppressed_recipients": 21,
  "dlq_pending": 0,
  "intent_positive": 18,
  "intent_negative": 6,
  "intent_out_of_office": 11,
  "intent_question": 9,
  "intent_neutral": 13,
  "intent_automated": 22,
  "emails_sent": 1240,
  "bounce_rate": 0.73,
  "complaint_rate": 0.08,
  "open_rate": 49.35,
  "click_rate": 11.53,
  "reply_rate": 4.6,
  "spam_placement_rate": 6.5,
  "inbox_placement_rate": 93.5,
  "placement_samples": 40,
  "band": "healthy",
  "score": 91,
  "timeseries": [
    {
      "date": "2026-06-05",
      "sent": 160,
      "bounces": 1,
      "complaints": 0,
      "opens": 79,
      "clicks": 18,
      "replies": 7,
      "unsubscribes": 1
    }
  ],
  "by_mailbox": [
    {
      "email_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
      "email": "sales@yourdomain.com",
      "sent": 420,
      "bounces": 2,
      "complaints": 0,
      "bounce_rate": 0.48,
      "complaint_rate": 0.0,
      "band": "healthy"
    }
  ],
  "by_campaign": [
    {
      "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
      "name": "Q2 outbound",
      "sent": 820,
      "bounces": 5,
      "complaints": 1,
      "bounce_rate": 0.61,
      "complaint_rate": 0.12,
      "band": "watch"
    }
  ],
  "by_provider": [
    {
      "provider": "gmail",
      "label": "Gmail",
      "samples": 24,
      "inbox": 21,
      "promotions": 1,
      "spam": 1,
      "other": 0,
      "missing": 1,
      "inbox_rate": 87.5,
      "spam_rate": 4.17
    }
  ],
  "warmup_placement": [
    {
      "provider": "microsoft365",
      "label": "Microsoft 365",
      "delivered": 132,
      "spam": 6,
      "inbox_rate": 95.45,
      "spam_rate": 4.55
    }
  ]
}
```

The `intent_*` counters and `reply_count` are two different things and do not add up to each other. `reply_count` counts `reply` deliverability events, which are the ones a provider or your own integration reports to [record an event](/api/reference/deliverability-ops/). The `intent_*` counters are the replies AY Inbox classified as they arrived in a connected mailbox, one per reply, including the machine ones: a vacation notice lands in `intent_out_of_office` and an autoresponder, ticket acknowledgement or bounce notice in `intent_automated`. A workspace that reports no reply events sees `reply_count` at zero with intents counted normally.

`spam_placement_rate` and `inbox_placement_rate` are omitted when there are no seed samples in the window. Seed samples are the copies of the workspace's [placement tests](/api/reference/placement/) that got a verdict in the window: `inbox`, `promotions`, `other` (another Gmail tab), `spam`, or `missing` (never arrived); copies that failed to send or were cancelled are not samples. `by_provider` rolls the same samples up per seed's provider family, where `provider` is the family id (`gmail`, `google_workspace`, `microsoft365`, `outlook`, `yahoo` and so on) and `label` its display name, and the rates are percentages of `samples`; `warmup_placement` is the continuous warmup signal per recipient mail host, where `provider` is the host id (`google_workspace`, `microsoft365`, `zoho`, `hostinger`, `other` and so on, or the provider group `google`, `microsoft`, `yahoo` or `other` for an arrival whose host was not detected), `label` its display name, `delivered` counts verified warmup arrivals and `spam` the subset the recipient's provider filed into junk. Warmup recipients are mostly other workspaces' mailboxes, so no recipient address or domain is reported. `score` starts at `100` and subtracts saturating penalties for bounce rate (up to `40` points, maxed at `10%`), complaint rate (up to `30` points, maxed at `0.30%`), and spam placement (up to `40` points, maxed at `40%`).

Get warmup analytics [#get-warmup-analytics]

`GET /analytics/warmup`

Returns warmup send and reply statistics over a date range, with a summary and per-day series. Optionally scoped to a single mailbox. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

Results are scoped to the selected workspace, so every member with access sees the same mailbox history. Workspace-wide results combine all mailboxes into one row per date. `average_daily` uses active days (dates with a warmup statistics row) as its denominator. `target_progress` is total sends divided by total planned target volume across those active days and may exceed `100` when sends beat the plan.

`total_received` and each day's `emails_received` count verified warmup mail that arrived from pool partners, bucketed on the UTC day it was verified. A day with arrivals but no warmup plan is listed with zero sends and does not count towards `days_active`.

| Parameter  | In    | Type          | Description                                                      |
| ---------- | ----- | ------------- | ---------------------------------------------------------------- |
| `from`     | query | string        | Required. Range start (`YYYY-MM-DD`).                            |
| `to`       | query | string        | Required. Range end (`YYYY-MM-DD`).                              |
| `email_id` | query | string (uuid) | Optional. Limit to one email account. Invalid UUIDs are ignored. |

Response [#response-2]

```json
{
  "email_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
  "email": "",
  "date_range": {
    "from": "2026-06-01T00:00:00Z",
    "to": "2026-06-12T00:00:00Z"
  },
  "summary": {
    "total_sent": 210,
    "total_replied": 84,
    "total_received": 196,
    "average_daily": 17.5,
    "reply_rate": 40.0,
    "target_progress": 87.5,
    "days_active": 12
  },
  "daily_stats": [
    { "date": "2026-06-01", "emails_sent": 12, "emails_replied": 5, "emails_received": 11, "target_volume": 12 }
  ]
}
```

`email_account_id` is the zero UUID when no `email_id` filter is supplied.

Get warmup placement [#get-warmup-placement]

`GET /analytics/warmup/placement`

Returns where warmup mail landed in partners' mailboxes over a date range: the primary inbox, a Gmail category tab (Promotions, Updates, Social or Forums), or spam. Counts are per UTC day, per recipient provider, and, on the workspace report, per mailbox. Requires an organization context. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

Every figure is measured, not estimated: a delivery is counted when the recipient's own sync finds the warmup email and records the folder it arrived in. `rescued` counts spam placements the recipient's mailbox was told to move back to the inbox. What each rescue came to is reported back by that mailbox: `rescue_confirmed` is rescues that left the message out of spam (moved, or already out when the rescue ran), `rescue_missing` is rescues whose message was no longer in the mailbox, `rescue_failed` is rescues the provider refused, and `rescue_unreported` is requested rescues with no outcome yet, which includes every rescue from before outcomes were recorded. The three outcome counters also appear on each `groups` entry. `unconfirmed` counts completed sends more than 24 hours old that no recipient has reported seeing, bucketed on the day they were sent; a provider filter cannot split it, so it is only reported unfiltered.

`rate` is the headline deliverability figure the dashboard shows next to each mailbox: `inbox_rate` is inbox plus tabs over what was delivered in the trailing 7 UTC days, and stays `null` until `min_sample` (`20`) deliveries are in. `scope` is always `major`: the rate is taken over Google, Microsoft and Yahoo recipients only, the providers that filter on sender reputation. A mailbox whose warmup mail reached only other hosts has no rate (`delivered` `0`, `band` `none`). Other hosts are always in `summary`, `daily` and `providers`, and `other_delivered` and `other_inbox_rate` give their share of the same window (`0` and `null` with none). `band` is `good` at `90` or above, `fair` from `80`, `poor` below `80`, `collecting` below the sample floor and `none` with no deliveries. Each day's `rolling_inbox_rate` is the trailing 7-day figure at the same three providers ending on that day; the day's counts cover every host, so filter by provider to read one. Rates are percentages with two decimals.

Recipient providers are grouped as `google` (Gmail and Google Workspace), `microsoft` (Outlook.com and Microsoft 365), `yahoo` (Yahoo and AOL) and `other`; each group lists its `hosts`, where an empty `host` means the recipient's host was not detected yet.

| Parameter  | In    | Type          | Description                                                                                                   |
| ---------- | ----- | ------------- | ------------------------------------------------------------------------------------------------------------- |
| `from`     | query | string        | Optional. Range start (`YYYY-MM-DD`). Defaults to 29 days before `to`.                                        |
| `to`       | query | string        | Optional. Range end (`YYYY-MM-DD`). Defaults to today (UTC).                                                  |
| `email_id` | query | string (uuid) | Optional. Limit to one email account in the workspace. Omit for the workspace report, which adds `mailboxes`. |

A range longer than 366 days, `from` after `to`, a malformed date or an invalid `email_id` returns `400`; an `email_id` outside the workspace returns `404`.

Response [#response-3]

```json
{
  "email_account_id": "a0a1a2a3-0000-0000-0000-000000000003",
  "date_range": { "from": "2026-06-01T00:00:00Z", "to": "2026-06-30T00:00:00Z" },
  "summary": {
    "sent": 820, "delivered": 791, "inbox": 702, "tabs": 41, "spam": 48,
    "rescued": 44, "rescue_confirmed": 41, "rescue_missing": 1, "rescue_failed": 0, "rescue_unreported": 2, "unconfirmed": 12, "inbox_rate": 93.93, "spam_rate": 6.07
  },
  "rate": {
    "window_days": 7, "min_sample": 20, "scope": "major", "delivered": 196, "inbox": 180, "tabs": 9, "spam": 7,
    "inbox_rate": 96.43, "band": "good"
  },
  "daily": [
    {
      "date": "2026-06-30",
      "sent": 30, "delivered": 29, "inbox": 27, "tabs": 1, "spam": 1, "rescued": 1, "unconfirmed": 0,
      "inbox_rate": 96.55, "spam_rate": 3.45, "rolling_inbox_rate": 96.43,
      "groups": [
        { "group": "google", "inbox": 15, "tabs": 1, "spam": 0, "rescued": 0, "rescue_confirmed": 0, "rescue_missing": 0, "rescue_failed": 0 },
        { "group": "microsoft", "inbox": 12, "tabs": 0, "spam": 1, "rescued": 1, "rescue_confirmed": 1, "rescue_missing": 0, "rescue_failed": 0 }
      ]
    }
  ],
  "providers": [
    {
      "group": "google",
      "sent": 0, "delivered": 402, "inbox": 351, "tabs": 41, "spam": 10, "rescued": 9, "unconfirmed": 0,
      "inbox_rate": 97.51, "spam_rate": 2.49,
      "hosts": [
        { "host": "google_workspace", "sent": 0, "delivered": 310, "inbox": 271, "tabs": 33, "spam": 6, "rescued": 6, "unconfirmed": 0, "inbox_rate": 98.06, "spam_rate": 1.94 },
        { "host": "gmail", "sent": 0, "delivered": 92, "inbox": 80, "tabs": 8, "spam": 4, "rescued": 3, "unconfirmed": 0, "inbox_rate": 95.65, "spam_rate": 4.35 }
      ]
    }
  ]
}
```

The workspace report (no `email_id`) omits `email_account_id` and adds `mailboxes`, one row per mailbox with activity in the range, ordered worst `rate.inbox_rate` first, then mailboxes still collecting. Each row carries the window counts, its own `rate`, and `daily_inbox_rate`, one entry per day of the range (`null` on a day with no deliveries).

Placement history is kept for the life of the mailbox, like warmup volume history, and moves with a workspace export. Deliveries recorded before this report existed, and any arrival the live count missed, are counted by a background pass from the receipts still on file within minutes; those read as inbox or spam only, with category tabs and rescues as `0`.

Get campaign analytics [#get-campaign-analytics]

`GET /analytics/campaigns/:id`

Returns a single campaign's performance summary plus per-sequence-step stats, for the campaign's whole history or for the emails sent in a date range. `total_contacts` counts enrolled leads, and `emails_pending` is the remaining planned email-step sends across those leads. The campaign must belong to the selected workspace. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

| Parameter | In    | Type          | Description                                                                      |
| --------- | ----- | ------------- | -------------------------------------------------------------------------------- |
| `id`      | path  | string (uuid) | Campaign id.                                                                     |
| `from`    | query | string        | Optional. First day of the period (`YYYY-MM-DD`, UTC). Requires `to`.            |
| `to`      | query | string        | Optional. Last day of the period (`YYYY-MM-DD`, UTC), included. Requires `from`. |

Leave out both `from` and `to` for the campaign's whole history. Supplying only one of them, a date in another format, or a `from` after `to` returns `400` with code `bad_request`.

A period is a send cohort: it selects the emails sent on its days, from the start of `from` to the end of `to` in UTC, and counts every open, click, reply and bounce those emails earned, whenever it arrived. An email sent on September 5 that is replied to on September 10 counts in a September 1 to 7 period; a reply that arrives on September 3 to an email sent in August does not. That keeps each rate's numerator and denominator the same emails. The period scopes `emails_sent`, the open, click, reply, positive reply and bounce counts and rates, `interested_leads`, `reply_breakdown`, every row in `steps` and every bucket in `engagement` (an open or click counts when the email it answers was sent in the period). `total_contacts` and `emails_pending` describe the campaign as it stands now and are the same whatever the period.

`date_range` is the period the figures cover, as midnight UTC on its first and last day: the `from` and `to` you sent, or for the whole history the day of the campaign's first send (its creation day before anything is sent) through today.

Response [#response-4]

```json
{
  "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
  "name": "Q2 outbound",
  "status": "active",
  "date_range": { "from": "2026-05-04T00:00:00Z", "to": "2026-06-12T00:00:00Z" },
  "summary": {
    "total_contacts": 500,
    "emails_sent": 820,
    "emails_pending": 60,
    "unique_opens": 410,
    "machine_opens": 52,
    "unique_clicks": 99,
    "machine_clicks": 4,
    "replies": 41,
    "positive_replies": 12,
    "interested_leads": 11,
    "bounces": 5,
    "unsubscribes": 3,
    "reply_breakdown": {
      "positive": 12,
      "neutral": 9,
      "negative": 14,
      "unsubscribe": 4,
      "unclassified": 2,
      "out_of_office": 17,
      "auto_reply": 6
    },
    "open_rate": 50.0,
    "click_rate": 12.07,
    "reply_rate": 5.0,
    "positive_reply_rate": 1.46,
    "bounce_rate": 0.61
  },
  "steps": [
    {
      "step_id": "5e9e0001-0000-0000-0000-000000000004",
      "name": "Intro",
      "position": 1,
      "emails_sent": 500,
      "opens": 260,
      "machine_opens": 31,
      "clicks": 61,
      "machine_clicks": 2,
      "replies": 28,
      "positive_replies": 8,
      "bounces": 3,
      "open_rate": 52.0,
      "click_rate": 12.2,
      "reply_rate": 5.6,
      "positive_reply_rate": 1.6,
      "bounce_rate": 0.6
    }
  ],
  "engagement": {
    "countries": [{ "key": "US", "opens": 120, "clicks": 31 }, { "key": "DE", "opens": 44, "clicks": 9 }],
    "clients": [{ "key": "Gmail", "opens": 98, "clicks": 20 }, { "key": "Outlook", "opens": 51, "clicks": 12 }],
    "devices": [{ "key": "desktop", "opens": 140, "clicks": 37 }, { "key": "mobile", "opens": 24, "clicks": 3 }],
    "surfaces": [{ "key": "hidden", "opens": 98, "clicks": 0 }, { "key": "desktop_app", "opens": 51, "clicks": 12 }, { "key": "mobile", "opens": 0, "clicks": 3 }]
  }
}
```

`engagement` groups the campaign's human opens and clicks by country (ISO code), mail client, device type (`desktop`, `mobile`, `tablet`) and surface, counting distinct contacts per bucket. A `clients` key is the mail client (`Gmail`, `Apple Mail`, `Outlook`), `Webmail in <browser>` for an open read in a browser that named no client, or for a click the browser the link opened in. `surfaces` joins device and app or webmail: `mobile_app`, `desktop_app`, `tablet_app`, `webmail`, the bare device type when neither is known (a click, unless a mail app made the request itself), and `hidden` when a mailbox provider's image proxy fetched the email and the device cannot be known. Each list holds the busiest eight; an empty `key` is unknown. Country needs the GeoLite2 database on the consumer, otherwise every country row is unknown.

`unique_opens`, `opens`, `total_opens` and every open rate count a person's opens only. `machine_opens` is the separate count of steps whose only open came from an automated fetcher (UA-less clients, known scanner networks, and opens inside the instance's automated-open window, which starts when the step is dispatched to a worker); it is not part of the open counts. Apple Mail Privacy Protection's relay (a user agent of just `Mozilla/5.0`) and the stripped WebKit signature Mail on a Mac and the new Outlook for Windows share are counted as a prefetch only inside that window; by themselves they do not prove automation. A person's later open on a machine-opened step moves it from `machine_opens` into the open count. `machine_clicks` counts the contacts whose only clicks on a step were automated (a security gateway walking the links); those are not part of `unique_clicks` or `total_clicks`, which only ever count a person's click.

`replies` counts human replies, one per step a contact answered; out-of-office notices and autoresponders are never part of it. `positive_replies` is the subset the [reply classifier](/guides/inbox-tagging/) judged positive, and `positive_reply_rate` is that count as a percentage of `emails_sent`, the same denominator as `reply_rate`. `interested_leads` counts distinct contacts with at least one positive reply, so a lead who answered two steps positively counts once. A step's classification is its latest reply's: a lead who answered positively and later wrote back to say no is counted under `negative`.

`reply_breakdown` splits the same replies by classification. `positive`, `neutral`, `negative`, `unsubscribe` (a reply asking to stop) and `unclassified` (a reply the classifier could not place, or one received before replies were classified) add up to `replies`. `out_of_office` and `auto_reply` count the automated answers, which are not replies and are listed only so the split is complete. The steps and daily series count positive replies the same way, so they add up to the summary for the same period.

`steps` lists the campaign's email steps in sequence order, the order the steps list returns them. `position` is the step's 1-based number among those email steps, the `N` the canvas shows as `Email N`. It is not the step's own `position` field: action steps are not counted and a deleted step leaves no gap. Match a row to its step by `step_id`.

Each entry in `steps` carries that step's own rates: `open_rate`, `click_rate`, `reply_rate`, `positive_reply_rate` and `bounce_rate` are percentages of that step's `emails_sent`, not of the campaign's, so a follow-up that reached a tenth of the contacts still compares against the first touch. They are `0` while the step has sent nothing. `machine_opens` and `machine_clicks` follow the same rules as their summary counterparts, scoped to the step.

Get campaign daily stats [#get-campaign-daily-stats]

`GET /analytics/campaigns/:id/daily`

Returns per-day send, open, click, reply and positive reply counts for one campaign over a date range. The campaign must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

| Parameter | In    | Type          | Description                                       |
| --------- | ----- | ------------- | ------------------------------------------------- |
| `id`      | path  | string (uuid) | Campaign id.                                      |
| `from`    | query | string        | Required. First day (`YYYY-MM-DD`, UTC).          |
| `to`      | query | string        | Required. Last day (`YYYY-MM-DD`, UTC), included. |

Each row is one UTC day with at least one send, and its opens, clicks, replies and positive replies are those earned by that day's sends, whenever they arrived. The rows for a period add up to the [campaign analytics](#get-campaign-analytics) for the same `from` and `to`.

Response [#response-5]

The series is returned under a `data` envelope.

```json
{
  "data": [
    { "date": "2026-06-05", "sent": 120, "opens": 61, "clicks": 14, "replies": 6, "positive_replies": 2 },
    { "date": "2026-06-06", "sent": 110, "opens": 58, "clicks": 12, "replies": 5, "positive_replies": 1 }
  ]
}
```

Get campaign hourly stats [#get-campaign-hourly-stats]

`GET /analytics/campaigns/:id/hourly`

Returns per-hour stats for one campaign on a single day. The campaign must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

| Parameter | In    | Type          | Description                                           |
| --------- | ----- | ------------- | ----------------------------------------------------- |
| `id`      | path  | string (uuid) | Campaign id.                                          |
| `date`    | query | string        | Day to report (`YYYY-MM-DD`, UTC). Defaults to today. |

Response [#response-6]

The series is returned under a `data` envelope, with the resolved `date` echoed back. Each item's `hour` is the UTC hour, `0`-`23`, so a day's rows add up to that day's row in the [daily stats](#get-campaign-daily-stats).

```json
{
  "data": [
    { "hour": 9, "sent": 22, "opens": 11, "clicks": 3, "replies": 1 },
    { "hour": 10, "sent": 30, "opens": 16, "clicks": 4, "replies": 2 }
  ],
  "date": "2026-06-11"
}
```

Compare campaigns [#compare-campaigns]

`GET /analytics/campaigns/compare`

Returns side-by-side performance for up to 10 campaigns over a date range. Every requested campaign must belong to the caller. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

| Parameter | In    | Type   | Description                                                                                                                         |
| --------- | ----- | ------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ids`     | query | string | Required. Comma-separated campaign UUIDs. Invalid entries are dropped; the list is capped at 10. At least one valid id is required. |
| `from`    | query | string | Required. First day (`YYYY-MM-DD`, UTC).                                                                                            |
| `to`      | query | string | Required. Last day (`YYYY-MM-DD`, UTC), included.                                                                                   |

Each campaign's figures are the emails it sent from the start of `from` to the end of `to`, with every open, click, reply and bounce they earned, the same send cohort as [campaign analytics](#get-campaign-analytics) for that period.

Response [#response-7]

```json
{
  "campaigns": [
    {
      "campaign_id": "b1f2c3d4-0000-0000-0000-000000000001",
      "name": "Q2 outbound",
      "status": "active",
      "emails_sent": 820,
      "open_rate": 50.0,
      "click_rate": 12.07,
      "reply_rate": 5.0,
      "bounce_rate": 0.61
    },
    {
      "campaign_id": "b1f2c3d4-0000-0000-0000-000000000005",
      "name": "Reactivation",
      "status": "paused",
      "emails_sent": 410,
      "open_rate": 44.1,
      "click_rate": 9.8,
      "reply_rate": 3.4,
      "bounce_rate": 1.2
    }
  ],
  "period": {
    "from": "2026-06-01T00:00:00Z",
    "to": "2026-06-12T00:00:00Z"
  }
}
```

List account statuses [#list-account-statuses]

`GET /analytics/accounts`

Returns the health and usage status of email accounts in the selected workspace, one bounded page at a time. The per-mailbox reads are batched across the page, so a page's cost does not grow with the size of the whole inventory. The request fails if a page's usage read cannot be completed, so clients never receive a silently incomplete page. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

Query parameters [#query-parameters]

| Parameter   | In    | Type    | Description                                                                                                                                                                                          |
| ----------- | ----- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `email_ids` | query | string  | Comma-separated mailbox UUIDs to return the status of (up to 200). Use this to ask only for the mailboxes a page shows. A foreign-workspace id matches nothing. Invalid or over-limit returns `400`. |
| `limit`     | query | integer | Page size when walking the whole inventory, `1`-`1000`, default `1000`. Invalid returns `400`. Ignored in spirit when `email_ids` is set, since that set is already bounded.                         |
| `cursor`    | query | string  | Opaque keyset cursor from a previous response's `pagination.next_cursor`. Invalid returns `400`.                                                                                                     |

Response [#response-8]

Returned under the standard `data` plus `pagination` envelope. `pagination.next_cursor` is an opaque token for the next page, or `null` on the last page. Each item in `data` has the same shape as [get account status](#get-account-status). A workspace with more mailboxes than one page follows `next_cursor` to reach the rest, rather than having the overflow silently dropped.

```json
{
  "data": [
    {
      "id": "a0a1a2a3-0000-0000-0000-000000000003",
      "email": "sales@yourdomain.com",
      "provider": "google",
      "status": "active",
      "last_synced_at": "2026-06-12T08:00:00Z",
      "health": { "status": "healthy", "score": 100, "issues": [] },
      "errors": [],
      "daily_usage": {
        "date": "2026-06-12",
        "campaign_sent": 18,
        "campaign_limit": 50,
        "warmup_sent": 22,
        "warmup_limit": 40
      },
      "in_campaign": true
    }
  ],
  "pagination": {
    "total": 1,
    "next_cursor": null,
    "has_more": false
  }
}
```

Get account status [#get-account-status]

`GET /analytics/accounts/:id`

Returns the detailed status for one email account: a combined health score (folding in warmup-pool reputation and measured warmup placement), active errors, today's UTC usage, warmup status, warmup-pool health, and the trailing warmup inbox rate. The account must belong to the selected workspace. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

| Parameter | In   | Type          | Description       |
| --------- | ---- | ------------- | ----------------- |
| `id`      | path | string (uuid) | Email account id. |

Response [#response-9]

```json
{
  "id": "a0a1a2a3-0000-0000-0000-000000000003",
  "email": "sales@yourdomain.com",
  "provider": "google",
  "status": "active",
  "last_synced_at": "2026-06-12T08:00:00Z",
  "health": {
    "status": "warning",
    "score": 90,
    "issues": ["Warmup reputation needs watching"]
  },
  "errors": [
    {
      "id": "e1e1e1e1-0000-0000-0000-000000000006",
      "error_code": "IMAP_AUTH",
      "severity": "WARNING",
      "title": "Mailbox reconnect recommended",
      "message": "Token nearing expiry",
      "created_at": "2026-06-11T22:14:00Z"
    }
  ],
  "daily_usage": {
    "date": "2026-06-12",
    "campaign_sent": 18,
    "campaign_limit": 50,
    "warmup_sent": 22,
    "warmup_limit": 40
  },
  "warmup_status": {
    "enabled": true,
    "paused": false,
    "started_at": "2026-05-20T00:00:00Z",
    "current_volume": 22,
    "target_volume": 33,
    "max_volume": 40,
    "reply_rate": 35,
    "days_active": 23,
    "next_send_at": "2026-06-12T14:37:12Z"
  },
  "warmup_health": {
    "state": "watch",
    "score": 78,
    "spam_score": 0,
    "partner_mailboxes_7d": 14,
    "partner_domains_7d": 11,
    "partner_organizations_7d": 9,
    "received_7d": 12,
    "senders_7d": 8,
    "evaluated_at": "2026-06-12T06:00:00Z"
  },
  "warmup_placement": {
    "window_days": 7,
    "min_sample": 20,
    "scope": "major",
    "delivered": 142,
    "inbox": 126,
    "tabs": 5,
    "spam": 11,
    "inbox_rate": 92.25,
    "band": "good"
  },
  "in_campaign": true
}
```

`send_recovery_reason` is present only while a send safety hold pauses the mailbox's sending: `authentication` (cleared by reconnecting the mailbox), `permanent` (cleared by reconnecting the mailbox, and on Gmail and Outlook automatically once the mailbox syncs again), `conflict` or `unknown`. `warmup_status` is present only when warmup has ever been enabled, and its `next_send_at` is when the mailbox's next warmup send is queued for (absent while nothing is queued, for example while warmup is paused); `warmup_health` is present only when the mailbox is in a warmup pool. `in_campaign` reports whether the mailbox currently backs a live campaign.

`warmup_placement` is the mailbox's measured warmup inbox rate over the trailing 7 UTC days, the same figure [Get warmup placement](#get-warmup-placement) reports as `rate`, and is absent when nothing was delivered in that window. Once `inbox_rate` is present, `health.score` is capped at it (rounded down), so a mailbox landing any mail in spam no longer reads `100`, and a rate below `90` also sets `health.status` to at least `warning` with an issue naming the rate.

`daily_usage.warmup_sent` and `campaign_sent` both count sends the mailbox completed on that date, which is what the caps are enforced against, so neither can read above the target in force.

The three `partner_*_7d` counters are the distinct partners reached by confirmed warmup sends over the last seven days: mailboxes, their domains, and the workspaces behind them. Failed and still-pending attempts do not count. `partner_organizations_7d` of `1` over a full week means the mailbox is only warming inside one workspace, which is expected on a self-hosted instance that is not linked to AY Inbox Cloud and worth investigating anywhere else.

`received_7d` and `senders_7d` are the other direction over the same window: verified warmup arrivals and the distinct partners they came from. Only mail that the mailbox's own sync verifies counts. A mailbox with many partners on the sending side and few on the receiving side is being written to less than it writes; the pool favours it on every draw until the two even out.

Get usage overview [#get-usage-overview]

`GET /analytics/usage`

Returns account, campaign, and contact usage counters for the selected workspace. `campaigns.emails_sent` counts sent email steps in the requested period; wait and action steps do not count. Auth: **Scope** `READ_ANALYTICS` · **Org permission** `view_analytics`.

The `api` object is a compatibility placeholder. For real API-key request totals, error rates, latency, and endpoint breakdowns use `GET /api-keys/usage/summary` and the API-key analytics endpoints.

| Parameter | In    | Type   | Description                                                                                                                                                                 |
| --------- | ----- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `period`  | query | string | `day` counts from 00:00 UTC today, `week` is a rolling seven-day window, and `month` is a rolling one-month window. Defaults to `day`; any other value falls back to `day`. |

Response [#response-10]

```json
{
  "user_id": "11111111-0000-0000-0000-000000000007",
  "period": "day",
  "email_accounts": { "total": 12, "active": 11, "in_warmup": 8, "with_errors": 1 },
  "campaigns": { "total": 9, "active": 4, "paused": 2, "draft": 3, "emails_sent": 124 },
  "contacts": { "total": 8200, "subscribed": 8050, "added_today": 120 },
  "api": { "total_calls": 0, "daily_limit": 50000, "top_endpoints": [] }
}
```

List audit logs [#list-audit-logs]

`GET /audit-logs`

Returns the organization-wide activity trail for the caller's current organization ("who did what, when, from where"). The organization is always taken from the session and never from a client parameter, so one organization can never read another's trail. Auth: **Scope** `READ_AUDIT_LOGS` · **Org permission** `view_analytics`.

| Parameter     | In    | Type          | Description                                                                                       |
| ------------- | ----- | ------------- | ------------------------------------------------------------------------------------------------- |
| `limit`       | query | int           | Page size. Defaults to `50`; must be between `10` and `200` or a `400` is returned.               |
| `cursor`      | query | string (uuid) | Opaque cursor from `pagination.next_cursor`. Invalid cursors return `400`.                        |
| `actor_id`    | query | string (uuid) | Filter to a single acting member.                                                                 |
| `entity_id`   | query | string (uuid) | Filter to a single entity.                                                                        |
| `entity_type` | query | string        | Filter by entity type (for example `campaign`, `contact`, `email_account`, `api_key`, `webhook`). |
| `action`      | query | string        | Filter by action (for example `create`, `update`, `delete`, `send`, `revoke`).                    |
| `date`        | query | string        | Single-day filter (`YYYY-MM-DD`), expanded to that whole UTC day.                                 |
| `start_date`  | query | string        | Range start. RFC 3339 or `YYYY-MM-DD`. Overrides `date`.                                          |
| `end_date`    | query | string        | Range end. RFC 3339 or `YYYY-MM-DD`. Overrides `date`.                                            |

Response [#response-11]

A `data` plus `pagination` envelope with an opaque cursor. `actor` is null when the acting user has since been deleted; `entity_id`, `changes`, and `metadata` are omitted when empty.

```json
{
  "data": [
    {
      "id": "9c9c9c9c-0000-0000-0000-000000000008",
      "org_id": "0a0a0a0a-0000-0000-0000-000000000009",
      "user_id": "11111111-0000-0000-0000-000000000007",
      "actor": {
        "id": "11111111-0000-0000-0000-000000000007",
        "first_name": "Ada",
        "last_name": "Lovelace",
        "email": "ada@yourdomain.com"
      },
      "action_date": "2026-06-12T08:14:00Z",
      "action": "update",
      "entity_type": "campaign",
      "entity_id": "b1f2c3d4-0000-0000-0000-000000000001",
      "ip_address": "203.0.113.10",
      "user_agent": "Mozilla/5.0",
      "changes": { "status": "paused" },
      "timestamp": "2026-06-12T08:14:00Z"
    }
  ],
  "pagination": {
    "next_cursor": "c1_b3BhcXVlLWN1cnNvcg",
    "has_more": true
  }
}
```

Secret values (API key material, webhook secrets, passwords) are never recorded in `changes` or `metadata`; the trail records only that a field changed.
