Skip to navigation

Campaign Reporting

Read email, SMS, and Meta ad campaign stats
View as Markdown

Campaign endpoints are read-only: they return reporting data and accept no writes. All of them require the campaigns:read scope.

Stats are served from a cache that refreshes as engagement arrives. Use stats_refreshed_at to tell when the numbers were last refreshed, rather than assuming they’re live. A refresh doesn’t necessarily change the numbers.

Endpoints

ChannelList campaignsSingle campaign statsAggregate stats
EmailGET /campaigns/email/GET /campaigns/email/{campaign_id}/stats/GET /campaigns/email/stats/
SMSGET /campaigns/sms/GET /campaigns/sms/{campaign_id}/stats/GET /campaigns/sms/stats/
AdsGET /campaigns/ads/GET /campaigns/ads/{campaign_id}/stats/GET /campaigns/ads/stats/

Common patterns

  • Listing: every list endpoint accepts limit (0–500, default 50), offset (default 0), and stats (a boolean such as true/false or 1/0; true includes the full stats object on every campaign; default false). An out-of-range limit or offset, or an invalid stats value, returns 422 Unprocessable Entity. Responses include offset, limit, total_items, and items.
  • Campaign IDs: each item’s id is the Hive campaign ID, used as campaign_id in the stats endpoints.
  • Aggregates: counts and revenue are summed, and rates are recalculated from the summed counts rather than averaged. Filters are optional and can be combined; campaign_ids is a comma-separated list of up to 100 IDs; duplicates are removed before the limit is checked. Datetime filters without a UTC offset are treated as UTC. Omitting all filters aggregates every campaign for the brand. Aggregate stats_refreshed_at is the oldest refresh timestamp among the contributing campaigns, so the whole total is at least that fresh. How campaigns without a refresh time are handled differs by channel; see each aggregate section.
  • Window boundaries: the *_after filter is inclusive and the *_before filter is exclusive, so adjacent windows never double-count a campaign sent exactly on the boundary.
Fetching stats efficiently

Call a list endpoint without stats, and compare each campaign’s stats_refreshed_at against what you stored. Only request stats for campaigns whose timestamp changed.

Common errors:

  • 404 Not Found (single campaign stats): no campaign with that ID exists for the authorized brand.
  • 422 Unprocessable Entity (aggregates): the *_before filter isn’t later than the *_after filter, campaign_ids is empty or has more than 100 distinct IDs, one or more campaign_ids don’t exist for the brand, or a datetime filter is malformed.

Email campaigns

List email campaigns

GET /campaigns/email/ lists sent email campaigns, most recently sent first. An A/B test appears once, as its parent campaign; its variants aren’t listed separately, and the parent’s stats and stats_refreshed_at combine all its variants.

{
"offset": 0,
"limit": 50,
"total_items": 2,
"items": [
{
"id": "9a1b2c3d4e",
"name": "Summer Fest - Announce",
"subject": "Tickets are live",
"time_sent": "2026-07-01T15:00:00Z",
"num_recipients": 48210,
"stats_refreshed_at": "2026-07-02T03:00:00Z"
}
]
}
FieldDescription
total_itemsTotal number of sent campaigns for the brand
items[].idHive campaign ID
items[].nameCampaign name
items[].subjectEmail subject line, if set
items[].time_sentISO 8601 timestamp the campaign was sent
items[].num_recipientsNumber of recipients the campaign was sent to
items[].stats_refreshed_atISO 8601 timestamp the cached stats were last refreshed, or null if there are no stats yet or no refresh time was recorded
items[].statsOnly present when stats=true; same shape as the single campaign stats

Email campaign stats

GET /campaigns/email/{campaign_id}/stats/ returns engagement and revenue stats for one campaign.

{
"campaign_id": "9a1b2c3d4e",
"stats_refreshed_at": "2026-07-02T03:00:00Z",
"stats": {
"counts": {
"sent": 48210,
"delivered": 47980,
"opened": 21455,
"clicked": 3120,
"bounced": 230,
"soft_bounced": 640,
"spam_complained": 4,
"unsubscribed": 61
},
"rates": {
"delivery_rate": 99.523,
"open_rate": 44.717,
"click_rate": 6.503,
"bounce_rate": 0.479,
"spam_complaint_rate": 0.008,
"unsubscribe_rate": 0.127,
"estimated_non_prefetched_open_rate": 31.884
},
"revenue": {
"ecommerce": { "num_orders": 12, "total_revenue": 540.0 },
"ticketing": { "num_orders": 340, "total_revenue": 15300.0 },
"total": { "num_orders": 352, "total_revenue": 15840.0 }
}
}
}
FieldDescription
stats.countsRecipient counts for the campaign
stats.counts.bouncedHard bounces only: mail that will never arrive
stats.counts.soft_bouncedRetryable delivery failures, such as a full mailbox or a temporary rejection
stats.ratesPercentages rounded to 3 decimal places. Every rate is calculated against delivered, except delivery_rate, which is against sent.
stats.rates.estimated_non_prefetched_open_rateOpen rate estimate that discounts privacy-proxy prefetch opens, such as Apple Mail Privacy Protection
stats.revenueAttributed revenue split by channel, rounded to 2 decimal places
A soft bounce is not a failed send

