Search Console data

Search Console data

Subscription required. Every endpoint on this page needs an active subscription. Without one the request is refused with 402 payment_required (Pick a plan to keep going.) before the endpoint runs, reads included. See Conventions.

Four endpoints read live Google Search Console data for a connected site — there are no GSC fact tables backing this API, so every call is a real request to Google made while your request waits. Expect roughly a second of latency and budget your per-key rate limit accordingly; none of these are suited to tight polling loops.

Scope: sites:read on all four.


Window parameters

All four endpoints accept the same window controls (as query parameters, or as body fields on POST /sites/{siteId}/search-analytics):

Parameter Type Notes
days integer Trailing window length, 1–365, default 28. Ends 2 days ago — Google Search Console's reporting lag means the most recent 2 days never have complete data. Ignored when start_date/end_date are given.
start_date string (YYYY-MM-DD) Explicit window start. Must be paired with end_date.
end_date string (YYYY-MM-DD) Explicit window end. Span (end_date - start_date + 1) must be ≤ 365 days.

Every response echoes the resolved window back as window: { start_date, end_date }, so you always know exactly what range you got even when you used the days shorthand.

gsc_status

Every response carries a gsc_status field describing whether live data was actually fetched:

gsc_status Meaning What to do
available Google returned data (possibly empty rows if there's genuinely no traffic). Use data normally.
not_indexed The page is known to be un-indexed as of a recent reconciliation (page-level endpoints only), or it's a planned page with no live URL yet. Nothing to fetch — don't retry.
not_connected The site has no active GSC connection. Tell the user to connect Search Console for this site in the MetaMonster dashboard.
unavailable The live call to Google failed (timeout, API error, etc.). Back off and retry later — don't loop tightly on this endpoint.

not_indexed is page-scoped and only short-circuits when the site's index reconciliation is fresh (within the last 7 days); otherwise the endpoint still makes the live call and may come back unavailable or available with empty rows.

Google failures never produce a 5xx — they always resolve to gsc_status: "unavailable" with empty data (and totals: null where applicable), so a non-2xx response from these endpoints means something else (auth, validation, rate limit).


GET /sites/{siteId}/performance

Live per-URL performance across the whole site — one row per URL Google Search Console reported on, restricted to the canonical host. Each row carries page_id when the URL matches one of your organization's pages (null for URLs Google indexed that MetaMonster doesn't track as a page). totals are undimensioned — computed as a separate query, not summed from data — so they're accurate even though per-row query rows drop under Google's anonymization threshold.

URL variants and page_id

Search Console reports https://example.com/, https://example.com/#reviews and https://example.com/?utm_source=nl as three separate rows. They're all the same page here, so page_id is resolved by canonical page key (host + path, no query, no fragment) — every variant of a URL you own comes back with the same page_id rather than null.

The rows themselves stay as Google reported them unless you ask for folding.

group_by=page

Param Value Effect
group_by page Fold every URL variant into one row per page

With group_by=page, each page gets exactly one row:

  • clicks and impressions are summed across the variants
  • ctr is recomputed as clicks / impressions (0 when there are no impressions)
  • position is the impressions-weighted mean of the variants' positions (an unweighted mean when there are no impressions to weight by)
  • url is the page's own canonical URL, not whichever variant Google reported

Rows that don't map to one of your pages still appear, folded by their canonical key, with page_id: null and the canonical key as url.

Folding runs before the row cap, so a page whose traffic is split across a dozen query-string variants is ranked (and kept) on its real total. meta.total then counts folded rows. totals is unaffected either way.

curl -s "https://app.metamonster.ai/api/v1/sites/42/performance?days=28&group_by=page" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Ordering and the row cap

Rows are ordered by clicks (descending), with impressions as the tiebreaker. Google returns up to 25,000 URLs for a large site, so there's a hard cap of 1000 rows per response — if the site has more, only the top 1000 by that ordering come back.

