# Search Console

Clicktag syncs Google Search Console clicks, impressions, CTR, average position, queries and pages into your dashboard: a 16-month backfill, then a refresh every 6 hours, stored by Clicktag so history outlives Google's 16-month retention.

Base URL: `https://clicktag.io`. [Download OpenAPI](/openapi.json) or [read this page as Markdown](/docs/search-console.md).

## Access

All endpoints on this page are owner-only, even for public sites: GSC data never appears on public dashboards. They take the site owner's Firebase ID bearer token; `GET .../gsc/status`, `.../gsc/overview` and `.../gsc/breakdown` also accept a [management key](/docs/authentication#management-keys) with `read`, and `POST .../gsc/sync` one with `manage`. Someone else's site answers `404`, and overview, breakdown and linking a property need a [verified](/docs/sites#verify-ownership) site (`403` otherwise). The OAuth callback (`/api/import-oauth/google/callback`) is public; the encrypted, 10-minute-expiring state and a browser-bound cookie protect the connection.

## Connecting

The OAuth dance is browser-driven and normally starts from site settings → **Integrations** → **Connect Google Search Console**. Programmatically:

1. `POST /api/import-oauth/google/start` with `{ "site": "example.com", "provider": "google-search-console", "origin": "https://clicktag.io" }` → returns `{ "url" }`; send the browser there. `provider` is optional; `google-search-console` is the only one. `origin` is optional, but must match the host receiving this request (`https://clicktag.io` or `https://clicktag.app`). Start in the same browser that completes consent so its session cookie is preserved.
2. Google returns to the existing registered OAuth callback at `https://totallytics.com/api/import-oauth/google/callback`; this address stays unchanged for compatibility. If you started on another host, it redirects back there to verify the browser session before storing an encrypted refresh token and returning to `/app/{hostname}/settings?gsc=connected`. Failures return to `?gsc=error&reason=<reason>` instead (`session`, `site`, `denied`, `configuration`, `exchange`, `refresh`, `scope`, `unavailable`, `account` or `queue`); an expired or invalid state gets a plain-text `400`. There is one Google connection per Clicktag account; reconnecting updates every site using it. A replacement account must retain access to the existing linked properties.
3. `GET /api/sites/{hostname}/gsc/properties?connection_id=...` (the `id` from `GET /api/import-oauth/google/connections`) lists the account's Search Console properties as `{ site_url, permission_level, matches }`, matches first. `matches` is `true` for `sc-domain:{hostname}` and URL-prefix properties under `https://{hostname}/`; properties the account hasn't verified are left out.
4. `POST /api/sites/{hostname}/gsc/link` with `{ "connection_id", "property" }` (an `sc-domain:` or `http(s)://` property, up to 512 characters) validates access, starts the backfill and returns `{ "linked": true, "property" }`.

## Sync behavior

Linking kicks off a backfill that walks backwards month-by-month through 16 months of history, then the link flips to `live`. Every 6 hours a job refreshes the trailing 10 days, including Google's fresh data for the last 2-3 days, which later refreshes replace with final numbers. Each sync rewrites whole days: a day always reflects its newest sync, so queries and pages Google drops when it finalizes or anonymizes data disappear instead of lingering, and retries never duplicate data. `POST …/gsc/sync` resumes the saved backfill month while backfilling, or queues a recent-window refresh when live. An expired connection returns `409` until reconnected; temporary queue failures return `503` and can be retried.

`sync_state` is `backfill`, `live`, or `error` (a revoked Google connection lands in `error` with `last_error` set; reconnect from settings to resume automatically without discarding synced history).

`GET .../gsc/status` returns `{ "linked": false }` for a site without a link. A linked site adds `property`, `sync_state`, `backfill_cursor` (the month the backfill is on, `null` once it's done), `synced_until` (the newest day Google had data for), `last_sync_at`, `last_error` and `created_at`.

## Endpoints

| Method | Route | Result |
| ------ | ----- | ------ |
| POST | `/api/import-oauth/google/start` | `{ url }` consent URL |
| GET | `/api/import-oauth/google/callback` | OAuth redirect target (public) |
| GET | `/api/import-oauth/google/connections` | `{ connections: [{ id, provider, label, created_at }] }`, newest first (tokens never returned) |
| DELETE | `/api/import-oauth/google/connections/{id}` | Delete connection, revoke at Google, unlink sites and remove their synced data |
| GET | `/api/sites/{hostname}/gsc/properties` | List the account's GSC properties |
| POST | `/api/sites/{hostname}/gsc/link` | Link a property, start backfill |
| DELETE | `/api/sites/{hostname}/gsc/link` | Unlink and remove synced Search Console data |
| GET | `/api/sites/{hostname}/gsc/status` | Link + sync state |
| POST | `/api/sites/{hostname}/gsc/sync` | Resume the backfill or queue a refresh (`202`) |
| GET | `/api/sites/{hostname}/gsc/overview` | Totals, previous period, daily series, visits from Google search |
| GET | `/api/sites/{hostname}/gsc/breakdown` | Sorted, searchable pages of `queries` / `pages` / `countries` / `devices`, with the previous period |

## Matching Search Console

Numbers match the Performance report for the same dates:

- Web search only, like the report's default search type.
- Totals, the daily series, queries, countries and devices are counted per property; pages per page, so page rows can add up to more than the totals.
- Search Console days are Pacific Time. `from`/`to` (end exclusive) map to calendar dates in `tz`, and those dates are read as Search Console days: Sep 1-7 here is Sep 1-7 there.
- Domain properties cover every subdomain and protocol. Totals include all of them, as Search Console does; pages on another host show that host before the path.
- Search Console's preset ranges end on the last finalized day. Ranges ending today also include fresh data, which can still change; `final_through` marks where it starts.

## Overview response

`GET …/gsc/overview?from=<unix>&to=<unix>&tz=<iana>` returns:

```json
{
  "totals": { "clicks": 512, "impressions": 39120, "ctr": 0.0131, "position": 18.4 },
  "previous": { "clicks": 480, "impressions": 36600, "ctr": 0.0131, "position": 19.1 },
  "through": "2025-09-28",
  "series": [{ "date": "2025-09-20", "t": 1758326400, "clicks": 31, "impressions": 2404, "ctr": 0.0129, "position": 17.9 }],
  "previous_series": [{ "date": "2025-09-13", "t": 1757721600, "clicks": 28, "impressions": 2210, "ctr": 0.0127, "position": 18.6 }],
  "overlay": [{ "t": 1758326400, "gsc_clicks": 31, "tt_pageviews": 24 }],
  "final_through": "2025-09-28"
}
```

- `totals` stop at `final_through` when the range runs past it, and `through` says so (`null` when the range is complete or has no finalized day yet). `previous` covers as many days from the start of the equally-sized period directly before the range, so a change compares like with like instead of reading Google's lag as a drop.
- `position` and `ctr` are impression-weighted, matching the Search Console UI.
- `series` has one point per day from the start of the range to the last day Google has data for, zero-filled. `date` is the Search Console day; `t` is its midnight in `tz`.
- `previous_series` is the previous period day by day, zero-filled to its end, so `previous_series[i]` is the same day of the period as `series[i]`.
- `final_through` is the last day Google has finalized; later days are fresh and can still change.
- `overlay` puts GSC clicks next to the same day's visits from Google search measured by the tracker (pageviews from the `google.com` source, bots and retries excluded): a quick read on how many search clicks actually land (ad blockers, bounces before load, and SERP features all eat clicks).

## Breakdown response

`GET /api/sites/{hostname}/gsc/breakdown?dim=queries&from=<unix>&to=<unix>&tz=<iana>&sort=clicks&dir=desc&q=deploy&limit=25&offset=0` returns one page of rows and how many rows match:

```json
{
  "dim": "queries",
  "total": 214,
  "through": null,
  "rows": [
    {
      "name": "deploy static site",
      "clicks": 12, "impressions": 340, "ctr": 0.0353, "position": 6.2,
      "previous": { "clicks": 9, "impressions": 310, "ctr": 0.029, "position": 7.1 }
    }
  ],
  "anonymized": { "clicks": 41, "impressions": 5210, "ctr": 0.0079, "position": 24.6 }
}
```

- `dim` is `queries`, `pages`, `countries` or `devices`. `countries` uses ISO 3166-1 alpha-3 codes, as GSC returns them.
- `sort` is `clicks` (default), `impressions`, `ctr` or `position`, and `dir` is `desc` (default) or `asc`. Ties fall back to clicks, impressions, then name.
- `q` keeps rows whose name contains it, ignoring case; longer values are cut to 256 characters. For countries that is the ISO code.
- `limit` defaults to 100. Numbers are rounded down and kept between 1 and 500; anything else uses the default. `offset` skips rows for paging; `total` counts every matching row.
- Rows stop at the overview's `through` day, and `previous` is the same row over the same days of the period before the range, or `null` when it had no impressions then: a new query, page, country or device. `through` is that day, or `null` when the whole range counts.

`pages` rows use the path as `name` (`/pricing`), prefixed by the host when it isn't the site's own (`docs.example.com/pricing`; `www.` counts as the site's own), plus `url`, the full URL of the page's most-shown variant. Protocol, `#fragment` and query-string variants of a path merge into one row.

GSC anonymizes rare queries, so query totals can undershoot the daily totals — that's Google's data, not a sync bug. For `dim=queries`, `anonymized` says exactly how much: the daily totals minus every query row over the same days, with its CTR and average position, whatever `q` and paging show. It's `null` when it can't be exact, such as a day the query list never synced, and on the last few days until both sides come from the same sync.

## Drill-downs

Add `f=query:<query>` or `f=page:<name>` to either endpoint to look at one query or one page. `<name>` is a Pages row `name`, like `/pricing`.

- The overview's `totals`, `previous` and both series become that query's or page's own rows, on the same day axis. `overlay` is empty.
- With `f=query:...`, the breakdown takes `dim=pages` and lists the pages that query showed, grouped like the Pages tab. With `f=page:...` it takes `dim=queries` and lists the queries that showed the page. Any other `dim` is a 400.
- With `f=page:...`, `anonymized` is the page's own clicks and impressions minus its listed queries, so the listed rows plus `anonymized` add up to the page's row exactly.
- One filter at a time. Values over 2048 characters are cut to Google's 2048.

In the dashboard, clicking a query or a page opens the same drill-down with a removable filter, kept in the URL as `sq=` or `sp=`, so Back removes it and links keep it.

## Errors

| Status | Error |
| ------ | ----- |
| 400 | `invalid range`, `unknown dim`, `unknown sort`, `unknown dir`, `one Search filter at a time`, `unknown Search filter`, `a query filter lists pages`, `a page filter lists queries`; on start `invalid site`, `unknown provider`, `invalid origin`; on properties and link `connection_id required`, `invalid property`, `the connected Google account has no access to that property`, or an expired Google connection with `"revoked": true` |
| 401 | `sign in required`, `invalid or revoked management key` |
| 403 | `Install your tracking script to verify this site first`, `management keys cannot call this endpoint`, `scope <scope> required` |
| 404 | `unknown site` or `site not found` for someone else's site, `connection not found`, `no Search Console link` |
| 409 | `POST .../gsc/sync` on a link in `error`, with `"revoked": true` |
| 502 | `Google denied Search Console access. Reconnect Google and approve read-only access.`, `Google could not list your properties. Please try again.` |
| 503 | `Sync queue unavailable. Please try again.`, `Property linked, but sync could not start. Use Sync now to retry.` |
