---
title: Reports
description: Aggregate numbers for your own dashboards, without pulling every record.
---

Reports give you the figures directly: how many contacts you have, how
many campaigns you have sent, how each one performed. Pull them into
your own admin screens, a weekly digest, or whatever you already use to
watch your business.

Use these instead of paging through [contacts](/api/contacts) and
counting. One request returns the number, and it stays fast however
large your audience gets.

Every field is a count, a rate or a campaign's own name. No recipient
details are returned, so you can feed these into your reporting tools
without carrying anyone's personal data into them.

## Workspace totals

`GET /v1/reports/summary`

```bash
curl https://api.zevcampaign.com/v1/reports/summary \
  -H "Authorization: Bearer sk_live_your_key_here"
```

```json
{
  "data": {
    "contacts": {
      "total": 18402,
      "subscribed": 17642,
      "unsubscribed": 422,
      "bounced": 310,
      "complained": 28
    },
    "lists": { "total": 12, "memberships": 21044 },
    "topics": { "total": 4 },
    "campaigns": {
      "total": 86,
      "sent": 81,
      "scheduled": 2,
      "draft": 3,
      "sending": 0
    },
    "sends": {
      "used_this_period": 24150,
      "period_started_at": "2026-10-01T00:00:00.000Z",
      "scope": "workspace"
    },
    "scope": { "type": "workspace", "brand_id": null }
  },
  "as_of": "2026-10-10T13:04:11.000Z"
}
```

`contacts.subscribed` counts everyone you can currently email: not
unsubscribed, not bounced, not complained. The other three buckets
account for the rest, so the four add up to `total`.

`sends.used_this_period` is your send allowance used so far in the
current period, and it always covers the whole workspace. One allowance
pays for every brand, so there is no per-brand figure to report.

