# Search Console

Pull Google Search Console data (clicks, impressions, CTR, average position, queries, pages, countries, devices) into a site's dashboard. Synced into Totallytics storage, so history outlives Google's 16-month retention.

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

## Access

All endpoints on this page require the site owner Firebase ID bearer token, even for public sites — GSC data never appears on public dashboards. The OAuth callback (`/api/import-oauth/google/callback`) is public; the signed, encrypted, 10-minute-expiring state blob is the capability.

## Connecting

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

1. `POST /api/import-oauth/google/start` with `{ "site": "example.com", "provider": "google-search-console", "origin": "https://totallytics.app" }` → returns `{ "url" }`; send the browser there. `origin` must be one of the app hosts and decides where the user lands afterwards.
2. Google redirects back to `/api/import-oauth/google/callback`, which stores an encrypted refresh token (one connection per account + provider) and bounces to `…/settings?gsc=connected`.
3. `GET /api/sites/{hostname}/gsc/properties?connection_id=…` lists the account's Search Console properties, with `matches: true` on the ones covering this site.
4. `POST /api/sites/{hostname}/gsc/link` with `{ "connection_id", "property" }` validates access and starts the backfill.

## Sync behavior

Linking kicks off a backfill that walks backwards month-by-month through 16 months of history, then the link flips to `live`. A nightly job refreshes the trailing 10 days (Google finalizes data with a 2-3 day lag; only finalized data is stored). Syncs are insert-only: every run writes fresh versions and reads collapse to the newest, so retries and overlapping runs never duplicate or hole data. `POST …/gsc/sync` queues an out-of-band refresh of the recent window.

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

## 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` | List connections (tokens never returned) |
| DELETE | `/api/import-oauth/google/connections/{id}` | Delete connection, revoke at Google, unlink sites |
| 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 (synced data is kept) |
| GET | `/api/sites/{hostname}/gsc/status` | Link + sync state |
| POST | `/api/sites/{hostname}/gsc/sync` | Queue a refresh (`202`) |
| GET | `/api/sites/{hostname}/gsc/overview` | Totals, previous period, daily series, tracker overlay |
| GET | `/api/sites/{hostname}/gsc/breakdown` | Top `queries` / `pages` / `countries` / `devices` |

## 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 },
  "series": [{ "t": 1758326400, "clicks": 31, "impressions": 2404, "ctr": 0.0129, "position": 17.9 }],
  "overlay": [{ "t": 1758326400, "gsc_clicks": 31, "tt_pageviews": 24 }]
}
```

- `previous` covers the equally-sized period directly before the range.
- `position` and `ctr` are impression-weighted, matching the Search Console UI.
- `overlay` puts GSC clicks next to the same day's google-referrer pageviews measured by the tracker — a quick read on how many search clicks actually land (ad blockers, bounces before load, and SERP features all eat clicks).
- Day buckets follow the `tz` parameter; the underlying GSC days are Pacific Time, so exact boundaries can differ by a few hours from your local tz.

## Breakdown response

`GET …/gsc/breakdown?dim=queries&from=…&to=…&limit=100` returns `{ "dim": "queries", "rows": [{ "name", "clicks", "impressions", "ctr", "position" }] }` sorted by clicks. `countries` uses ISO 3166-1 alpha-3 codes (as GSC returns them). `limit` caps at 500.

GSC anonymizes rare queries and caps rows per day, so query totals can undershoot the daily totals — that's Google's data, not a sync bug.
