Page Report
Shows you what a page does for the business: who sees it, how it performs, and the revenue it drives.
Use it when you are deciding which page to write, rewrite or retire — or when someone asks what a blog post is actually worth.
The question it answers: which pages introduce people who go on to pay, and which ones only collect traffic?
Two ways to call it
| Call | What you get |
|---|---|
Omit page_path | Every page on one host, ranked, with traffic, engagement, revenue, search and product-event columns. This is the scorecard you sort. |
Pass page_path | That one page in detail: its traffic, its behaviour, its search performance, and the revenue credited to it. This is the optimisation view. |
What you get
| Data | What it tells you |
|---|---|
| Traffic | page views, unique visitors, sessions, landing sessions |
| Engagement | scroll depth, dead/rage clicks, form funnel, Core Web Vitals (p75) |
| First-touch outcomes | signups, customers and revenue for people whose first landing was here |
| Assisted outcomes | outcomes for journeys that merely touched this page |
| Search | Google Search Console (GSC) clicks, impressions, click-through rate (CTR), average position |
| Product events | exact unique people per product event, for people first-touched here |
p75 means the 75th percentile: three quarters of the measured page loads were
at least this good.
Where the numbers come from. Revenue and product events are first-touch
attribution — they count people whose first-ever landing was this page, not
conversions that happened on it. Traffic and engagement describe what happened
on the page itself and follow page_url_scope. Product-event 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.
Group form — omit page_path
A ranked, sortable scorecard of every page on one host over a date range — traffic, engagement, the revenue each page drove as a first-touch landing or an assisting touch, plus search, click-health and product-event columns. Raw columns, no composite score: you sort by whatever matters.
| Group | Columns | What it tells you |
|---|---|---|
| traffic | page_views, unique_visitors, sessions, landing_sessions | reach + how often the page is an entry point |
| engagement | engagement_rate_pct, bounce_rate_pct, median_engagement_ms, scroll_depth_50_pct, scroll_reach_3vp_pct, p75_lcp_ms, p75_inp_ms, p75_cls | behaviour + Core Web Vitals (p75) |
| first-touch | ft_signups, ft_activations, ft_customers, ft_revenue_cents | outcomes for people whose first-ever landing was this page |
| assisted | as_signups, as_activations, as_customers, as_revenue_cents | outcomes for people whose journey touched this page before converting |
| customer recency | new_customers, new_customer_revenue_cents, existing_customers, existing_customer_revenue_cents, total_cash_cents | payers attributed to this page, split by whether they were new or existing in the window |
| ads | is_ad_lp, lp_path | whether Google Ads serves this page as a configured ad destination |
| search | gsc_clicks, gsc_impressions, gsc_ctr_pct, gsc_avg_position | Google Search Console performance (null when the page has no GSC data in range) |
| click health | outbound_clicks, dead_clicks, rage_clicks | clicks that left the site, hit nothing, or repeated in frustration |
| product events | events | compact set (signup + top-2 catalog events): exact unique people per event, first-touched here |
FullVision ranks rows by ft_revenue_cents desc, then page_views desc. limit
(default 20, max 1000) applies AFTER ranking — the search, click-health and
event columns are only filled for the rows you get back, which is what keeps the
group form cheap.
Two orthogonal split axes — do not add them together
The row carries two independent splits of the same money, and neither is computable from the other:
| Axis | Columns | Splits by |
|---|---|---|
| Credit kind | ft_* vs as_* | which page gets the credit — first-touch (introduced the person) vs assisted (merely touched) |
| Customer recency | new_* vs existing_* | who the payer was — first-ever payment inside the window vs an existing customer who paid again |
A page can have 3 new customers of which 2 were first-touch credited and 1
assisted. ft_customers + new_customers is meaningless; pick one axis per
question. total_cash_cents is the recency axis's total (new + existing).
is_ad_lp means Google Ads serves this page as an advertiser-configured
ad final URL — not a page auto-served by Dynamic Search Ads or Performance Max.
lp_path echoes the canonical path that matched, or null when is_ad_lp is
false.
Reading the numbers — three traps
- First-touch vs assisted. First-touch credits the page that introduced the person; assisted credits any page the journey touched. They answer different questions and do not sum.
- Assisted overlaps across pages. One journey touching N pages credits all N,
so Σ
as_*across pages can exceed the workspace total. This is by design. ft_signups_per_landing_session_pctmixes date bases — range first-touch signups over range landing sessions, where first-touch is all-time. Treat it as a directional landing-page signal, not a cohort conversion rate.
Single form — supply page_path
The domains an SEO or content agent needs to optimise one page, assembled into one nested-JSON response:
| Section | Source | What it tells you |
|---|---|---|
traffic | page views by path | page views, unique visitors, bounce rate |
engagement | per-page behaviour | engaged-session rate, time-on-page percentiles, p75 Web Vitals, scroll depth, form funnel, outbound/dead/rage clicks |
revenue | first-touch attribution | new vs existing customers + cash attributed to this page as a first landing |
assisted | page attribution | outcomes for journeys that touched this page |
gsc | Google Search Console | clicks, impressions, CTR, impression-weighted avg position |
events | product events | count per event name fired by people who landed here |
Two page semantics — do not conflate them
traffic+engagementdescribe what happened on this page (thepage_pathcolumn).revenue+eventsare first-touch attribution: they count people whose first-ever landing page was this page — not on-page conversions. Therevenue._notefield restates this inline. This is the right framing for content ROI ("what did people who discovered us via this post go on to do"), but it is not "money made on this page".
Parameters
| Parameter | What it does | When you'd use it |
|---|---|---|
page_path | Addresses one page and switches the report into the single form. Host-qualified, scheme + query stripped (e.g. evaboot.com/blog/how-to-guide); the same value matches both the page_path and first_touch_page columns. | You have picked a page out of the scorecard and want everything about it. |
limit | Caps how many ranked rows come back (default 20, max 1000), applied after ranking. Group form only — ignored when page_path is supplied. | You want a top-10 leaderboard, or the full 1000-row page. |
starting_after | Group form only: returns the ranked rows after the row identity you pass, taken from next_cursor. Walk it until has_more is false to read past the 1000-row limit ceiling. | Your site has more than a thousand pages and you want all of them. |
page_url_scope | Pins traffic and engagement to one host: marketing (default) or product. all removes the host pin entirely — every host you have, plus the Non-attributed row (customers with no tracked first touch). Revenue and events are host-agnostic either way. | You run a marketing site and an app on different hosts and want pages from one of them — or all when the page revenue must add up to your Stripe total. |
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 page instead of returning one total per bucket. Group form only. | You want one line per page rather than a site-wide line. |
fields | Narrows the response to the blocks you name. It only ever removes blocks, never adds one. | Your client only renders search data and you don't want to pay for the rest of the payload. |
order_by, order_dir | Re-ranks the complete row set by one column before limit. Group form only. Omit for the default ranking. | You want the slowest pages, not the richest ones. |
totals | Adds a totals object over the complete filtered set, before limit, plus a totals_meta sidecar. Group form only. | You need the site-wide number without walking every page to add it up. |
compare_to, compare_from, compare_until | Attaches a prior object to each row, measured over a prior window. Group form only. | You want "up or down since last month" on the same row, 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 a page keeps 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 group form sorts by ft_revenue_cents descending, so under
page_url_scope=all the Non-attributed row is usually the first row. It is a
bucket, not a page — customers with no pageview-derived first touch. Re-sort or
filter it out if your surface ranks pages.
2026-08-17 — the page-attribution data and the attributed_page dimension
behind this report now include a Non-attributed row. This is the same
bucket the channel dimension has always exposed; the two dimensions are now
consistent. page_url_scope gained an opt-in all value. Output under
marketing or product, and output when the parameter is omitted, is unchanged.
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, gsc_clicks, gsc_impressions, gsc_ctr_pct,
gsc_avg_position and form_submits — contact-bearing, deduplicated form
submissions credited to the submitter's first-touch page, the same recipe as
/channel-report and /form-report. form_submits is also a row column
(the attribution block) and 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.
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.
In the single form, metrics are aggregated totals over the range (one number per
metric), except events and the per-dimension engagement lists (form_funnel,
outbound_clicks, dead_clicks, rage_clicks), which are arrays. Columns that
can't be summed over a range (e.g. unique_visitors on engagement views) are
omitted rather than returned as blanks.
Use Channel Report for the same two-form analysis by acquisition channel.
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, and the same cursor.
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 this report's
default ranking (ft_revenue_cents desc, then page_views desc), unchanged.
order_dir is asc or desc, defaults to desc, and does nothing on its own.
- The re-rank applies to the complete row set, before
limit— a page the default ranking buried below the limit surfaces, rather than the same top-20 reshuffled. - Nulls sort last in both directions. A page with no GSC data is unknown, not worst, and floating it to the top of an ascending sort would bury the rows you asked to see.
- Ties always break on
page_pathascending, so the order is total and a cursor walk visits every row exactly once. - The widened columns (
gsc_*,outbound_clicks,dead_clicks,rage_clicks,events) are not sortable here: they are filled only for the rows you get back, so sorting the complete set by one of them would sort by null on every row and silently return the default ranking.
Cursors under a non-default ordering
next_cursor is still echoed back into starting_after verbatim — never
construct or parse the value. Under the default ranking it is the bare row
identity it has always been. Under any order_by it becomes an opaque token
that also carries the ordering, so replaying a cursor under a different
order_by / order_dir is a 400 rather than a silent relocation into another
ordering. Repeat the original ordering on every page of a walk, or restart from
page one.
Whole-set totals — totals
totals=true adds a totals object covering the complete filtered set before
limit — the number you would otherwise get by walking every page — 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. The rate and search columns (bounce_rate_pct,
engagement_rate_pct, scroll_*, gsc_*, the three click counts) come from
their own whole-set aggregate queries rather than from the returned 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 | per-page distinct visitors overlap — a visitor who saw N pages is counted N times |
as_signups, as_activations, as_customers, as_revenue_cents | assisted credit overlaps across pages by design |
totals_meta.omitted names the columns deliberately left out, so their absence
reads as a decision rather than a bug:
| Column | Reason |
|---|---|
median_engagement_ms, p75_lcp_ms, p75_inp_ms, p75_cls | percentile columns cannot be merged outside ClickHouse |
is_ad_lp | a flag, not a metric |
lp_path | a label, not a metric |
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, and a whole-set column
whose block was dropped joins omitted.
Comparing against a prior window — compare_to
compare_to attaches a prior object to each row, 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 page 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 page as flat.- Prior-only rows are not introduced. A page with traffic then and none 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. Page Report is the one report that memoizes its ranked set, so a prior window you have already asked for is served from that cache rather than re-queried.
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 seven block names, but they select different keys:
| Block | What it's for | Group columns | Single sections |
|---|---|---|---|
traffic | how many people saw the page | page_views, landing_sessions, unique_visitors, sessions, bounce_rate_pct, engagement_rate_pct, median_engagement_ms | traffic, engagement.form_funnel |
vitals | whether the page loads well enough to keep them | p75_lcp_ms, p75_inp_ms, p75_cls | engagement.summary |
scroll | how far down they actually read | scroll_reach_3vp_pct, scroll_depth_50_pct | engagement.scroll_depth, engagement.scroll_reach |
attribution | what the page was worth | ft_*, as_*, new_*, existing_*, total_cash_cents, ft_signups_per_landing_session_pct, is_ad_lp, lp_path | revenue, assisted |
gsc | how the page performs in Google Search | gsc_clicks, gsc_impressions, gsc_ctr_pct, gsc_avg_position | gsc |
clicks | where clicking goes wrong | outbound_clicks, dead_clicks, rage_clicks | engagement.outbound_clicks, engagement.dead_clicks, engagement.rage_clicks |
events | what those people did in your product | events | events |
?fields=traffic,vitals → the group row keeps only those columns
?page_path=…&fields=gsc → the single form keeps only `gsc`Rules that bite:
- FullVision resolves the legal set AFTER form selection, not per endpoint. Supplying
page_pathpicks 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. Sources are shared:
on the group form
trafficandvitalsboth ride the engagement query, andattributionneeds the traffic query forft_signups_per_landing_session_pct. The contract is correct trimming — you get exactly the blocks you asked for and nothing else — never one fewer round trip per block dropped. A block never ships a key another block owns, whichever query happened to run:?page_path=…&fields=trafficreturnsengagement.form_funnelbut notengagement.summary. fieldsselects columns, never which rows exist.?fields=eventsreturns the same top-20 pages the untrimmed call does, with only theeventscolumn on each. The Precision notes say which queries decide that.- Row identity is never droppable.
page_path,host_scopeandrangesurvive every trim — a row without its key is unjoinable. granularityis independent. Theseriesarray is not a block:?fields=vitals&granularity=dailystill returns one.fieldsnever lowers the scopes the endpoint requires.?fields=trafficstill needsattribution:read,customer:read,revenue:read,search:readandweb:read(customer:readcoversform_submits— aggregate counts only, never a lead row).
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: page.
granularity=weekly → [{ date, <metrics> }, …]
granularity=weekly&series_by=page → [{ date, key, <metrics> }, …]Under series_by=page each point carries unique_visitors, new_customers,
new_customer_revenue_cents, existing_customers,
existing_customer_revenue_cents, total_cash_cents and form_submits, keyed
on the attributed (first-touch) page. Submits whose visitor had no prior
pageview have no page to segment on and are dropped from the split; the flat
series counts them.
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
Page to analyse — host-qualified, scheme + query stripped (e.g. evaboot.com/blog/how-to-guide). This same value matches both the page_path (traffic/engagement) and first_touch_page (revenue/events) columns. Omit for the ranked group form.
Group form only: number of ranked rows to return (default 20, max 1000). Ignored when page_path is supplied.
1 <= value <= 1000Group form only: return ranked rows after this cursor. ECHO a previous response's next_cursor verbatim — never construct or parse the value. Under the default ranking it is the row identity it has always been; under any order_by it is an opaque token that also encodes the ordering, so replaying a cursor under a different order_by/order_dir is a 400 instead of silently relocating you inside another ordering. Walk it until has_more is false to read the complete set past the 1000-row limit ceiling. Supplying it together with the addressing key is a 400.
Which host to scope traffic + engagement to. Defaults to marketing. all removes the host predicate: every host plus the Non-attributed bucket. Revenue + events use the host-stripped first-touch page and are unaffected by marketing/product.
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: page_path, page_views, landing_sessions, unique_visitors, sessions, bounce_rate_pct, engagement_rate_pct, median_engagement_ms, p75_lcp_ms, p75_inp_ms, p75_cls, scroll_reach_3vp_pct, scroll_depth_50_pct, ft_signups, ft_activations, ft_customers, ft_revenue_cents, ft_form_submits, ft_signups_per_landing_session_pct, as_signups, as_activations, as_customers, as_revenue_cents, as_form_submits, form_submits, new_customers, new_customer_revenue_cents, existing_customers, existing_customer_revenue_cents, total_cash_cents.
Value in
- "page_path"
- "page_views"
- "landing_sessions"
- "unique_visitors"
- "sessions"
- "bounce_rate_pct"
- "engagement_rate_pct"
- "median_engagement_ms"
- "p75_lcp_ms"
- "p75_inp_ms"
- "p75_cls"
- "scroll_reach_3vp_pct"
- "scroll_depth_50_pct"
- "ft_signups"
- "ft_activations"
- "ft_customers"
- "ft_revenue_cents"
- "ft_form_submits"
- "ft_signups_per_landing_session_pct"
- "as_signups"
- "as_activations"
- "as_customers"
- "as_revenue_cents"
- "as_form_submits"
- "form_submits"
- "new_customers"
- "new_customer_revenue_cents"
- "existing_customers"
- "existing_customer_revenue_cents"
- "total_cash_cents"
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-page report (page_path supplied) or the ranked page group
/page-reportcurl -X GET "https://data.fullvision.io/page-report?page_path=evaboot.com%2Fblog%2Fhow-to-guide&range=last_30_days"{ "page_path": "string", "host_scope": "string", "range": { "from": "string", "to": "string" }, "traffic": {}, "engagement": { "summary": {}, "scroll_depth": {}, "scroll_reach": {}, "form_funnel": [ {} ], "outbound_clicks": [ {} ], "dead_clicks": [ {} ], "rage_clicks": [ {} ], "clicks": [ {} ] }, "revenue": {}, "assisted": {}, "gsc": {}, "trend": [ {} ], "events": [ { "name": "string", "people": 0 } ], "is_cached": true, "age_s": 0, "last_refresh": "string"}