A soft bounce is a delivery attempt the receiving server deferred, and it’s usually retried. A recipient who soft bounces and is then delivered on a later attempt is counted in both soft_bounced and delivered, so the two don’t sum to sent. Soft bounces are excluded from bounced and bounce_rate; only bounced represents mail that will never arrive. Recipients who soft bounce repeatedly are eventually reclassified as hard bounced.

Null sections

stats is null for a campaign with no cached stats, and counts, rates, and revenue are each null when that section hasn’t been computed. Engagement and revenue refresh independently, so a campaign can return counts and rates while revenue is still null.

Aggregate email stats

GET /campaigns/email/stats/ totals stats across multiple campaigns. Filters: sent_after, sent_before (ISO 8601 datetimes, filtering on send time), and campaign_ids.

{
"campaigns_included": 12,
"stats_refreshed_at": "2026-08-02T03:00:00Z",
"stats": {
"counts": { "...": "same shape as single campaign stats" },
"rates": { "...": "same shape as single campaign stats" },
"revenue": { "...": "same shape as single campaign stats" }
}
}
  • campaigns_included: number of campaigns that matched the filters. An A/B test counts once, though its stats come from its variants.
  • stats_refreshed_at: the oldest refresh timestamp across the engagement and revenue stats of the contributing campaigns (and A/B variants). Stats with no recorded refresh time are skipped; null if none has one.
  • stats: same shape as single campaign stats, or null when no matched campaign had stats.

SMS campaigns

List SMS campaigns

GET /campaigns/sms/ lists sent SMS campaigns, most recently sent first.

{
"offset": 0,
"limit": 50,
"total_items": 2,
"items": [
{
"id": "def456abc1",
"name": "Presale Reminder",
"time_sent": "2026-08-01T12:00:00Z",
"num_recipients": 200,
"stats_refreshed_at": "2026-08-02T03:00:00Z"
}
]
}

Fields match email campaigns, except there’s no subject, name is null if the campaign was never named, and num_recipients can be null.

SMS campaign stats

GET /campaigns/sms/{campaign_id}/stats/ returns engagement and revenue stats for one SMS campaign.

{
"campaign_id": "def456abc1",
"stats_refreshed_at": "2026-08-02T03:00:00Z",
"stats": {
"counts": {
"sent": 200,
"delivered": 194,
"clicked": 38,
"failed": 6,
"opted_out": 2,
"opted_in": 1,
"undeliverable": 3
},
"rates": {
"delivery_rate": 97.0,
"click_rate": 19.588,
"opt_out_rate": 1.031,
"failed_rate": 3.093
},
"revenue": {
"ecommerce": { "num_orders": 3, "total_revenue": 120.0 },
"ticketing": { "num_orders": 25, "total_revenue": 1125.0 },
"total": { "num_orders": 28, "total_revenue": 1245.0 }
}
}
}
FieldDescription
stats.counts.failedMessages the carrier rejected or couldn’t deliver
stats.counts.undeliverableNumbers that can’t receive SMS at all, such as landlines
stats.counts.opted_in, stats.counts.opted_outSubscription changes attributed to the campaign
stats.ratesPercentages rounded to 3 decimal places. Every rate is calculated against delivered, except delivery_rate, which is against sent.
stats.revenueAttributed revenue split by channel, rounded to 2 decimal places
SMS has no opens

There’s no SMS equivalent of an email open, so clicks are the engagement signal and click_rate is the headline number. For the same reason there’s no opened, bounced, or spam_complained.

Null stats

stats is null for a campaign whose stats have never been computed. Unlike email, the sections are computed together, so once stats is present, counts, rates, and revenue are all present. Zeroed revenue means no attributed orders, not missing data.

Aggregate SMS stats

GET /campaigns/sms/stats/ totals stats across multiple SMS campaigns. Filters: sent_after, sent_before (filtering on send time), and campaign_ids.

  • campaigns_included: number of campaigns that matched the filters, whether or not they had stats to contribute.
  • stats_refreshed_at: the oldest refresh timestamp among campaigns with stats; campaigns whose stats were never computed are skipped. null when no matched campaign had stats.
  • stats: same shape as single campaign stats, or null when no matched campaign had stats.

Ad campaigns

