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:
POST /api/import-oauth/google/startwith{ "site": "example.com", "provider": "google-search-console", "origin": "https://clicktag.io" }→ returns{ "url" }; send the browser there.provideris optional;google-search-consoleis the only one.originis optional, but must match the host receiving this request (https://clicktag.ioorhttps://clicktag.app). Start in the same browser that completes consent so its session cookie is preserved.- 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,accountorqueue); an expired or invalid state gets a plain-text400. 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. GET /api/sites/{hostname}/gsc/properties?connection_id=...(theidfromGET /api/import-oauth/google/connections) lists the account's Search Console properties as{ site_url, permission_level, matches }, matches first.matchesistrueforsc-domain:{hostname}and URL-prefix properties underhttps://{hostname}/; properties the account hasn't verified are left out.POST /api/sites/{hostname}/gsc/linkwith{ "connection_id", "property" }(ansc-domain:orhttp(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 intz, 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_throughmarks where it starts.
Overview response
GET …/gsc/overview?from=<unix>&to=<unix>&tz=<iana> returns:
{
"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"
}totalsstop atfinal_throughwhen the range runs past it, andthroughsays so (nullwhen the range is complete or has no finalized day yet).previouscovers 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.positionandctrare impression-weighted, matching the Search Console UI.serieshas one point per day from the start of the range to the last day Google has data for, zero-filled.dateis the Search Console day;tis its midnight intz.previous_seriesis the previous period day by day, zero-filled to its end, soprevious_series[i]is the same day of the period asseries[i].final_throughis the last day Google has finalized; later days are fresh and can still change.overlayputs GSC clicks next to the same day's visits from Google search measured by the tracker (pageviews from thegoogle.comsource, 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:
{
"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 }
}dimisqueries,pages,countriesordevices.countriesuses ISO 3166-1 alpha-3 codes, as GSC returns them.sortisclicks(default),impressions,ctrorposition, anddirisdesc(default) orasc. Ties fall back to clicks, impressions, then name.qkeeps rows whose name contains it, ignoring case; longer values are cut to 256 characters. For countries that is the ISO code.limitdefaults to 100. Numbers are rounded down and kept between 1 and 500; anything else uses the default.offsetskips rows for paging;totalcounts every matching row.- Rows stop at the overview's
throughday, andpreviousis the same row over the same days of the period before the range, ornullwhen it had no impressions then: a new query, page, country or device.throughis that day, ornullwhen 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,previousand both series become that query's or page's own rows, on the same day axis.overlayis empty. - With
f=query:..., the breakdown takesdim=pagesand lists the pages that query showed, grouped like the Pages tab. Withf=page:...it takesdim=queriesand lists the queries that showed the page. Any otherdimis a 400. - With
f=page:...,anonymizedis the page's own clicks and impressions minus its listed queries, so the listed rows plusanonymizedadd 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. |