Clicktag docs

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 or read this page as Markdown.

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 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 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.