FullVision
Reports

Keyword Report

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

CallWhat you get
Omit queryEvery keyword, ranked by attributed revenue, with its search metrics and opportunity flags. This is the list you prioritise from.
Pass queryThat 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

DataWhat it tells you
Attributed revenuerevenue from customers whose journey involved this keyword
GSC metricsclicks, impressions, click-through rate (CTR), average position
Striking distancekeywords ranking just off page one — a rewrite moves traffic
Content gapimpressions with no page that answers the query
Trend (single)the keyword's Google Search Console metrics over time
Live Search Console breakdownopt-in page/country/device split, fetched live from Google
Product eventsexact 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

ParameterWhat it doesWhen you'd use it
queryAddresses 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_breakdownSingle 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_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.You rank for more than a thousand queries and you want all of them.
limitCaps 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_scopePins 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 + toSets the date window.Always worth setting explicitly — a default window makes two reports look like they disagree.
granularityAdds a series array with one point per daily, weekly or monthly bucket, for charting. Group form only.You want a trend line rather than one total.
series_bySplits 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.
fieldsNarrows 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_dirRe-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.
totalsAdds 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_untilAttaches 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.
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 keyword 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 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 by paid_spend_cents ranks 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:

ColumnReason
gsc_avg_positionimpression-weighted; position_x_impressions is not a row column, so a whole-set value cannot be derived here
gsc_data_daysa per-keyword day count; summing counts the same calendar day once per keyword
exact_sharea share whose numerator and denominator are not row columns
is_branded, striking_distance, content_gapa flag, not a metric
tagsa label array, not a metric
top_channel, top_channel_category, top_landing_page, sourcea 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.

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 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, same fields.
  • Independent of totals. A nested totals.prior appears only when both are set.
  • compare_to roughly doubles the cold-path work — every query this report issues runs a second time over the prior window. Only Page Report memoizes its ranked set; this one does not.

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.

ColumnMeaning
paid_clicksad clicks on this keyword
paid_conversionsplatform-reported conversions
paid_spend_centsad spend, in cents
paid_revenue_centsfirst-charge gross attributed to the paid click, in cents
sourceorganic (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. spend is 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 same limit slots 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.

BlockWhat it's forGroup columnsSingle sections
attributionwhat the keyword earnednew_customers, existing_customers, new_customer_revenue, existing_customer_revenue, attributed_revenue, paying_persons, signup_count, form_submit_countthe same keys under performance
gschow you rank and where the openings aregsc_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_gapthe same under performance, plus top-level striking_distance, content_gap, trend
paidwhat you also pay for the same wordspaid_clicks, paid_conversions, paid_spend_cents, paid_revenue_cents, sourcesame
eventswhat the people you acquired did in your producteventsevents
?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 trend

Rules that bite:

  • attribution and gsc share one query. keyword-performance fills 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.
  • paid genuinely changes which keywords appear. Paid-only keywords exist in the response solely because of ads-performance, so a response that includes paid can list keywords an attribution-only one does not. Every other block leaves the row set alone — the Precision notes say which query decides it.
  • passthrough is not a block. gsc_breakdown controls it alone. ?gsc_breakdown=device&fields=attribution still performs the live Search Console call, still fills passthrough, and still 502s if that upstream fails — an explicit opt-in outranks an omission.
  • Empty, duplicated, unknown, and * are all 400s — fields=, fields=gsc,gsc, fields=nope, fields=*. The error names the legal blocks.
  • Row identity is never droppable. query, host_scope and range survive every trim.
  • granularity is independent. The series array is not a block.
  • fields never lowers the scopes the endpoint requires. ?fields=paid still needs search:read alongside attribution:read and web: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_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

query?string

Exact Google Search Console query. Omit for the ranked group form.

limit?integer

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

Range1 <= value <= 1000
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 to. Defaults to marketing. all removes the host predicate entirely (every host in the workspace).

Value in

  • "marketing"
  • "product"
  • "all"
gsc_breakdown?string

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.

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: 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"
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-keyword report (query supplied) or the ranked keyword group

get/keyword-report
curl -X GET "https://data.fullvision.io/keyword-report?query=evaboot&range=last_30_days"
Example Responses
{  "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"}
application/json