Channel Report
Ranks your acquisition channels by the money they actually brought in, next to the traffic they sent.
Use it when you are deciding where the next marketing hour or euro goes, or when someone asks "is SEO working?" and you want an answer with revenue in it.
The question it answers: which channels bring you visitors who become paying customers — and which only bring you visitors?
Two ways to call it
| Call | What you get |
|---|---|
Omit channel | Every channel bucket, ranked by the revenue from customers who arrived through it. This is the comparison view. |
Pass channel | That one bucket in detail: the concrete sources inside it, its top landing pages, and its customers and cash. This is the "why" view, once a bucket looks interesting. |
What you get
| Data | What it tells you |
|---|---|
| Traffic | unique visitors, page views, sessions per channel |
| New customers | first-time payers attributed to the channel, and their cash |
| Existing customers | returning payers attributed to the channel, and their cash |
| Sources (single) | the concrete sources inside the bucket (Google, ChatGPT, LinkedIn, …) |
| Top landing pages (single) | the pages that captured the most attributed revenue from the channel |
| Product events | exact unique people per product event, by acquiring channel |
Where the numbers come from. Attributed customers and cash are the same
whichever host you scope to; traffic and sources follow page_url_scope. Product
events count the first-touch acquisition population — people acquired via
the channel who fired the event, not events fired on the channel. Those counts
are 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.
The nine buckets
Organic Search, AI, Organic Social, Paid Search, Paid Social, Email,
Direct, Referral, Non-attributed.
Every visit rolls up into exactly one of them. That is what lets you compare channels against each other instead of against a definition that shifts per report.
A paid visit — a paid utm_medium (cpc, ppc, paid, paid_social,
display) or an ad click id — beats every other signal, and the network then
decides which paid bucket it lands in: Paid Social for Facebook and Instagram,
LinkedIn, TikTok, X, Reddit, Pinterest, Snapchat, Threads and Telegram;
Paid Search for every other paid visit — Google, Bing, YouTube, display
networks. Paid Social is the newest bucket: before it existed, paid social
traffic was reported under Paid Search.
Matching is case-insensitive, and FullVision still accepts LLMs as a legacy
alias for AI (the label was renamed on 2026-06-08; historical rows still carry
it). The
traffic view's catch-all Other and empty labels fold into Non-attributed, so
the report never emits a bucket outside these nine.
A channel outside this vocabulary returns 404 entity_not_found. That
error exists so a typo fails loudly instead of returning an empty report you
would read as "this channel earned nothing". A bucket that merely has no rows in
the requested range is not an error — it returns an empty report.
Group form — omit channel
Every bucket, ranked by the revenue from first-time payers who arrived through it. A bucket that sent traffic but has no attributed customers — or the reverse — still appears, with zeros on the side it is missing, so the list is complete rather than a silent intersection.
| Column | What it tells you |
|---|---|
unique_visitors, page_views, sessions | traffic reaching the site through this channel |
new_customers, new_customer_revenue_cents | first-time payers attributed to the channel, and their cash |
existing_customers, existing_customer_revenue_cents | returning payers attributed to the channel, and their cash |
total_cash_cents | all cash attributed to the channel in range |
events | compact set (signup + top-2 catalog events): exact unique people per event, acquired via this channel |
FullVision ranks rows by new_customer_revenue_cents desc, then sessions desc.
limit (default 20, max 1000) caps the list — moot at nine buckets.
Single form — supply channel
| Section | What it tells you |
|---|---|
traffic | this bucket's visitors, page views and sessions over the range |
sources | the concrete sources inside the bucket (Google, ChatGPT, LinkedIn, …), ranked by unique visitors, top 20 |
outcomes | attributed new/existing customers and cash for the bucket |
top_pages | the 10 landing pages that captured the most attributed revenue from this channel |
unclassified_referrers | Referral only — referrer hosts that fell through to the catch-all, as an inbox for the classifier. null for every other bucket |
unclassified_referrers comes from a view with a hard-coded trailing 30-day
window. It does not follow the requested date range.
Parameters
| Parameter | What it does | When you'd use it |
|---|---|---|
channel | Narrows the report to one of the nine buckets and switches it into the single form. | You know one channel is interesting and want to see what is inside it. |
limit | Caps how many ranked rows come back (default 20, max 1000). Group form only — ignored when channel is supplied. | Rarely — there are only nine buckets. It matters for the sources list. |
page_url_scope | Pins traffic and sources to one host: marketing (default) or product. all drops the pin and reports traffic from every host you have. Customers and cash are unaffected either way. | You run a marketing site and an app on different hosts and want traffic from one of them — or all to see the traffic behind every bucket at once. |
range, or from + to | Sets the date window. Defaults to the last 30 days. | Always worth setting explicitly — a default window makes two reports look like they disagree. |
granularity | Adds a series array with one point per daily, weekly or monthly bucket, for charting. Group form only. | You want a trend line rather than one total. |
series_by | Splits that series by channel_category (the 9 buckets of rows) or channel (the concrete sources of sources) instead of returning one total per bucket. Group form only. | You want a stacked chart rather than a single line. |
fields | Narrows the response to the blocks you name. It only ever removes blocks, never adds one. | Your client only renders traffic and you don't want to pay for the rest of the payload. |
order_by, order_dir | Re-ranks the buckets by one column. Group form only. Omit for the default ranking. | You want the buckets sorted by traffic rather than by revenue. |
totals | Adds a totals object over all nine buckets, plus a totals_meta sidecar. Group form only. | You want the site-wide line under the per-channel table. |
compare_to, compare_from, compare_until | Attaches a prior object to each bucket, measured over a prior window. Group form only. | You want "up or down since last month" per channel, not a second call. |
model | Switches the attributed columns to the multi-touch journey pipeline: first_touch, last_touch, linear or time_decay. Group form only. Omit for the default first-touch cohort response. Under linear/time_decay the credited counts are fractional, and the attributed columns become host-scoped with page_url_scope (they are cross-host by default). | You want to see how much credit the buckets keep once the middle of the journey counts too. |
half_life_days | Half-life in days for model=time_decay — an integer 1–90, default 7. Rejected unless model=time_decay. | Your sales cycle is longer or shorter than a week and the default decay misreads it. |
The series array — granularity
granularity is purely additive: rows is unchanged, and omitting it returns
exactly the previous response. Each point carries page_views,
unique_visitors, sessions, new_customers, new_customer_revenue_cents,
existing_customers, existing_customer_revenue_cents, total_cash_cents,
ft_signups and form_submits — contact-bearing, deduplicated form
submissions credited to the visitor's first-touch channel, the same recipe as
/page-report and /form-report. form_submits is not journey-modelled: it
is the same under every model.
FullVision labels each bucket with its start date (Monday for weekly) and
returns them oldest first. The trailing bucket may be partial — compare it
against range.to.
The last day or two are incomplete, not a collapse. Traffic
(unique_visitors, page_views, sessions, in rows and in the flat series)
reads a rollup refreshed once a day at about 06:06 UTC. Until that refresh the
current day holds only the hours before it, and the previous day is partial too,
so every channel drops by the same proportion at once (one live day read 196
visitors against a ~1,300 baseline). The attributed columns refresh on their own
schedule, so a recent day can show more customers than visitors.
Do not add the buckets up to get the range totals. At weekly and monthly
resolution unique_visitors is an upper bound rather than an exact count, and
FullVision recomputes rate metrics per bucket. Use rows for totals; the Precision
notes explain why.
Use Page Report for the same two-form analysis by landing page.
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 buckets — 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
ranking (new_customer_revenue_cents desc, then sessions desc), unchanged.
order_dir is asc or desc, defaults to desc, and does nothing on its own.
- The re-rank applies to the complete bucket set, before
limit— moot at nine buckets, but the rule is the same one every report follows. - Nulls sort last in both directions. A bucket with no measurement for the column is unknown, not worst.
- Ties always break on
channelascending, so the order is total and repeated calls return the buckets in the same order.
Whole-set totals — totals
totals=true adds a totals object covering the complete filtered set before
limit — every bucket, 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.
totals_meta.upper_bound names the columns whose total overcounts, because
per-row values overlap between rows. Render these as approximations, not counts:
| Column | Why it overcounts |
|---|---|
unique_visitors | a visitor can arrive through several channels in one range, and the daily view's per-day distincts are summed |
totals_meta.omitted names the columns deliberately left out, so their absence
reads as a decision rather than a bug:
| Column | Reason |
|---|---|
sources | a companion array of source rows, not a row column |
events | a 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 bucket, measured over a second
window:
| Value | Window |
|---|---|
previous_period | the equal-length window immediately before range |
previous_year | the same calendar window one year back |
custom | compare_from + compare_until — both required here, both rejected otherwise |
prior: nullmeans the bucket 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 newly-active channel as flat.- Prior-only rows are not introduced. A bucket active then and silent now does not appear — the list is still the current window's.
- The prior window differs only in its dates: same workspace, same
page_url_scope, same filters, samefields. - Independent of
totals. A nestedtotals.priorappears only when both are set. compare_toroughly 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.
Both forms accept the same four block names, but they select different keys:
| Block | What it's for | Group columns | Single sections |
|---|---|---|---|
traffic | how many people arrived | unique_visitors, page_views, sessions | traffic |
attribution | what those people were worth | new_customers, new_customer_revenue_cents, existing_customers, existing_customer_revenue_cents, total_cash_cents | outcomes, top_pages |
sources | where inside the bucket they came from | the top-level sources array | sources, unclassified_referrers |
events | what they did in your product | events | events |
?fields=attribution → group rows keep only the outcome columns
?channel=AI&fields=sources → the single form keeps only `sources` (+ `unclassified_referrers`)Rules that bite:
- FullVision resolves the legal set AFTER form selection, not per endpoint. Supplying
channelpicks the single form and its block list; omitting it picks the group form and its own. A block that exists on only one form is a400on the other, naming the blocks legal there. - Empty, duplicated, unknown, and
*are all400s —fields=,fields=traffic,traffic,fields=nope,fields=*. A stray comma is a typo, not a request for everything. The error message lists the legal blocks inline. - Trimming does not guarantee a query saved per block. The contract is
correct trimming — you get exactly the blocks you asked for and nothing else
— never one fewer round trip per block dropped. Here
sourcesandattributionare two filters over the same underlying performance view. fieldsselects columns, never which rows exist.?fields=eventsreturns the same buckets the untrimmed call does, with only theeventscolumn on each. The Precision notes say which query decides that.- Row identity is never droppable.
channel,host_scopeandrangesurvive every trim. granularityis independent. Theseriesarray is not a block:?fields=attribution&granularity=weeklystill returns one.fieldsnever lowers the scopes the endpoint requires.?fields=trafficstill needsattribution:readas well asweb:read.
rows vs sources — two levels of detail, never summed together
The group form returns both:
rows— the 8 canonical buckets (Organic Search,AI, …). This is the authoritative bucket total.sources— the concrete origins (Google, ChatGPT, LinkedIn, a referrer host…), each tagged with thechannel_categorybucket it rolls up into.
Sources roll into rows. Summing both double-counts every payer. Use rows
for "how much did AI bring", sources for "which AI assistant".
sources respects the same limit as rows and ranks by
new_customer_revenue_cents desc, then unique_visitors desc. FullVision folds
each source's channel_category through the canonical mapping, so the legacy
LLMs label appears as AI and Other/blank as Non-attributed — a source never
reports a bucket outside the nine.
With the attribution block, rows[] and sources[] both carry
form_submits: contact-bearing, deduplicated form submissions credited to
the visitor's first-touch channel (rows[].ft_form_submits is the same value
under its historical name). A source that produced leads but no attributed payer
in the range still appears in sources, with zero traffic and cash. Sources
partition their bucket's form_submits exactly.
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: channel_category — the 9 buckets, the same grain and labels as
rows[].channel — and channel — the concrete source (Google Ads, Bing, a
referrer host…), the same grain as sources[].channel. channel is not the
9 buckets: the name follows the warehouse's dimension=channel, where
channel_category is the roll-up.
Under either split each point carries attributed_visitors: the attribution
cohort (people the channel first-touched who converted in range — the measure
sources[].unique_visitors reports), not the host-scoped traffic that
rows[].unique_visitors and the flat series carry. On one live day the split
read 362 for Google Ads while the traffic row read 50 for Paid Search; both
are right, they count different people.
Split points also still carry unique_visitors, as a deprecated alias of the
same cohort value — it is removed after 2026-11-26. Read attributed_visitors.
granularity=weekly → [{ date, unique_visitors, <metrics> }, …]
granularity=weekly&series_by=channel_category → [{ date, key, attributed_visitors, <metrics> }, …]form_submits is carried under both splits (keyed on the first-touch channel
category or channel). Submits whose visitor had no prior pageview have no
channel to segment on and are dropped from the split; the flat series counts
them. ft_signups is flat-only.
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.
- FullVision skips rows whose dimension value is blank: an unnamed series cannot be labelled or filtered by the caller.
- An unrecognised
series_byreturns an emptyseries(not an error, and not a silent fallback to the flat series). - Segment values may contain spaces (search queries, campaign names) —
keyis the full value. rowsis unaffected. Do not sum segmented buckets to get range totals; userows.
API key as Bearer token
In: header
Query Parameters
One of the 9 channel buckets: Organic Search, AI, Organic Social, Paid Search, Paid Social, Email, Direct, Referral, Non-attributed (case-insensitive; LLMs is accepted as a legacy alias for AI). Omit for the ranked group form. A value outside this vocabulary returns 404.
Group form only: number of ranked rows to return (default 20, max 1000). Ignored when channel is supplied.
1 <= value <= 1000Which host to scope traffic + sources to. Defaults to marketing. all removes the host predicate (every host). Attributed outcomes are host-independent and unaffected.
Value in
- "marketing"
- "product"
- "all"
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"
Custom start date (YYYY-MM-DD). Ignored if range is set.
^\d{4}-\d{2}-\d{2}$Custom end date (YYYY-MM-DD). Ignored if range is set.
^\d{4}-\d{2}-\d{2}$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"
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.
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.
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: channel, unique_visitors, page_views, sessions, new_customers, new_customer_revenue_cents, existing_customers, existing_customer_revenue_cents, total_cash_cents, ft_form_submits, form_submits.
Value in
- "channel"
- "unique_visitors"
- "page_views"
- "sessions"
- "new_customers"
- "new_customer_revenue_cents"
- "existing_customers"
- "existing_customer_revenue_cents"
- "total_cash_cents"
- "ft_form_submits"
- "form_submits"
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"
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"
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"
Prior-window start (YYYY-MM-DD). Required with compare_to=custom, rejected otherwise.
^\d{4}-\d{2}-\d{2}$Prior-window end (YYYY-MM-DD). Required with compare_to=custom, rejected otherwise.
^\d{4}-\d{2}-\d{2}$Attribution model for the multi-touch journey pipeline (group form only). Omitted = default first-touch cohort response.
Value in
- "first_touch"
- "last_touch"
- "linear"
- "time_decay"
time_decay half-life in days (1-90, default 7). Requires model=time_decay.
1 <= value <= 90Responses
Single-channel report (channel supplied) or the ranked channel group
/channel-reportcurl -X GET "https://data.fullvision.io/channel-report?channel=Organic+Search&range=last_30_days"{ "channel": "string", "host_scope": "string", "range": { "from": "string", "to": "string" }, "traffic": {}, "sources": [ {} ], "outcomes": {}, "top_pages": [ { "page_path": "string", "customers": 0, "revenue_cents": 0 } ], "unclassified_referrers": [ {} ], "events": [ { "name": "string", "people": 0 } ], "is_cached": true, "age_s": 0, "last_refresh": "string"}