FullVision
Reports

Email Report

gethttps://data.fullvision.io/email-report

Tells you what each email campaign is worth: who it reached, who opened and clicked, and how much money followed.

Use it when you are deciding which campaign to send again, or when someone asks whether the newsletter earns anything.

The question it answers: which emails move money, and which ones just get opened?

Two ways to call it

CallWhat you get
Omit campaignOne row per campaign, ranked by reach revenue. This is the comparison view.
Pass campaignThat campaign in detail: its reach and causal revenue, its engagement funnel, and the same totals split by tag. This is the "what happened here" view.

What you get

DataWhat it tells you
Reach revenuelifetime revenue from people the campaign touched
Causal revenuebought-in-session and influenced revenue
Engagement funnelsends → deliveries → opens → clicks, with the derived rates
Tag breakdown (single)the same totals split by campaign_tag
Product eventsexact unique people per product event, over the campaign's reach set

Where the numbers come from. A campaign is one Amazon SES (Simple Email Service) configuration set. Product events count exact unique people over the report's range, not estimates or samples, and they are aggregate only: the report returns no personally identifiable information (PII). The Precision notes at the end of this page give the exact warehouse wording.

Reach metrics overlap across campaigns — never sum them. Reach revenue and reach product events both count every person a campaign touched, so a person reached by three campaigns is counted under all three. Adding reach numbers across campaigns invents revenue that does not exist. Compare campaigns to each other; do not total them.

Reach revenue vs causal revenue

They answer different questions, and reach is always the larger number:

  • Reach revenue — money from everyone the campaign touched, whenever they paid. It says "these people are on this list and they spend"; it is not a claim that the email caused the purchase.
  • Causal revenue — money from people who bought in the same session as the email, or who were demonstrably influenced by it. It is the narrower, defensible number.

Use reach to size an audience. Use causal to argue that a send worked.

Group form — omit campaign

One row per configuration set (the campaign bucket), ranked by reach revenue.

Single form — supply campaign

That campaign's:

SectionWhat it tells you
reachlifetime revenue + matched payers reached by the campaign
causalbought-in-session and influenced revenue
engagementsends → deliveries → opens → clicks, with the derived rates
campaign_tag breakdownthe same totals split by tag

Parameters

ParameterWhat it doesWhen you'd use it
campaignAddresses one Amazon SES configuration set and switches the report into the single form.You picked a campaign off the ranked list and want its funnel and its tag split.
range, or from + toSets the date window.Always worth setting explicitly — a default window makes two reports look like they disagree.
granularityAdds a series array with one point per daily, weekly or monthly bucket, for charting. Group form only, engagement funnel only.You want to see sends and opens over time rather than one total.
series_bySplits that series by campaign instead of returning one total per bucket. Group form only.You want one line per campaign rather than a single sending line.
fieldsNarrows the response to the blocks you name. It only ever removes blocks, never adds one.Your client only renders the funnel and you don't want to pay for the rest of the payload.
order_by, order_dirRe-ranks the complete campaign list by one column before limit. Group form only. Omit for the reach-revenue ranking.You want the worst bounce rates, not the biggest revenue.
totalsAdds a totals object over the complete campaign list, before limit, plus a totals_meta sidecar. Group form only.You want the sending programme's overall funnel under the per-campaign table.
compare_to, compare_from, compare_untilAttaches a prior object to each row, measured over a prior window. Group form only.You want "up or down since last month" per campaign, not a second call.

The series array — granularity

granularity is purely additive: rows is unchanged, and omitting it returns exactly the previous response. Each point carries sent, delivered, opened, clicked, bounced, open_rate, click_rate and bounce_rate.

The series is engagement only. Reach revenue overlaps across campaigns, so it is deliberately absent — bucketing it over time would read as additive when it is not.

Buckets are labelled with their start date (Monday for weekly), oldest first, and the trailing bucket may be partial — compare it against range.to. Do not add the buckets up to get the range totals — rate metrics are recomputed per bucket. Use rows for totals.

Use Channel Report to compare email against the other acquisition channels.

Ranking, totals and comparison

Three group-form parameters, each independently opt-in. Omit all three and the response is exactly the one you got before they existed — no totals, no totals_meta, no prior.

Re-ranking the list — order_by

order_by is a closed enum per report — the legal columns are listed in the order_by dropdown of the API panel above. Omit it and you keep the default reach-revenue ranking, unchanged. order_dir is asc or desc, defaults to desc, and does nothing on its own.

  • The re-rank applies to the complete campaign list, before limit — a campaign the revenue ranking buried below the limit surfaces, rather than the same top-20 reshuffled.
  • Nulls sort last in both directions. A campaign with no deliveries has no open_rate; that is unknown, not worst, and floating it to the top of an ascending sort would bury the campaigns you asked to see.
  • Ties always break on config_set ascending, so the order is total and repeated calls return the campaigns in the same order.

Whole-set totals — totals