meta.total is the count of rows actually returned in this response (so it's always ≤ 1000) — it is not the site's true URL count when truncation happened. meta.truncated is the trust signal: check it, not meta.total, to know whether you're looking at everything. totals always covers the whole site regardless of the cap, so site-wide clicks/impressions stay accurate even on a truncated response.

Response includes Cache-Control: private, max-age=300.

Request

curl -s "https://app.metamonster.ai/api/v1/sites/42/performance?days=28" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": [
    { "url": "https://example.com/pricing", "page_id": 5001, "clicks": 120, "impressions": 5400, "ctr": 0.0222, "position": 11.3 },
    { "url": "https://example.com/old-campaign-page", "page_id": null, "clicks": 4, "impressions": 210, "ctr": 0.019, "position": 22.1 }
  ],
  "totals": { "clicks": 1204, "impressions": 58900, "ctr": 0.0204, "position": 16.8 },
  "gsc_status": "available",
  "window": { "start_date": "2026-07-17", "end_date": "2026-08-13" },
  "meta": { "total": 2, "truncated": false }
}

Errors

Status When
400 invalid_request Bad days/start_date/end_date (out of range, mismatched pairing, span > 365 days), or a group_by value other than page
404 not_found No such site in your organization

GET /pages/{pageId}/queries

Top search queries (by clicks, capped at 50) that surfaced the page's URL in Google's results over the window.

Which URL a page is measured on. Per-page GSC endpoints filter Search Console on one exact URL — the page's gsc_url (derived from Google's own indexed URLs) falling back to its url. If a page's numbers look implausibly small next to GET /sites/{siteId}/performance, that stored URL is probably pointing at a variant. Repoint it with PATCH /pages/{pageId} (gsc_url), or send "gsc_url": null to clear it and let the next reconciliation re-derive it. See Pages.

Request

curl -s "https://app.metamonster.ai/api/v1/pages/5001/queries?days=28" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": [
    { "query": "example pricing", "clicks": 64, "impressions": 1800, "ctr": 0.0356, "position": 8.2 },
    { "query": "example plans and pricing", "clicks": 21, "impressions": 640, "ctr": 0.0328, "position": 12.1 }
  ],
  "gsc_status": "available",
  "window": { "start_date": "2026-07-17", "end_date": "2026-08-13" }
}

Errors

Status When
400 invalid_request Bad window params
404 not_found No such page in your organization

GET /pages/{pageId}/performance

Daily time series for one page — one row per day that actually had data (days with zero activity are absent, not zero-filled), ascending by date — plus undimensioned totals for the whole window. Same gsc_status semantics as /queries.

Request

curl -s "https://app.metamonster.ai/api/v1/pages/5001/performance?start_date=2026-07-01&end_date=2026-07-31" \
  -H "Authorization: Bearer mm_YOUR_API_KEY"

Response 200

{
  "data": [
    { "date": "2026-07-02", "clicks": 6, "impressions": 180, "ctr": 0.0333, "position": 10.9 },
    { "date": "2026-07-03", "clicks": 4, "impressions": 165, "ctr": 0.0242, "position": 11.4 }
  ],
  "totals": { "clicks": 120, "impressions": 5400, "ctr": 0.0222, "position": 11.3 },
  "gsc_status": "available",
  "window": { "start_date": "2026-07-01", "end_date": "2026-07-31" }
}

Errors

Status When
400 invalid_request Bad window params
404 not_found No such page in your organization

POST /sites/{siteId}/search-analytics

The Performance report as a query: any dimensions, filters, search type and comparison window, sorted and paged here. It's a POST only because a filter array doesn't fit in a query string. It reads, never writes. The MCP tool query_search_console calls it.

Request body

All fields are optional. An empty body returns the last 28 days' totals.