This endpoint takes no `from` or `to`, on purpose. Its figures are
states: "how many contacts you have" is true at a moment, and there is
no total between two dates. For numbers over a period, use
[activity](#activity-over-a-period) below, where every figure is a
count of things that happened.

## Engagement per campaign

`GET /v1/reports/campaigns`

One row per campaign you have sent, newest first.

```bash
curl https://api.zevcampaign.com/v1/reports/campaigns?limit=2 \
  -H "Authorization: Bearer sk_live_your_key_here"
```

```json
{
  "data": [
    {
      "id": "cmp_7k2m9x",
      "name": "October announcement",
      "subject": "Your October update is here",
      "sent_at": "2026-10-05T10:00:00.000Z",
      "recipients": 12480,
      "accepted": 12402,
      "delivered": 12311,
      "opened": 5240,
      "clicked": 812,
      "bounced": 78,
      "complained": 3,
      "unsubscribed": 41,
      "failed": 0,
      "open_rate": 42.56,
      "click_rate": 6.6,
      "bounce_rate": 0.63,
      "complaint_rate": 0.02
    }
  ],
  "meta": { "limit": 2, "has_more": true, "next_cursor": "MjAyNi0xMC0wNFQxMDowMDowMC4wMDBafGNtcF84ajNuMnE" },
  "as_of": "2026-10-10T13:04:00.000Z",
  "scope": { "type": "workspace", "brand_id": null }
}
```

Only campaigns you have sent appear here. A draft has nothing to
report, and including it would give you rows of zeros to filter out.

Paginate with `cursor`, the same way as everywhere else in the API. See
[pagination](/api/pagination).

### Filtering to a period

Add `from` and `to` to narrow it to campaigns sent in a window:

```bash
curl "https://api.zevcampaign.com/v1/reports/campaigns?from=2026-09-01&to=2026-10-01" \
  -H "Authorization: Bearer sk_live_your_key_here"
```

Both accept a plain date (`2026-09-01`) or a full timestamp
(`2026-09-01T00:00:00Z`). A plain date is read as UTC.

Either can be used on its own: `from` alone means everything since,
`to` alone means everything before. With neither, you get your most
recent campaigns with no window applied.

`to` is exclusive. September and October therefore never both report
the same campaign, so you can request consecutive months and add the
results up.

### How the rates are worked out

Open and click rates are measured against **deliveries**, not
recipients:

```
open_rate  = opened  / delivered
click_rate = clicked / delivered
```

An email that bounced never reached a person, so counting it would drag
your open rate down for a reason that has nothing to do with the
campaign.

Bounce and complaint rates are measured against **recipients**, because
there a failed delivery is the thing being measured:

```
bounce_rate    = bounced    / recipients
complaint_rate = complained / recipients
```

All four are percentages, rounded to two decimal places. Work them out
yourself from the raw counts if you would rather use a different
denominator.

## Activity over a period

`GET /v1/reports/activity`

A time series: what happened, bucketed by day, week or month. This is
the endpoint for "how did last month go" and for charting growth.

```bash
curl "https://api.zevcampaign.com/v1/reports/activity?from=2026-10-01&to=2026-10-04&interval=day" \
  -H "Authorization: Bearer sk_live_your_key_here"
```

```json
{
  "data": [
    {
      "period_start": "2026-10-01T00:00:00.000Z",
      "contacts_added": 142,
      "unsubscribes": 3,
      "campaigns_sent": 1,
      "opens": 5240,
      "clicks": 812
    },
    {
      "period_start": "2026-10-02T00:00:00.000Z",
      "contacts_added": 0,
      "unsubscribes": 0,
      "campaigns_sent": 0,
      "opens": 1103,
      "clicks": 95
    },
    {
      "period_start": "2026-10-03T00:00:00.000Z",
      "contacts_added": 96,
      "unsubscribes": 11,
      "campaigns_sent": 2,
      "opens": 7418,
      "clicks": 1204
    }
  ],
  "meta": { "limit": 3, "has_more": false, "next_cursor": null },
  "totals": {
    "contacts_added": 238,
    "unsubscribes": 14,
    "campaigns_sent": 3,
    "opens": 13761,
    "clicks": 2111
  },
  "period": {
    "from": "2026-10-01T00:00:00.000Z",
    "to": "2026-10-04T00:00:00.000Z",
    "interval": "day"
  },
  "as_of": "2026-10-10T13:04:00.000Z",
  "scope": { "type": "workspace", "brand_id": null }
}
```

| Parameter | Default | Notes |
|---|---|---|
| `from` | 30 days before `to` | ISO date or timestamp |
| `to` | now | Exclusive |
| `interval` | `day` | `day`, `week` or `month` |

`totals` adds up the buckets in the response, so it always matches the
series rather than being counted separately.

`period` echoes the window that was actually applied, including any
default you did not set, so you can see what you got.

A quiet period comes back as a row of zeros rather than being left
out. The series is continuous, so you can chart it without
reconstructing the calendar yourself.

### Things worth knowing

**Buckets are aligned to the interval, not to your `from`.** A weekly
series starts its first bucket on the Monday of the week containing
`from`, which may be before `from` itself. The counts only include
what falls inside your window, so the first and last buckets of a
series can be partial. If you are comparing periods, line your window
up with the interval.

**The series covers events, not states.** Contacts added, unsubscribes,
campaigns sent, opens and clicks are all things that happened at a
moment, so a window applies to them cleanly. Per-email outcomes like
deliveries and bounces belong to the campaign that produced them, so
they live on [engagement per campaign](#engagement-per-campaign)
instead of being spread across days.

**One request, one window.** There is no pagination here, because the
window is capped instead: at most 400 buckets, so a year of days or
three decades of months. Ask for more and you get a clear error
telling you to use a coarser interval or split the range. What comes
back is always the whole answer.

**Timestamps are UTC**, everywhere, in and out.

## Keeping the numbers current

Reports are cached, and the cache is tied to your data rather than to a
clock. Change something and the next request reflects it: add a
contact, and the contact total moves straight away.

The one exception is opens, clicks and deliveries. Those arrive
continuously while a campaign is going out and for a while afterwards,
so they can trail by up to 30 seconds. It applies to the open and
click columns on both the per-campaign report and the activity series.
Every response carries `as_of` to tell you the moment the figures were
valid, so you never have to guess.

## Polling without wasting your rate limit

Every response carries an `ETag`. Send it back as `If-None-Match` and
you get `304 Not Modified` while nothing has changed:

```bash
curl -i https://api.zevcampaign.com/v1/reports/summary \
  -H "Authorization: Bearer sk_live_your_key_here" \
  -H 'If-None-Match: W/"wd0-_HMvJ7tTg7X3nsGoDJrgNEE"'
```

```
HTTP/1.1 304 Not Modified
ETag: W/"wd0-_HMvJ7tTg7X3nsGoDJrgNEE"
Cache-Control: private, max-age=0, must-revalidate
```

A `304` has no body, so it is far cheaper for both of us, and it is
never stale: the `ETag` changes the moment your figures do. If you poll
these endpoints on a schedule, store the `ETag` and send it back. It is
the single best thing you can do for a dashboard that refreshes often.

Requests still count against your rate limit whether they return `200`
or `304`. See [rate limits](/api/rate-limits).

## Brand-scoped keys

A key restricted to one brand reports that brand only, and `scope` in
every response tells you which you are looking at:

```json
{ "scope": { "type": "brand", "brand_id": "br_3nf8q1" } }
```

A workspace-wide key reports `{ "type": "workspace", "brand_id": null }`.
The URL is the same either way, so check `scope` rather than assuming,
especially if you hold keys for several brands.

The send allowance is the exception noted above: it is always the
workspace figure, because the allowance is not divided between brands.

## Limits

These endpoints do more work than a single-record fetch, so they have
their own, lower rate limit of 30 requests a minute. With `If-None-Match`
in place that is plenty for a dashboard that refreshes every few
seconds.

They need a secret key (`sk_…`). A publishable key will not reach them,
because these are whole-business figures and do not belong in a browser.