totals=true adds a totals object covering the complete filtered set before limit — every campaign, not just the returned ones — plus a totals_meta sidecar. Anything other than true/false is a 400; nothing is coerced.

Rates are recomputed from their bases over the whole set, never summed or averaged across rows: open_rate is whole-set opens over whole-set deliveries, not the average of the per-campaign rates.

totals_meta.upper_bound names the columns whose total overcounts, because per-row values overlap between rows. Render these as approximations, not counts — this is the same overlap the warning at the top of this page describes:

ColumnWhy it overcounts
new_customersreach cohorts overlap across campaigns
revenue_centsreach revenue overlaps across campaigns
bought_in_session_customers, influenced_customersa person can be counted under several campaigns

The causal revenue columns are not marked: they are the MECE per-charge complement, so they sum exactly.

totals_meta.omitted names the columns deliberately left out, so their absence reads as a decision rather than a bug:

ColumnReason
ltv_centsa ratio of two overlap-inflated reach cohorts; the whole-set value would not be a lifetime value of anything
campaign_tagsa nested per-row breakdown, not a scalar
eventsa per-row array of named counts; there is no whole-set scalar

A fields trim trims totals with it: neither totals nor prior ever describes a column the response itself does not carry.

Comparing against a prior window — compare_to

compare_to attaches a prior object to each row, measured over a second window:

ValueWindow
previous_periodthe equal-length window immediately before range
previous_yearthe same calendar window one year back
customcompare_from + compare_until — both required here, both rejected otherwise
  • prior: null means the campaign did not exist in that window. That is a different claim from a prior object of zeros, which means present and measured zero; conflating them reports a brand-new campaign as flat.
  • Prior-only rows are not introduced. A campaign that sent then and is dormant now does not appear — the list is still the current window's.
  • The prior window differs only in its dates: same workspace, same filters, same fields.
  • Independent of totals. A nested totals.prior appears only when both are set.
  • compare_to roughly doubles the cold-path work — every query this report issues runs a second time over the prior window. Only Page Report memoizes its ranked set; this one does not.

Trimming the response — fields

fields is a comma-separated list of response blocks. Omit it and you get every block — it only ever narrows a response, never widens one, so every existing call keeps its exact shape.

BlockWhat it's forGroup columnsSingle sections
deliverywhether the mail arrived and got readsent, delivered, opened, clicked, bounced, complained, open_rate, click_rate, bounce_ratethe same keys under totals, plus campaign_tags
revenuewhat the people it reached were worthnew_customers, revenue_cents, bought_in_session_*, influenced_*, ltv_cents (lifetime value)the same under totals
eventswhat those people did in your producteventsevents
?fields=delivery           → the engagement funnel only
?fields=delivery,revenue   → both, still one upstream query
?fields=events             → the campaign list with only the events column

Rules that bite:

  • delivery and revenue share one query, entirely. Both are filled by email-channel-performance, so asking for one instead of both saves no round trip — the split between them is response-level. Trimming here buys payload size and context, not latency.
  • Trimming does not guarantee a query saved per block. Following from the above, the same query also decides which campaigns are in the list, so it runs whatever you ask for. ?fields=events returns the same campaigns the untrimmed call does. The Precision notes name it.
  • Row identity is never droppable. config_set and range survive every trim.
  • Empty, duplicated, unknown, and * are all 400s — fields=, fields=revenue,revenue, fields=nope, fields=*. The error names the legal blocks.
  • granularity is independent. The series array is not a block.
  • fields never lowers the scopes the endpoint requires.
  • Reach revenue still overlaps across campaigns after a trim — fields never changes what a number means, only whether it is returned.

Segmenting the series — series_by

Supply series_by alongside granularity (group form only) to split the series by one dimension instead of returning scope-level totals. series then holds one point per (bucket × segment), each carrying key — the segment value — so the response can feed a stacked or multi-line chart directly.

Accepted here: campaign.

granularity=weekly                 → [{ date, <metrics> }, …]
granularity=weekly&series_by=campaign  → [{ date, key, <metrics> }, …]

Notes that bite:

  • A source view that has no breakdown for the requested dimension is dropped from the segmented series, not smeared across every segment — copying a scope-level total onto each segment would read as per-segment data and be wrong. So a segmented series can carry fewer metric keys than the flat one.
  • Rows whose dimension value is blank are skipped: an unnamed series cannot be labelled or filtered by the caller.
  • An unrecognised series_by returns an empty series (not an error, and not a silent fallback to the flat series).
  • Segment values may contain spaces (search queries, campaign names) — key is the full value.
  • rows is unaffected. Do not sum segmented buckets to get range totals; use rows.

Authorization

AuthorizationBearer <token>

API key as Bearer token

In: header

Query Parameters

campaign?string

SES configuration set (the campaign bucket). Omit for the ranked group form.

limit?integer

Group form only: number of ranked rows to return (default 20, max 1000). Ignored when the addressing key is supplied.

Range1 <= value <= 1000
range?string

