FullVision
Reports

Ad Report

gethttps://data.fullvision.io/ad-report

Ranks your paid campaigns by what they spend, and shows what came back.

Use it when you are deciding which campaign to scale, pause or rebuild — or when someone asks whether the ad budget is paying for itself.

The question it answers: where is the ad money going, and which of it comes back as customers?

Two ways to call it

CallWhat you get
Omit entityA spend-ranked leaderboard at the level you pick, plus the workspace-level Google landing-pages snapshot. This is the "where is the money going" view.
Pass entityThat one campaign, ad group, ad or keyword: its row, its lifetime economics, and the date its fair-window measurement starts. This is the "is this one working" view.

What you get

DataWhat it tells you
Spendwhat each campaign / ad group / ad / keyword cost
Full-window metricsclicks, conversions, revenue, return on ad spend (ROAS) over the whole range
Fair-window metricsthe same, excluding days too recent for conversions to mature
LTV / CAC / ROASlifetime value (LTV), customer acquisition cost (CAC) and lifetime ROAS on both forms, campaign-level, Google only — null on non-Google rows
Landing pagesworkspace-level Google landing-pages snapshot — a fixed trailing 90 days, not your range
Product eventsexact unique people per product event — campaign-level only

Where the numbers come from. The ad platforms themselves (Google, Facebook, LinkedIn and Reddit) supply spend and clicks. Revenue is joined back to a click through its gclid — Google's click identifier — and that join is thin, which is why the leaderboard ranks by spend, not revenue: spend is complete, joined revenue is not. Product events are campaign-level only (empty at the ad_group, ad and keyword levels) and cover first-touch paid payers (Paid Search or Paid Social); 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.

Lifetime economics are Google-only and campaign-level. Every row carries the LTV / CAC / ROAS keys so the response shape never varies, but they are null outside level=campaign and on non-Google rows — including on the default cross-platform call, where the Google rows among them are populated and the rest are null. A null here means "we cannot compute this for this row", not "zero".

Reddit accounts and attribution

Reddit supports campaign, ad group and ad levels. Reports include only the account currently connected to the workspace, including cross-platform calls, comparisons and chart series. An account_id filter cannot expose a previously connected account; no connection means no Reddit rows.

First-party Reddit revenue attaches at campaign grain when utm_campaign contains the Reddit campaign ID. Bare click IDs or campaign names do not match spend. Ad group and ad rows keep platform-reported purchases and spend, but have no first-party conversion credit. Reddit has no keyword level or click map; choosing a journey model does not create Reddit multi-touch attribution.

Two things that do not follow your range

landing_pages is a fixed trailing-90-day snapshot. It is workspace-wide and Google-only, ignores from / to, level, entity and account_id, and comes back even when rows is empty — so a two-day range returns the same landing_pages block as a thirty-day one. Its g_clicks is a 90-day figure and obs_gclid_clicks counts gclid pageviews (a reload inflates it). Never reconcile either against the windowed rows.

level=keyword sees keyword-matched Search traffic only. It reads Google's keyword_view, and impressions or clicks served through Display expansion carry no keyword, so they are absent at this level. When a Search campaign has Display expansion on, keyword rows can sum to a few percent of the campaign (3% of impressions on one live week, 86% the week before). level=ad_group reconciles to the campaign exactly; read a keyword-vs-campaign gap as non-keyword serving, not as missing data.

Full window vs fair window

Conversions land days after the click, so a campaign that started spending yesterday looks terrible on the full window: it has all of the spend and almost none of the revenue.

  • Full window — every day in your range, spend and revenue alike.
  • Fair window — the same metrics with the most recent days clipped off, because those days are too new for their conversions to have arrived yet. The single form returns the date that window starts as measurement_start.

Compare like with like: judge a young campaign on its fair-window figures and a mature one on either.

Parameters

