Docs

Google Search Console API

Pull your Search Console performance and indexing data into your own scripts, reports and dashboards, with more history than Google keeps.

Overview

BasecampSEO keeps your Search Console history past Google's 16 month cut off, and layers movers, brand and non brand splits, page groups, annotations and indexing coverage on top of it. This API gives you all of it as JSON.

Build the weekly client report that writes itself, alert on the queries that fell off page one, feed a warehouse, or check whether the pages you just shipped ever got indexed. If you would rather ask in plain language than write code, the same data is available over MCP.

Everything is read-only and available on paid Pro and Agency plans.

Authentication

Create a key in Settings, Developer tools and send it as a bearer token. The key appears once and we only store a hash of it, so a lost key has to be replaced rather than recovered.

A key acts as the person who created it. It can reach exactly the sites that person can reach, and every endpoint is read-only, so a key cannot edit data, start a sync, request indexing, or create another key. Remove someone from a workspace and their key loses that workspace with them.

Request
curl "https://api.basecampseo.com/v1/properties" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY"

API access is available on paid Pro and Agency plans. Trials are not included, and the check runs on every request, so a lapsed subscription stops working immediately rather than when the key expires.

Response shape

Every successful response has the same three keys. data is the payload, meta describes how it was produced.

Response
{
  "success": true,
  "data": { "rows": [] },
  "meta": {
    "request_id": "0f4c1b9e-7a2d-4c3f-9b11-2a8d5e6f7c10",
    "property_id": "8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8",
    "date_range": { "start_date": "2026-08-18", "end_date": "2026-09-14", "preset": "last_28d" },
    "row_count": 25,
    "limit": 25,
    "total": null,
    "truncated_by": "clicks",
    "source": "live",
    "stale_reason": null,
    "cache": "miss",
    "notes": []
  }
}

Two fields in meta deserve attention.

  • source is live when the numbers came straight from Google, or stored when they came from our longer term archive, which keeps the top 100 queries and pages per day. Archive clicks are close to reality, but archive impressions under report the long tail. stale_reason is set when a disconnected Google account forced the fallback.
  • truncated_by is clicks when there were more rows than we could fetch. Search Console ranks by clicks before truncating, so that is the axis anything missing fell off. total is null for these endpoints because Search Console does not report one.

Date ranges

Use date_range for a relative window, or start_date and end_date in YYYY-MM-DD form for an exact one. Everything is anchored two days ago, because Search Console publishes with roughly that delay. An end date later than the anchor is moved back to it and a line explaining why appears in meta.notes.

ValueMeaning
last_7dSeven days ending at the anchor
last_28dThe default, and the one to use for “how are we doing”
last_30d, last_90dFixed day counts
last_3m, last_6m, last_12m, last_16mCalendar months back
month_to_dateFirst of this month to the anchor
previous_monthThe whole of last calendar month, good for reporting
year_to_date, previous_yearCalendar years

Ranges older than 16 months are served from the archive, and how far back you can go depends on your plan. Call /v1/properties/{id} to see the window for a given site.

Filters

filters is a URL encoded JSON array. Filters apply across dimensions, so you can ask for top queries restricted to mobile traffic from one country. Any filter forces a live Search Console query.

Request
curl -G "https://api.basecampseo.com/v1/properties/example.com/queries" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY" \
  --data-urlencode 'date_range=previous_month' \
  --data-urlencode 'filters=[{"dimension":"device","operator":"equals","value":"mobile"}]'

Dimensions are query, page, country, device, plus two virtual ones. brand takes brand or non-brand and expands from the brand terms configured for that property. page_group takes a page group name. Operators are contains, equals, regex, their three negations, and is / is_not.

A malformed filter returns 400 rather than being ignored, so you never get unfiltered numbers under a filtered label.

Rate limits

One number to watch. Every key on your account draws from a single daily allowance, and it resets at midnight UTC.

PlanRequests per dayAPI keys
Pro1,0003
Agency5,00010

Repeating a call within a few minutes is served from cache, which keeps the allowance going further than it looks and means we rarely have to ask Google for anything twice.

Two guard rails sit underneath, and you will only meet them if something is wrong. A burst cap stops a runaway loop spending the day in thirty seconds, and a per property cap on live Search Console lookups protects your own dashboard, which draws on the same Google quota you do.