Every ad figure is Meta-reported

Hive serves these numbers from its cache of Meta’s campaign insights and doesn’t recompute them. Unlike email and SMS, there’s no Hive-attributed order revenue for ads: purchase figures are Meta’s own attribution, which is why they carry the meta_ prefix.

List ad campaigns

GET /campaigns/ads/ lists published Meta ad campaigns, most recently created first.

{
"offset": 0,
"limit": 50,
"total_items": 2,
"items": [
{
"id": "84213",
"name": "Summer Fest - Retargeting",
"status": "active",
"objective": "OUTCOME_SALES",
"start_time": "2026-06-15T04:00:00Z",
"stop_time": "2026-07-01T04:00:00Z",
"budget_cents": 50000,
"budget_type": "DAILY",
"stats_refreshed_at": "2026-07-02T03:00:00Z"
}
]
}
FieldDescription
total_itemsTotal number of published ad campaigns for the brand
items[].idHive campaign ID
items[].nameCampaign name
items[].statusactive, paused, publishing, archived, deleted, disconnected, campaign_over, attribution_over, or unknown
items[].objectiveMeta campaign objective, for example OUTCOME_SALES or OUTCOME_TRAFFIC
items[].start_time, items[].stop_timeISO 8601 flight dates; either can be null
items[].budget_centsBudget in cents, or null
items[].budget_typeDAILY or LIFETIME, describing what budget_cents covers, or null
items[].stats_refreshed_atISO 8601 timestamp Hive last replaced the cached stats with fresh figures from Meta, or null if it hasn’t refreshed since Hive began recording this
items[].statsOnly present when stats=true; same shape as single campaign stats

Ad campaign stats

GET /campaigns/ads/{campaign_id}/stats/ returns lifetime performance stats for one ad campaign.

{
"campaign_id": "84213",
"stats_refreshed_at": "2026-07-02T03:00:00Z",
"stats": {
"spend": 1240.55,
"impressions": 184320,
"reach": 96110,
"frequency": 1.918,
"clicks": 3821,
"unique_clicks": 3104,
"cost_per_click": 0.32,
"cost_per_unique_click": 0.4,
"click_through_rate": 2.073,
"results": 412,
"cost_per_result": 3.01,
"meta_purchase_value": 5820.0,
"meta_purchase_roas": 4.691
}
}
FieldDescription
stats_refreshed_atISO 8601 timestamp the cached stats last came from Meta
stats.spendAmount spent, rounded to 2 decimal places
stats.impressionsTimes the ads were shown
stats.reachUnique people who saw the ads
stats.frequencyAverage impressions per person reached, rounded to 3 decimal places
stats.clicks, stats.unique_clicksTotal clicks, and the number of distinct people who clicked
stats.cost_per_click, stats.cost_per_unique_clickSpend divided by the matching click count, rounded to 2 decimal places
stats.click_through_rateClicks as a percentage of impressions, rounded to 3 decimal places
stats.resultsCount of the outcome the campaign optimizes for, which depends on its objective
stats.cost_per_resultSpend divided by results (as reported by Meta), rounded to 2 decimal places
stats.meta_purchase_valuePurchase value Meta attributes to the campaign, rounded to 2 decimal places. Meta reports purchases under several overlapping action types, so this is the highest value among them (preferring Meta’s aggregated omni_purchase/purchase types), not their sum
stats.meta_purchase_roasMeta’s return on ad spend: attributed purchase value divided by spend, rounded to 3 decimal places, chosen the same way as meta_purchase_value
Null stats

stats is null for a campaign whose insights have never been cached. spend, impressions, reach, frequency, clicks, and unique_clicks default to 0. Other metrics are null when Meta reported nothing for them, which is common for purchase figures on campaigns without conversion tracking.

Aggregate ad stats

GET /campaigns/ads/stats/ totals stats across multiple ad campaigns. Spend and counts are summed, and derived metrics are recalculated from those sums rather than averaged.

Filters: created_after, created_before (ISO 8601 datetimes), and campaign_ids. Unlike the email and SMS aggregates, which filter on send time, ad aggregates filter on when the campaign was created.

{
"campaigns_included": 6,
"stats_refreshed_at": "2026-07-02T03:00:00Z",
"stats": { "...": "same shape as single campaign stats" }
}
  • campaigns_included: number of campaigns that matched the filters, whether or not they had stats to contribute.
  • stats_refreshed_at: the oldest refresh timestamp among the contributing campaigns, or null if any of them has no recorded refresh time.
  • stats: same shape as single campaign stats, or null when no matched campaign had stats.
Reach is summed, not deduplicated

Someone reached by three campaigns is counted three times in the aggregate reach, because Meta reports reach per campaign and the overlap between campaigns can’t be derived from those figures.