ParameterWhat it doesWhen you'd use it
entityAddresses one ad entity at the selected level and switches the report into the single form. Matches an exact id first, then an exact name (case-insensitive).You picked a campaign off the leaderboard and want its lifetime economics.
levelSets the reporting level: campaign (default), ad_group, ad, or keyword (Google only). It is a context slice, not addressing — the report still returns a list.You know which campaign is spending and now want to see which ad group or keyword inside it is doing it.
platformRestricts the report to google, facebook, linkedin or reddit. Omit for the cross-platform union.You are reviewing one channel's budget — or you need to disambiguate an entity whose name exists on several platforms, where it is required.
account_idRestricts the report to one Google Ads leaf account customer id. Omit for the union across the whole workspace. It slices both the leaderboard rows and the series.You run several Google Ads accounts under one workspace (agency or multi-brand) and want one account's numbers on their own.
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 spend-over-time line rather than one total.
series_bySplits that series by campaign instead of returning one total per bucket. Group form only.You want one line per campaign rather than a single budget line.
fieldsNarrows the response to the blocks you name. It only ever removes blocks, never adds one.Your client only renders lifetime economics and you don't want to pay for the rest of the payload.
order_by, order_dirRe-ranks the complete leaderboard by one column before limit. Group form only. Omit for the spend ranking.You want the leaderboard by lifetime value, not by spend.
totalsAdds a totals object over the complete leaderboard, before limit, plus a totals_meta sidecar. Group form only.You want total budget and total return under the table.
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 campaign, 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.You want to see how much credit a campaign 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 spend, revenue and reported_conversion_value.

Buckets are labelled with their start date (Monday for weekly), oldest first, and 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 Keyword Report for the organic-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.

Re-ranking the leaderboard — 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 spend ranking, unchanged. order_dir is asc or desc, defaults to desc, and does nothing on its own.

  • The re-rank applies to the complete leaderboard, before limit — a campaign the spend ranking buried below the limit surfaces, rather than the same top-20 reshuffled.
  • The three LTV columns are sortable: ltv-by-campaign is read once for every Google campaign before the spend ranking, so their values exist across the complete set. events is not sortable — it is filled only for the rows you get back.
  • Nulls sort last in both directions. A non-Google row has no avg_ltv_cents; that is unknown, not worst.
  • Ties always break on the level's identity (campaign_id, ad_group_id, ad_id or the keyword) ascending, so the order is total and two equal-spend campaigns never swap between calls.

Whole-set totals — totals

totals=true adds a totals object covering the complete leaderboard before limit — every entity at the selected level, not just the returned ones — plus a totals_meta sidecar. Anything other than true/false is a 400; nothing is coerced.

Rates are recomputed from their bases over the whole set, never summed or averaged across rows: avg_ltv_cents is whole-set lifetime revenue over whole-set customers.

totals_meta.upper_bound is empty on this report — every summed column partitions cleanly across ad entities.

totals_meta.omitted names the columns deliberately left out, so their absence reads as a decision rather than a bug:

ColumnReason
ltv_roasits denominator is lifetime spend, which is not a column of this row
cacits numerator is lifetime spend, which is not a column of this row
ltv_cac_ratioderived from cac, whose base is not a column of this row
currencya label, not a metric
landing_pagesa workspace-level companion list of landing pages, not a row column
eventsa per-row array of named counts; there is no whole-set scalar

Units and currency. Every money value in the response is in spend_currency (the workspace display currency, also the currency label on each row). Spend recorded in another ad-account currency is converted per day at that day's FX rate, so full_spend, clipped_spend, the reported conversion values, CPC, CPA and CAC (all major units) divide cleanly into the revenue and LTV columns (integer cents). An account already in the display currency passes through unchanged. currency is omitted from totals because it is a label, not a metric — the total itself is single-currency.

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 entity 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 campaign as flat.
  • Prior-only rows are not introduced. A campaign that spent then and is paused now does not appear — the list is still the current window's.
  • The prior window differs only in its dates: same workspace, same level, platform and account_id, 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, and that includes a second read of the un-windowed ltv-by-campaign view, whose lifetime figures are identical in both windows. Only Page Report memoizes its ranked set; this one does not.

Trimming the response — fields

fields is a comma-separated list of response blocks. Omit it and you get every block — it only ever narrows a response, never widens one, so every existing call keeps its exact shape.