Response headers
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 994
X-RateLimit-Reset: 1789603200
X-Cache: hit

A 429 says which ceiling you reached and includes Retry-After in seconds. GET /v1/me returns your allowance and what is left of it. If your work needs more, get in touch. We raise limits per account.

Errors

Errors use the same envelope with success: false. Quote the request_id if you need support.

Response
{
  "success": false,
  "error": {
    "code": "UPSTREAM_QUOTA_EXCEEDED",
    "message": "This property has reached its hourly Search Console request budget. Retry in 1420s.",
    "request_id": "0f4c1b9e-7a2d-4c3f-9b11-2a8d5e6f7c10",
    "docs_url": "https://basecampseo.com/docs/api"
  }
}
StatusCodes
400VALIDATION_ERROR, DATE_RANGE_TOO_OLD, UNSUPPORTED_PARAMETER, PROPERTY_AMBIGUOUS
401INVALID_API_KEY, API_KEY_REVOKED, API_KEY_EXPIRED
403API_ACCESS_REQUIRED, INSUFFICIENT_SCOPE
404NOT_FOUND, also returned for properties your key cannot reach
429RATE_LIMITED, QUOTA_EXCEEDED, UPSTREAM_QUOTA_EXCEEDED
503FILTERED_DATA_UNAVAILABLE, returned instead of serving unfiltered numbers as filtered

Account

GET/v1/me

Who this key is, what it can do, and how much of each budget is left. Useful as a health check and for pacing a long running script.

Request
curl "https://api.basecampseo.com/v1/me" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY"
Response
{
  "success": true,
  "data": {
    "key": {
      "id": "3c9d1f22-5a7b-4e88-9f01-6d2e3a4b5c6d",
      "scopes": ["properties:read", "performance:read", "indexing:read", "config:read"],
      "workspace_scope": null
    },
    "account": { "plan_tier": "pro" },
    "limits": {
      "requests_per_minute_per_key": 20,
      "requests_per_hour": 250,
      "requests_per_day": 1000,
      "search_console_calls_per_day": 250
    },
    "usage": {
      "requests_this_hour": 6,
      "requests_today": 41,
      "search_console_calls_today": 12,
      "search_console_calls_remaining_today": 238
    }
  }
}

Properties

Every path below accepts a property id, a bare domain such as example.com, a full Search Console property string such as sc-domain:example.com, or a dashboard slug. If a domain is connected twice, you get a 400 listing both ids.

GET/v1/properties

Every site this key can read, sorted by name. Archived sites are left out unless you ask for them.

ParameterTypeDescription
limitintegerDefault 50, maximum 200.
offsetintegerFor paging. meta.total gives the full count.
include_archivedbooleanArchived sites serve stored history only.
Request
curl "https://api.basecampseo.com/v1/properties" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY"
Response
{
  "success": true,
  "data": {
    "rows": [
      {
        "id": "8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8",
        "gsc_property": "sc-domain:example.com",
        "display_name": "example.com",
        "type": "domain",
        "permission_level": "siteOwner",
        "starred": true,
        "archived": false,
        "created_at": "2026-02-10T09:12:44.000Z"
      }
    ]
  },
  "meta": { "row_count": 1, "limit": 50, "offset": 0, "total": 1 }
}
GET/v1/properties/{id}

One site, with the window of data available to you and whether its Google connection is healthy. Check this before trusting a long range.

Request
curl "https://api.basecampseo.com/v1/properties/example.com" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY"
Response
{
  "success": true,
  "data": {
    "id": "8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8",
    "gsc_property": "sc-domain:example.com",
    "display_name": "example.com",
    "type": "domain",
    "archived": false,
    "data_window": {
      "latest_data_date": "2026-09-14",
      "earliest_data_date": "2023-09-16",
      "live_window_months": 16,
      "retention_months": 36
    },
    "connection": { "healthy": true, "note": null },
    "last_archived_date": "2025-05-12"
  }
}

Performance

GET/v1/properties/{id}/summary

Clicks, impressions, CTR and average position for a range. Add compare=previous_period to get the equivalent window immediately before it in the same call.