Field Type Notes
page_id integer Only this page: filters on its reconciled Search Console URL. A page of another site is a 404.
dimensions array Any of query, page, country, device, date, searchAppearance, in the order the row keys should follow. Empty (default) returns totals only.
filters array Up to 10 { dimension, operator, expression }, all of which must match. dimension is any dimension but date. operator is equals (default), notEquals, contains, notContains, includingRegex or excludingRegex (RE2 syntax). Country uses ISO 3166-1 alpha-3 codes (usa); device is DESKTOP, MOBILE or TABLET.
days, start_date, end_date The window, as above.
search_type string web (default), image, video, news, discover or googleNews.
include_fresh boolean Adds the last ~2 days of provisional data. Default false.
compare_to string previous_period (the same number of days just before) or previous_year (the same dates a year back). Not with the date dimension. Google keeps 16 months of data, so a long window a year back may come back partly empty.
sort string clicks (default), impressions, ctr, position, or, with compare_to, clicks_change, impressions_change, position_change.
direction string asc or desc. Default desc, asc for position. Nulls sort last.
limit integer Rows per page, 1–200, default 50.
offset integer Rows to skip. Use meta.next_offset.

How rows are built

  • Up to 25,000 rows per window are pulled from Google, which orders by clicks only. They're sorted and paged here. meta.truncated is true when a window hit that cap: narrow with a filter.
  • With compare_to, rows join on their dimension values. A row that only exists in the earlier window is kept, with zero clicks and impressions now, so you can see what was lost.
  • totals come from an undimensioned query and are exact. Rows leave out queries Google anonymizes, so they add up to less.
  • ctr is rounded to 4 places and position to 2. position is null when there were no impressions. A negative change.position means the row moved up.
  • With the page dimension, every row carries page_id: the page its URL resolves to (by canonical page key, as above), or null. Rows aren't filtered to the site's canonical host.

Request

curl -s -X POST "https://app.metamonster.ai/api/v1/sites/12/search-analytics" \
  -H "Authorization: Bearer mm_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "dimensions": ["query"], "compare_to": "previous_period", "sort": "clicks_change", "direction": "asc", "limit": 2 }'

Response 200

{
  "data": [
    { "query": "seo audit tool", "clicks": 0, "impressions": 0, "ctr": 0, "position": null,
      "previous": { "clicks": 14, "impressions": 220, "ctr": 0.0636, "position": 6.4 },
      "change": { "clicks": -14, "impressions": -220, "ctr": -0.0636, "position": null } },
    { "query": "meta description length", "clicks": 22, "impressions": 900, "ctr": 0.0244, "position": 5.1,
      "previous": { "clicks": 31, "impressions": 950, "ctr": 0.0326, "position": 4.2 },
      "change": { "clicks": -9, "impressions": -50, "ctr": -0.0082, "position": 0.9 } }
  ],
  "totals": { "clicks": 1210, "impressions": 48000, "ctr": 0.0252, "position": 18.3,
    "previous": { "clicks": 1300, "impressions": 46500, "ctr": 0.028, "position": 17.9 },
    "change": { "clicks": -90, "impressions": 1500, "ctr": -0.0028, "position": 0.4 } },
  "gsc_status": "available",
  "window": { "start_date": "2026-10-06", "end_date": "2026-11-02" },
  "previous_window": { "start_date": "2026-09-08", "end_date": "2026-10-05" },
  "meta": { "total_rows": 3412, "offset": 0, "returned": 2, "next_offset": 2, "source_rows": 6650, "truncated": false }
}

Errors

Status When
400 invalid_request A bad body (for example a *_change sort without compare_to), or a query Google rejects (a bad regex, a dimension mix it won't group). The message carries Google's reason.
404 not_found No such site, or page_id isn't a page of this site

Prefer the brief's bundled queries when you can

GET /pages/{pageId}/brief already bundles a 28-day queries section for the page — if you're already calling it (or about to), read GSC from there instead of a separate live call, and reserve /queries and /performance for cases where you specifically need a custom window.