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