Keyword Report
Ranks the search keywords you show up for by the revenue they earned, and flags the ones a rewrite would move.
Use it when you are picking what to publish or refresh next, or when you need to answer "which searches make us money?" rather than "which searches make us traffic?".
The question it answers: which queries bring people who pay — and which near-miss queries are one rewrite away from doing so?
Two ways to call it
| Call | What you get |
|---|---|
Omit query | Every keyword, ranked by attributed revenue, with its search metrics and opportunity flags. This is the list you prioritise from. |
Pass query | That one keyword: its trend over time, its flags, and — if you ask — a live Search Console breakdown. This is the diagnosis view. |
What you get
| Data | What it tells you |
|---|---|
| Attributed revenue | revenue from customers whose journey involved this keyword |
| GSC metrics | clicks, impressions, click-through rate (CTR), average position |
| Striking distance | keywords ranking just off page one — a rewrite moves traffic |
| Content gap | impressions with no page that answers the query |
| Trend (single) | the keyword's Google Search Console metrics over time |
| Live Search Console breakdown | opt-in page/country/device split, fetched live from Google |
| Product events | exact unique people per product event, over the keyword's acquisition population |
Where the numbers come from. Search metrics are our synced Google Search Console (GSC) data — not live — except the opt-in live breakdown, which calls Google at request time. Product events count the first-touch acquisition population: people acquired via the keyword who later fired the event. 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.
Candidate flags are workspace-level and unscoped — a fixed 30-day window —
even when page_url_scope is supplied.
Parameters
| Parameter | What it does | When you'd use it |
|---|---|---|
query | Addresses one exact Google Search Console query and switches the report into the single form. | You picked a keyword out of the ranked list and want its history. |
gsc_breakdown | Single form only: a comma-separated list of page, country and device that fetches a live Search Console breakdown of that one query into passthrough. | You want to know which page or which country a query actually lands on, right now, rather than in the synced roll-up. |
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. | You rank for more than a thousand queries and you want all of them. |
limit | Caps how many ranked rows come back (default 20, max 1000). Group form only. | You want a top-20 shortlist instead of the whole keyword set. |
page_url_scope | Pins the report to one host: marketing or product. all removes the pin and ranks queries across every host you have. Candidate flags ignore it either way (see the note above). | You run a marketing site and an app on different hosts and only one of them ranks — or all when you do not know which host a query lands on. |
range, or from + to | Sets the date window. | 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 query instead of returning one total per bucket. Group form only. | You want one line per keyword rather than a single site-wide line. |
fields | Narrows the response to the blocks you name. It only ever removes blocks, never adds one. | Your client only renders the revenue columns and you don't want to pay for the rest of the payload. |
order_by, order_dir | Re-ranks the complete keyword set by one column before limit. Group form only. Omit for the default ranking. | You want the biggest impression counts, not the biggest revenue. |
totals | Adds a totals object over the complete filtered set, before limit, plus a totals_meta sidecar. Group form only. | You want total clicks and revenue across every keyword without walking the cursor. |
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" per keyword, 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 keyword 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 series array — granularity
granularity is purely additive: rows is unchanged, and omitting it returns
exactly the previous response. Each point carries new_customers,
existing_customers, new_customer_revenue, existing_customer_revenue,
attributed_revenue, gsc_clicks, gsc_impressions, gsc_ctr_pct and
gsc_avg_position.
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 — rate metrics are
recomputed per bucket. Use rows for totals.
Use Ad Report for the paid-search counterpart.
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 the default
attributed-revenue ranking over the merged organic + paid-only set, 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 keyword the default ranking buried below the limit surfaces, rather than the same top-20 reshuffled. - The GSC and
paid_*columns are sortable here, unlike on Page Report: they ride the query that produces the complete set rather than being filled in afterwards. A paid-only row reads its paid columns from the merged set, so sorting bypaid_spend_centsranks organic and paid-only keywords on one axis. - Nulls sort last in both directions. A paid-only keyword has no
gsc_avg_position; that is unknown, not worst. - Ties always break on the query ascending, so the order is total and a cursor walk visits every row exactly once.
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 the cursor to the end
— 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: gsc_ctr is whole-set clicks over whole-set impressions,
still as a fraction (a 3.2% CTR reads 0.032).
totals_meta.upper_bound is empty on this report — every summed column
partitions cleanly across keywords.
totals_meta.omitted names the columns deliberately left out, so their absence
reads as a decision rather than a bug:
| Column | Reason |
|---|---|
gsc_avg_position | impression-weighted; position_x_impressions is not a row column, so a whole-set value cannot be derived here |
gsc_data_days | a per-keyword day count; summing counts the same calendar day once per keyword |
exact_share | a share whose numerator and denominator are not row columns |
is_branded, striking_distance, content_gap | a flag, not a metric |
tags | a label array, not a metric |
top_channel, top_channel_category, top_landing_page, source | 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.
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 keyword 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 keyword as flat.- Prior-only rows are not introduced. A keyword that ranked then and does not now will 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.
The paid half — paid_* and source
Rows merge the organic GSC keyword with its paid counterpart from
ads-performance at level=keyword, which is Google-only — the other ad
platforms have no keyword level at all. Use these columns when you want to see
whether you are paying for a click you already earn organically.
| Column | Meaning |
|---|---|
paid_clicks | ad clicks on this keyword |
paid_conversions | platform-reported conversions |
paid_spend_cents | ad spend, in cents |
paid_revenue_cents | first-charge gross attributed to the paid click, in cents |
source | organic (GSC only), paid (ads only), or both |
Matching is case-insensitive: the GSC query sales navigator export merges with
the ad keyword Sales Navigator Export.
Things to know:
- Both money columns are cents.
spendis major units on the underlying ads view and FullVision converts it here, so every money field on a report row shares one unit. - A workspace with no ads connection gets zeros, not an error. Paid data is an enrichment; a failing ads view never takes the organic report down.
- Paid-only keywords are their own rows (
source: "paid", zero GSC columns) and compete for the samelimitslots as organic ones, ranked on the shared cents axis. They are addressable in the single form too. - Mixed currencies. Currency is a grouping dimension on the ads view; this roll-up keys on the keyword alone, so a workspace running one keyword across several account currencies gets a mixed-currency sum.
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.
| Block | What it's for | Group columns | Single sections |
|---|---|---|---|
attribution | what the keyword earned | new_customers, existing_customers, new_customer_revenue, existing_customer_revenue, attributed_revenue, paying_persons, signup_count, form_submit_count | the same keys under performance |
gsc | how you rank and where the openings are | gsc_clicks, gsc_impressions, gsc_avg_position, gsc_ctr, gsc_data_days, exact_share, is_branded, tags, top_channel, top_channel_category, top_landing_page, striking_distance, content_gap | the same under performance, plus top-level striking_distance, content_gap, trend |
paid | what you also pay for the same words | paid_clicks, paid_conversions, paid_spend_cents, paid_revenue_cents, source | same |
events | what the people you acquired did in your product | events | events |
?fields=attribution → rows keep only the revenue columns
?fields=gsc → GSC metrics + the opportunity flags
?query=evaboot&fields=gsc → the single form's performance.gsc_* plus its trendRules that bite:
attributionandgscshare one query.keyword-performancefills both, so dropping just one saves no round trip — it only narrows the payload. Dropping both skips it. The contract is correct trimming, never one fewer query per block dropped.paidgenuinely changes which keywords appear. Paid-only keywords exist in the response solely because ofads-performance, so a response that includespaidcan list keywords an attribution-only one does not. Every other block leaves the row set alone — the Precision notes say which query decides it.passthroughis not a block.gsc_breakdowncontrols it alone.?gsc_breakdown=device&fields=attributionstill performs the live Search Console call, still fillspassthrough, and still502s if that upstream fails — an explicit opt-in outranks an omission.- Empty, duplicated, unknown, and
*are all400s —fields=,fields=gsc,gsc,fields=nope,fields=*. The error names the legal blocks. - Row identity is never droppable.
query,host_scopeandrangesurvive every trim. granularityis independent. Theseriesarray is not a block.fieldsnever lowers the scopes the endpoint requires.?fields=paidstill needssearch:readalongsideattribution:readandweb:read.
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: query.
granularity=weekly → [{ date, <metrics> }, …]
granularity=weekly&series_by=query → [{ 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.
- 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
Exact Google Search Console query. Omit for the ranked group form.
Group form only: number of ranked rows to return (default 20, max 1000). Ignored when the addressing key 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 to. Defaults to marketing. all removes the host predicate entirely (every host in the workspace).
Value in
- "marketing"
- "product"
- "all"
Single form only (requires query). Comma-separated GSC dimensions for a LIVE Search Console breakdown of this keyword: any of page, country, device (1–3, order preserved). Omit for no live slice. 400 if used without query, or with query/date/unknown values. When present, the response passthrough is populated and the whole request 502s if the live fetch fails.
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: query, attributed_revenue, new_customers, existing_customers, new_customer_revenue, existing_customer_revenue, paying_persons, signup_count, form_submit_count, gsc_clicks, gsc_impressions, gsc_ctr, gsc_avg_position, paid_clicks, paid_conversions, paid_spend_cents, paid_revenue_cents.
Value in
- "query"
- "attributed_revenue"
- "new_customers"
- "existing_customers"
- "new_customer_revenue"
- "existing_customer_revenue"
- "paying_persons"
- "signup_count"
- "form_submit_count"
- "gsc_clicks"
- "gsc_impressions"
- "gsc_ctr"
- "gsc_avg_position"
- "paid_clicks"
- "paid_conversions"
- "paid_spend_cents"
- "paid_revenue_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-keyword report (query supplied) or the ranked keyword group
/keyword-reportcurl -X GET "https://data.fullvision.io/keyword-report?query=evaboot&range=last_30_days"{ "query": "string", "host_scope": "string", "range": { "from": "string", "to": "string" }, "performance": {}, "trend": [ {} ], "striking_distance": true, "content_gap": true, "passthrough": { "dimensions": [ "string" ], "range": { "from": "string", "to": "string" }, "rows": [ {} ], "row_count": 0, "truncated": true, "fetched_at": "string" }, "events": [ { "name": "string", "people": 0 } ], "paid_clicks": 0, "paid_conversions": 0, "paid_spend_cents": 0, "paid_revenue_cents": 0, "source": "organic", "is_cached": true, "age_s": 0, "last_refresh": "string"}