ParameterTypeDescription
date_rangestringSee date ranges above. Defaults to last_28d.
comparestringnone or previous_period.
filtersjsonSee filters above.
Request
curl -G "https://api.basecampseo.com/v1/properties/example.com/summary" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY" \
  --data-urlencode 'date_range=previous_month' \
  --data-urlencode 'compare=previous_period'
Response
{
  "success": true,
  "data": {
    "totals": { "clicks": 12430, "impressions": 402118, "ctr": 0.0309, "position": 14.2 },
    "previous": {
      "start_date": "2026-07-01",
      "end_date": "2026-07-31",
      "clicks": 11488,
      "impressions": 390102,
      "ctr": 0.0294,
      "position": 15.0
    }
  },
  "meta": { "source": "live", "stale_reason": null, "cache": "miss" }
}
GET/v1/properties/{id}/timeseries

The same metrics day by day, for charting.

Request
curl -G "https://api.basecampseo.com/v1/properties/example.com/timeseries" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY" \
  --data-urlencode 'date_range=last_7d'
Response
{
  "success": true,
  "data": {
    "rows": [
      { "date": "2026-09-08", "clicks": 421, "impressions": 13980, "ctr": 0.0301, "position": 14.6 },
      { "date": "2026-09-09", "clicks": 455, "impressions": 14210, "ctr": 0.0320, "position": 14.1 }
    ]
  },
  "meta": { "row_count": 7, "source": "live" }
}
GET/v1/properties/{id}/queries

Top search queries. The same shape is served by /pages, /countries and /devices, with the row key changing accordingly.

ParameterTypeDescription
limitintegerDefault 100, maximum 1000.
order_bystringDefaults to -clicks. Any other ordering pulls a wider sample from Search Console first and then sorts, so you get the real top rows for that metric rather than a reordered clicks ranking.
filtersjsonSee filters above.
Request
curl -G "https://api.basecampseo.com/v1/properties/example.com/queries" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY" \
  --data-urlencode 'date_range=last_28d' \
  --data-urlencode 'limit=3'
Response
{
  "success": true,
  "data": {
    "dimension": "query",
    "rows": [
      { "key": "seo dashboard", "clicks": 1204, "impressions": 22410, "ctr": 0.0537, "position": 6.2 },
      { "key": "search console alternative", "clicks": 880, "impressions": 19044, "ctr": 0.0462, "position": 8.1 },
      { "key": "gsc api", "clicks": 512, "impressions": 30188, "ctr": 0.0170, "position": 18.4 }
    ]
  },
  "meta": { "row_count": 3, "limit": 3, "truncated_by": "clicks", "source": "live" }
}
GET/v1/properties/{id}/movers/queries

Queries that gained and lost the most clicks against the previous period of equal length. /movers/pages does the same for landing pages. These endpoints reject filters, because the underlying comparison returns nothing when filtered and an empty result would read as nothing moved.

ParameterTypeDescription
limitintegerDefault 10, maximum 50, applied to each list.
min_clicksintegerNoise floor. On a small site most movers are a page going from zero clicks to one. Set this to 5 or 10 and only real movement comes back.
Request
curl -G "https://api.basecampseo.com/v1/properties/example.com/movers/queries" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY" \
  --data-urlencode 'date_range=previous_month' \
  --data-urlencode 'limit=2'
Response
{
  "success": true,
  "data": {
    "dimension": "query",
    "previous_range": { "start_date": "2026-07-01", "end_date": "2026-07-31" },
    "gainers": [
      {
        "key": "seo dashboard",
        "clicks": 1204, "previous_clicks": 702,
        "clicks_delta": 502, "clicks_change_pct": 71.5,
        "impressions": 22410, "previous_impressions": 16880,
        "position": 6.2, "previous_position": 9.4
      }
    ],
    "losers": [
      {
        "key": "free seo tool",
        "clicks": 140, "previous_clicks": 610,
        "clicks_delta": -470, "clicks_change_pct": -77.0,
        "impressions": 9100, "previous_impressions": 18220,
        "position": 21.7, "previous_position": 11.2
      }
    ]
  },
  "meta": { "row_count": 2, "notes": [] }
}

Indexing

