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.
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.
{
"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.
sourceislivewhen the numbers came straight from Google, orstoredwhen 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_reasonis set when a disconnected Google account forced the fallback.truncated_byisclickswhen there were more rows than we could fetch. Search Console ranks by clicks before truncating, so that is the axis anything missing fell off.totalisnullfor 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.
| Value | Meaning |
|---|---|
last_7d | Seven days ending at the anchor |
last_28d | The default, and the one to use for “how are we doing” |
last_30d, last_90d | Fixed day counts |
last_3m, last_6m, last_12m, last_16m | Calendar months back |
month_to_date | First of this month to the anchor |
previous_month | The whole of last calendar month, good for reporting |
year_to_date, previous_year | Calendar 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.
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.
| Plan | Requests per day | API keys |
|---|---|---|
| Pro | 1,000 | 3 |
| Agency | 5,000 | 10 |
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.
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 994
X-RateLimit-Reset: 1789603200
X-Cache: hitA 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.
{
"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"
}
}| Status | Codes |
|---|---|
| 400 | VALIDATION_ERROR, DATE_RANGE_TOO_OLD, UNSUPPORTED_PARAMETER, PROPERTY_AMBIGUOUS |
| 401 | INVALID_API_KEY, API_KEY_REVOKED, API_KEY_EXPIRED |
| 403 | API_ACCESS_REQUIRED, INSUFFICIENT_SCOPE |
| 404 | NOT_FOUND, also returned for properties your key cannot reach |
| 429 | RATE_LIMITED, QUOTA_EXCEEDED, UPSTREAM_QUOTA_EXCEEDED |
| 503 | FILTERED_DATA_UNAVAILABLE, returned instead of serving unfiltered numbers as filtered |
Account
/v1/meWho 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.
curl "https://api.basecampseo.com/v1/me" \
-H "Authorization: Bearer bcs_live_YOUR_KEY"{
"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.
/v1/propertiesEvery site this key can read, sorted by name. Archived sites are left out unless you ask for them.
| Parameter | Type | Description |
|---|---|---|
limit | integer | Default 50, maximum 200. |
offset | integer | For paging. meta.total gives the full count. |
include_archived | boolean | Archived sites serve stored history only. |
curl "https://api.basecampseo.com/v1/properties" \
-H "Authorization: Bearer bcs_live_YOUR_KEY"{
"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 }
}/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.
curl "https://api.basecampseo.com/v1/properties/example.com" \
-H "Authorization: Bearer bcs_live_YOUR_KEY"{
"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
/v1/properties/{id}/summaryClicks, impressions, CTR and average position for a range. Add compare=previous_period to get the equivalent window immediately before it in the same call.
| Parameter | Type | Description |
|---|---|---|
date_range | string | See date ranges above. Defaults to last_28d. |
compare | string | none or previous_period. |
filters | json | See filters above. |
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'{
"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" }
}/v1/properties/{id}/timeseriesThe same metrics day by day, for charting.
curl -G "https://api.basecampseo.com/v1/properties/example.com/timeseries" \
-H "Authorization: Bearer bcs_live_YOUR_KEY" \
--data-urlencode 'date_range=last_7d'{
"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" }
}/v1/properties/{id}/queriesTop search queries. The same shape is served by /pages, /countries and /devices, with the row key changing accordingly.
| Parameter | Type | Description |
|---|---|---|
limit | integer | Default 100, maximum 1000. |
order_by | string | Defaults 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. |
filters | json | See filters above. |
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'{
"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" }
}/v1/properties/{id}/movers/queriesQueries 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.
| Parameter | Type | Description |
|---|---|---|
limit | integer | Default 10, maximum 50, applied to each list. |
min_clicks | integer | Noise 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. |
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'{
"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.
/v1/properties/{id}/indexingCoverage totals, a breakdown by Google's own coverage states, and per sitemap rollups.
| Parameter | Type | Description |
|---|---|---|
days | integer | 30, 90 or 365. Applies to the trend only. |
include_trend | boolean | Off by default, since it can be 365 rows. |
curl "https://api.basecampseo.com/v1/properties/example.com/indexing" \
-H "Authorization: Bearer bcs_live_YOUR_KEY"{
"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" }
}/v1/properties/{id}/indexing/urlsIndividual 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.
| Parameter | Type | Description |
|---|---|---|
bucket | string | indexed, not_indexed, error or unknown. |
coverage_state | string | Google's own label, as returned in the summary breakdown. |
q | string | Substring match on the URL. |
sort | string | importance (default), url, last_crawl_time or last_inspected_at. |
limit | integer | Default 100, maximum 500. |
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'{
"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.
/v1/properties/{id}/annotationsDated notes for a property. /brand-terms and /page-groups return the matching rules for that site in the same envelope.
| Parameter | Type | Description |
|---|---|---|
start_date | date | Optional lower bound. |
end_date | date | Optional upper bound. |
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'{
"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.