Preset date range. Canonical names (last_30_days, last_7_days, last_3_months, last_6_months, last_year, all_time, …) and dashboard short keys (30d, 7d, 3m, 6m, 1y, all) are both accepted. Mutually exclusive with from/to.

Value in

  • "last_7_days"
  • "last_14_days"
  • "last_28_days"
  • "last_30_days"
  • "last_90_days"
  • "current_month"
  • "this_month"
  • "last_3_months"
  • "last_6_months"
  • "last_year"
  • "all_time"
  • "7d"
  • "14d"
  • "28d"
  • "30d"
  • "90d"
  • "3m"
  • "6m"
  • "1y"
  • "all"
from?string

Custom start date (YYYY-MM-DD). Ignored if range is set.

Match^\d{4}-\d{2}-\d{2}$
to?string

Custom end date (YYYY-MM-DD). Ignored if range is set.

Match^\d{4}-\d{2}-\d{2}$
granularity?string

Group form only. When set, the response gains a series array: scope-level totals per date bucket at this grain, for charting. Purely additive — rows is unchanged, and omitting this returns exactly the previous response. Ignored when an addressing key selects the single form.

Value in

  • "daily"
  • "weekly"
  • "monthly"
series_by?string

Group form only, requires granularity: split the series by one dimension. The response's series then holds one point per (bucket × segment), each carrying key. Accepted values: page-report → page; channel-report → channel (concrete source, the grain of sources[]), channel_category (the 9 buckets, the grain of rows[]); keyword-report → query; form-report → form_id; ad-report → campaign; email-report → campaign. An unrecognised value returns an empty series. Under a channel-report split, visitor counts are emitted as attributed_visitors (attribution cohort); unique_visitors on split points is a deprecated alias of that same value until 2026-11-26, not the host-scoped traffic count rows carry.

fields?string

Comma-separated response blocks to return. OMIT for every block of the resolved form (the default — this param only ever narrows a response, never widens one). The legal set is resolved AFTER form selection, so it depends on whether an addressing key was supplied: some blocks exist only on the single form, and a report whose resolved form declares none rejects fields with 400. An unknown or duplicated name is a 400 listing the legal blocks for that form.

order_by?string

Group form only: rank the COMPLETE row set by this column before limit and the cursor slice. Omit for this report's default ranking, which is unchanged. Ties always break on the row identity, so the order is total and a cursor walk visits every row exactly once. Legal values: config_set, sent, delivered, opened, clicked, bounced, complained, open_rate, click_rate, bounce_rate, new_customers, revenue_cents, bought_in_session_revenue_cents, bought_in_session_customers, influenced_revenue_cents, influenced_customers, ltv_cents.

Value in

  • "config_set"
  • "sent"
  • "delivered"
  • "opened"
  • "clicked"
  • "bounced"
  • "complained"
  • "open_rate"
  • "click_rate"
  • "bounce_rate"
  • "new_customers"
  • "revenue_cents"
  • "bought_in_session_revenue_cents"
  • "bought_in_session_customers"
  • "influenced_revenue_cents"
  • "influenced_customers"
  • "ltv_cents"
order_dir?string

Direction for order_by, default desc. Ignored when order_by is omitted. Rows whose sort column is null sort LAST in both directions.

Value in

  • "asc"
  • "desc"
totals?string

Group form only: also return a totals object over the COMPLETE filtered set (before limit), plus a totals_meta sidecar naming the columns that are upper bounds rather than exact counts and the columns deliberately omitted. Anything other than true/false is a 400 — never coerced.

Value in

  • "true"
  • "false"
compare_to?string

Group form only: also measure each row over a prior window and attach it as prior. previous_period is the equal-length window immediately before; previous_year is the same calendar window one year back; custom requires compare_from + compare_until. A row present now but absent then carries prior: null — distinct from a prior row of zeros, which means present and measured zero. Rows present ONLY in the prior window are not introduced. Independent of totals: a totals.prior sub-object appears only when both are set. Note this genuinely doubles the cold-path work on every report except page-report.

Value in

  • "previous_period"
  • "previous_year"
  • "custom"
compare_from?string

Prior-window start (YYYY-MM-DD). Required with compare_to=custom, rejected otherwise.

Match^\d{4}-\d{2}-\d{2}$
compare_until?string

Prior-window end (YYYY-MM-DD). Required with compare_to=custom, rejected otherwise.

Match^\d{4}-\d{2}-\d{2}$

Responses

Single-campaign email report (campaign supplied) or the ranked config-set group

get/email-report
curl -X GET "https://data.fullvision.io/email-report?campaign=onboarding&range=last_30_days"
Example Responses
{  "campaign": "string",  "range": {    "from": "string",    "to": "string"  },  "totals": {},  "campaign_tags": [    {}  ],  "events": [    {      "name": "string",      "people": 0    }  ],  "is_cached": true,  "age_s": 0,  "last_refresh": "string"}
application/json