BlockWhat it's forGroup columnsSingle sections
performancewhat it cost and what came back in rangecurrency, full_* and clipped_* (spend / conversions / revenue / impressions / clicks)row, measurement_start
landing_pageswhere Google is sending the paid clicks (fixed trailing 90 days, ignores range)landing_pageslanding_pages
ltvwhether a customer is worth more than they costcustomer_count, lifetime_revenue_cents, avg_ltv_cents, ltv_roas, cac, ltv_cac_ratioltv
eventswhat the people you bought did in your producteventsevents
?fields=ltv                        → campaign rows keep only lifetime economics
?fields=performance                → the spend leaderboard, no LTV / landing pages
?entity=123&fields=ltv             → that campaign's LTV block alone

Rules that bite:

  • The ids and names are not a block. campaign_id, campaign_name, ad_group_id, ad_id, keyword, platform, account_id, level and range are row identity — they survive every trim and cannot be named in fields. On the single form, entity is identity but row is not: it is the performance payload, so ?fields=ltv omits it.
  • ltv is campaign-level and Google-only. Every row carries the six LTV keys so the response shape does not vary by level, but they are null outside level=campaign and on non-Google rows — including on the default cross-platform call, where the Google rows among them are populated and the rest are null.
  • Trimming does not guarantee a query saved per block. The leaderboard query is also what resolves an addressed entity, so it runs whatever you ask for — ?fields=ltv still issues it, then keeps only the LTV columns. The Precision notes name it.
  • Empty, duplicated, unknown, and * are all 400s — fields=, fields=ltv,ltv, fields=nope, fields=*. The error names the legal blocks.
  • granularity is independent. The series array is not a block.
  • fields never lowers the scopes the endpoint requires.

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: campaign.

granularity=weekly                 → [{ date, <metrics> }, …]
granularity=weekly&series_by=campaign  → [{ 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.
  • Rows whose dimension value is blank are skipped: 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

entity?string

Ad entity at the selected level — exact id first, then exact name (case-insensitive). Omit for the ranked group form. Ambiguous across platforms? Add platform.

level?string

Reporting grain: campaign (default), ad_group, ad, or keyword (Google-only).

Value in

  • "campaign"
  • "ad_group"
  • "ad"
  • "keyword"
platform?string

Ad network. Omit for the cross-platform union. Required to disambiguate an entity name present on several platforms.

Value in

  • "google"
  • "facebook"
  • "linkedin"
  • "reddit"
  • "twitter"
account_id?string

Google Ads leaf account customer id. Omit for the within-workspace union. Slices both the leaderboard rows and the series.

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
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: full_spend, full_conversions, full_revenue, full_reported_conversions, full_reported_conversion_value, full_impressions, full_clicks, clipped_spend, clipped_conversions, clipped_revenue, clipped_reported_conversions, clipped_reported_conversion_value, clipped_impressions, clipped_clicks, customer_count, lifetime_revenue_cents, avg_ltv_cents.

Value in

  • "full_spend"
  • "full_conversions"
  • "full_revenue"
  • "full_reported_conversions"
  • "full_reported_conversion_value"
  • "full_impressions"
  • "full_clicks"
  • "clipped_spend"
  • "clipped_conversions"
  • "clipped_revenue"
  • "clipped_reported_conversions"
  • "clipped_reported_conversion_value"
  • "clipped_impressions"
  • "clipped_clicks"
  • "customer_count"
  • "lifetime_revenue_cents"
  • "avg_ltv_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-entity ad report (entity supplied) or the ranked leaderboard group

get/ad-report
curl -X GET "https://data.fullvision.io/ad-report?entity=Brand&level=campaign&platform=google"
Example Responses
{  "level": "string",  "platform": "string",  "entity": {    "id": "string",    "name": "string",    "campaign_kind": "cold"  },  "range": {    "from": "string",    "to": "string"  },  "spend_currency": "string",  "row": {},  "ltv": {},  "measurement_start": "string",  "landing_pages": [    {}  ],  "events": [    {      "name": "string",      "people": 0    }  ],  "is_cached": true,  "age_s": 0,  "last_refresh": "string"}
application/json