FullVision
Reports

Page Report

gethttps://data.fullvision.io/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

CallWhat you get
Omit page_pathEvery page on one host, ranked, with traffic, engagement, revenue, search and product-event columns. This is the scorecard you sort.
Pass page_pathThat 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

DataWhat it tells you
Trafficpage views, unique visitors, sessions, landing sessions
Engagementscroll depth, dead/rage clicks, form funnel, Core Web Vitals (p75)
First-touch outcomessignups, customers and revenue for people whose first landing was here
Assisted outcomesoutcomes for journeys that merely touched this page
SearchGoogle Search Console (GSC) clicks, impressions, click-through rate (CTR), average position
Product eventsexact 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.

GroupColumnsWhat it tells you
trafficpage_views, unique_visitors, sessions, landing_sessionsreach + how often the page is an entry point
engagementengagement_rate_pct, bounce_rate_pct, median_engagement_ms, scroll_depth_50_pct, scroll_reach_3vp_pct, p75_lcp_ms, p75_inp_ms, p75_clsbehaviour + Core Web Vitals (p75)
first-touchft_signups, ft_activations, ft_customers, ft_revenue_centsoutcomes for people whose first-ever landing was this page
assistedas_signups, as_activations, as_customers, as_revenue_centsoutcomes for people whose journey touched this page before converting
customer recencynew_customers, new_customer_revenue_cents, existing_customers, existing_customer_revenue_cents, total_cash_centspayers attributed to this page, split by whether they were new or existing in the window
adsis_ad_lp, lp_pathwhether Google Ads serves this page as a configured ad destination
searchgsc_clicks, gsc_impressions, gsc_ctr_pct, gsc_avg_positionGoogle Search Console performance (null when the page has no GSC data in range)
click healthoutbound_clicks, dead_clicks, rage_clicksclicks that left the site, hit nothing, or repeated in frustration
product eventseventscompact 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:

AxisColumnsSplits by
Credit kindft_* vs as_*which page gets the credit — first-touch (introduced the person) vs assisted (merely touched)
Customer recencynew_* 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_pct mixes 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:

SectionSourceWhat it tells you
trafficpage views by pathpage views, unique visitors, bounce rate
engagementper-page behaviourengaged-session rate, time-on-page percentiles, p75 Web Vitals, scroll depth, form funnel, outbound/dead/rage clicks
revenuefirst-touch attributionnew vs existing customers + cash attributed to this page as a first landing
assistedpage attributionoutcomes for journeys that touched this page
gscGoogle Search Consoleclicks, impressions, CTR, impression-weighted avg position
eventsproduct eventscount per event name fired by people who landed here

Two page semantics — do not conflate them

  • traffic + engagement describe what happened on this page (the page_path column).
  • revenue + events are first-touch attribution: they count people whose first-ever landing page was this page — not on-page conversions. The revenue._note field 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

ParameterWhat it doesWhen you'd use it
page_pathAddresses 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.
limitCaps 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_afterGroup 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_scopePins 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 + toSets the date window. Defaults to the last 30 days.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.You want a trend line rather than one total.
series_bySplits 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.
fieldsNarrows 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_dirRe-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.
totalsAdds 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_untilAttaches 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.
modelSwitches 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_daysHalf-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_path ascending, 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:

ColumnWhy it overcounts
unique_visitorsper-page distinct visitors overlap — a visitor who saw N pages is counted N times
as_signups, as_activations, as_customers, as_revenue_centsassisted 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:

ColumnReason
median_engagement_ms, p75_lcp_ms, p75_inp_ms, p75_clspercentile columns cannot be merged outside ClickHouse
is_ad_lpa flag, not a metric
lp_patha label, not a metric
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, 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:

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 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, same fields.
  • Independent of totals. A nested totals.prior appears only when both are set.
  • compare_to roughly 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:

BlockWhat it's forGroup columnsSingle sections
traffichow many people saw the pagepage_views, landing_sessions, unique_visitors, sessions, bounce_rate_pct, engagement_rate_pct, median_engagement_mstraffic, engagement.form_funnel
vitalswhether the page loads well enough to keep themp75_lcp_ms, p75_inp_ms, p75_clsengagement.summary
scrollhow far down they actually readscroll_reach_3vp_pct, scroll_depth_50_pctengagement.scroll_depth, engagement.scroll_reach
attributionwhat the page was worthft_*, as_*, new_*, existing_*, total_cash_cents, ft_signups_per_landing_session_pct, is_ad_lp, lp_pathrevenue, assisted
gschow the page performs in Google Searchgsc_clicks, gsc_impressions, gsc_ctr_pct, gsc_avg_positiongsc
clickswhere clicking goes wrongoutbound_clicks, dead_clicks, rage_clicksengagement.outbound_clicks, engagement.dead_clicks, engagement.rage_clicks
eventswhat those people did in your producteventsevents
?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_path picks 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 a 400 on the other, naming the blocks legal there.
  • Empty, duplicated, unknown, and * are all 400s — 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 traffic and vitals both ride the engagement query, and attribution needs the traffic query for ft_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=traffic returns engagement.form_funnel but not engagement.summary.
  • fields selects columns, never which rows exist. ?fields=events returns the same top-20 pages the untrimmed call does, with only the events column on each. The Precision notes say which queries decide that.
  • Row identity is never droppable. page_path, host_scope and range survive every trim — a row without its key is unjoinable.
  • granularity is independent. The series array is not a block: ?fields=vitals&granularity=daily still returns one.
  • fields never lowers the scopes the endpoint requires. ?fields=traffic still needs attribution:read, customer:read, revenue:read, search:read and web:read (customer:read covers form_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_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

page_path?string

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.

limit?integer

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

Range1 <= value <= 1000
starting_after?string

Group 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.

page_url_scope?string

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"
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: 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"
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}$
model?string

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"
half_life_days?integer

time_decay half-life in days (1-90, default 7). Requires model=time_decay.

Range1 <= value <= 90

Responses

Single-page report (page_path supplied) or the ranked page group

get/page-report
curl -X GET "https://data.fullvision.io/page-report?page_path=evaboot.com%2Fblog%2Fhow-to-guide&range=last_30_days"
Example Responses
{  "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"}
application/json