Indexing data is our own stored inspection results, refreshed daily against Google's URL Inspection API, so it is not live. A status of pending_discovery or pending_inspection means collection has not run for that site yet, which is not the same as zero pages being indexed.

GET/v1/properties/{id}/indexing

Coverage totals, a breakdown by Google's own coverage states, and per sitemap rollups.

ParameterTypeDescription
daysinteger30, 90 or 365. Applies to the trend only.
include_trendbooleanOff by default, since it can be 365 rows.
Request
curl "https://api.basecampseo.com/v1/properties/example.com/indexing" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY"
Response
{
  "success": true,
  "data": {
    "status": "ready",
    "discovery_source": "sitemap",
    "url_count": 1842,
    "urls_capped": false,
    "last_refreshed_at": "2026-09-16T03:11:02.000Z",
    "totals": { "total_urls": 1842, "inspected_urls": 1802, "indexed": 1518, "not_indexed": 284 },
    "breakdown": [
      { "coverage_state": "Submitted and indexed", "bucket": "indexed", "count": 1518 },
      { "coverage_state": "Crawled - currently not indexed", "bucket": "not_indexed", "count": 201 },
      { "coverage_state": "Discovered - currently not indexed", "bucket": "not_indexed", "count": 83 }
    ],
    "trend": null
  },
  "meta": { "source": "stored" }
}
GET/v1/properties/{id}/indexing/urls

Individual URLs with their index status. This is what answers the question of which pages Google has not indexed. The default sort puts pages with real search traffic first, and meta.total is the exact match count rather than an estimate.

ParameterTypeDescription
bucketstringindexed, not_indexed, error or unknown.
coverage_statestringGoogle's own label, as returned in the summary breakdown.
qstringSubstring match on the URL.
sortstringimportance (default), url, last_crawl_time or last_inspected_at.
limitintegerDefault 100, maximum 500.
Request
curl -G "https://api.basecampseo.com/v1/properties/example.com/indexing/urls" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY" \
  --data-urlencode 'bucket=not_indexed' \
  --data-urlencode 'limit=2'
Response
{
  "success": true,
  "data": {
    "rows": [
      {
        "url": "https://example.com/blog/old-post",
        "coverage_state": "Crawled - currently not indexed",
        "verdict": "NEUTRAL",
        "last_crawl_time": "2026-08-30T11:04:00.000Z",
        "last_inspected_at": "2026-09-16T03:09:41.000Z"
      },
      {
        "url": "https://example.com/tags/misc",
        "coverage_state": "Discovered - currently not indexed",
        "verdict": "NEUTRAL",
        "last_crawl_time": null,
        "last_inspected_at": "2026-09-16T03:09:44.000Z"
      }
    ]
  },
  "meta": { "row_count": 2, "limit": 2, "offset": 0, "total": 284, "source": "stored" }
}

Segments

The configuration behind the brand and page_group filter dimensions, plus the dated notes your team leaves on the timeline. Fetch annotations before explaining a trend, because a note on the exact date traffic moved is usually the answer.

GET/v1/properties/{id}/annotations

Dated notes for a property. /brand-terms and /page-groups return the matching rules for that site in the same envelope.

ParameterTypeDescription
start_datedateOptional lower bound.
end_datedateOptional upper bound.
Request
curl -G "https://api.basecampseo.com/v1/properties/example.com/annotations" \
  -H "Authorization: Bearer bcs_live_YOUR_KEY" \
  --data-urlencode 'start_date=2026-08-01' \
  --data-urlencode 'end_date=2026-08-31'
Response
{
  "success": true,
  "data": {
    "rows": [
      {
        "id": "b21c4d5e-6f70-4819-a2b3-c4d5e6f70819",
        "date": "2026-08-14",
        "title": "Migrated /blog to /resources",
        "note": "301s in place, sitemap resubmitted",
        "color": "amber"
      }
    ]
  },
  "meta": { "row_count": 1, "source": "stored" }
}

Versioning

The version lives in the path. New endpoints and new fields land inside /v1; anything that would break an existing client gets a new version instead. Treat unknown fields as safe to ignore.

The machine readable spec is at /v1/openapi.json. It is generated from the same schemas the API validates against, so it cannot drift from the implementation.