ZevCampaign Docs
Sign up

API Reference

Reports

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 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

curl https://api.zevcampaign.com/v1/reports/summary \
  -H "Authorization: Bearer sk_live_your_key_here"
{
  "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 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.

curl https://api.zevcampaign.com/v1/reports/campaigns?limit=2 \
  -H "Authorization: Bearer sk_live_your_key_here"
{
  "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.

Filtering to a period

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

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.

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"
{
  "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 }
}
ParameterDefaultNotes
from30 days before toISO date or timestamp
tonowExclusive
intervaldayday, 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 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:

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.

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:

{ "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.

Updated at, Saturday, October 10, 2026