Skip to Content
ProvidersGoogle Search Console

Google Search Console

Category: SEO & Analytics
Integration type: Tenant OAuth (stored in integrations table)
External API: Google Search Console API v1 (via googleapis SDK)


Purpose

Google Search Console (GSC) provides organic search performance data directly from Google — impressions, clicks, average position, and CTR per keyword and page. This data powers:

  • Report Writer — monthly SEO performance section
  • Anomaly Detector — detects ranking drops or traffic anomalies
  • Site Auditor — baseline organic performance before audit recommendations
  • Dashboard — live organic traffic metrics (D4 — SEO Performance screen)

Tenants connect their own Google Search Console property via OAuth. The platform never sees the raw API credentials — only the OAuth tokens stored in integrations.


Config Structure

OAuth flow

Tenant clicks "Connect Google Search Console" in Dashboard → Settings → Integrations Platform redirects to Google OAuth consent screen scope: https://www.googleapis.com/auth/webmasters.readonly Google redirects back with authorization code Platform exchanges code for access_token + refresh_token Stores in integrations table: provider: 'google_search_console' api_key: encrypt(refresh_token) metadata: { siteUrl, propertyType, accessToken, accessTokenExpiresAt }

Integration record

interface GSCIntegrationMetadata { siteUrl: string; // e.g. "https://acmeplumbing.com/" or "sc-domain:acmeplumbing.com" propertyType: 'url' | 'domain'; accessToken: string; // Short-lived (1hr) — refreshed automatically accessTokenExpiresAt: string; // ISO 8601 }

Platform OAuth credentials (env vars)

GOOGLE_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx GOOGLE_REDIRECT_URI=https://app.leadmetrics.io/api/auth/callback/google

Integration Pattern

Token refresh

Access tokens expire after 1 hour. The tool layer refreshes automatically before making calls:

async function getGSCClient(integration: Integration): Promise<google.searchconsole_v1.Searchconsole> { const meta = integration.metadata as GSCIntegrationMetadata; if (isExpired(meta.accessTokenExpiresAt)) { const refreshToken = decrypt(integration.api_key); const { credentials } = await oauth2Client.refreshAccessToken(refreshToken); // Update stored access token await db.update(integrations) .set({ metadata: { ...meta, accessToken: credentials.access_token, accessTokenExpiresAt: ... } }) .where(eq(integrations.id, integration.id)); oauth2Client.setCredentials(credentials); } else { oauth2Client.setCredentials({ access_token: meta.accessToken }); } return google.searchconsole({ version: 'v1', auth: oauth2Client }); }

Querying performance data

class GSCTool { async getPerformance( integration: Integration, options: { startDate: string; // YYYY-MM-DD endDate: string; // YYYY-MM-DD dimensions: ('query' | 'page' | 'device' | 'country')[]; rowLimit?: number; // Max 25000 filters?: { dimension: 'query' | 'page' | 'device' | 'country'; operator: 'equals' | 'contains' | 'notContains' | 'includingRegex' | 'excludingRegex'; expression: string; }[]; }, ): Promise<GSCPerformanceRow[]> { const client = await getGSCClient(integration); const meta = integration.metadata as GSCIntegrationMetadata; const response = await client.searchanalytics.query({ siteUrl: meta.siteUrl, requestBody: { startDate: options.startDate, endDate: options.endDate, dimensions: options.dimensions, rowLimit: options.rowLimit ?? 1000, dataState: 'final', // 'all' includes partial data for recent days ...(options.filters?.length && { dimensionFilterGroups: [{ filters: options.filters.map(f => ({ dimension: f.dimension, operator: f.operator, expression: f.expression, })), }], }), }, }); return (response.data.rows ?? []).map(row => ({ keys: row.keys ?? [], clicks: row.clicks ?? 0, impressions: row.impressions ?? 0, ctr: row.ctr ?? 0, position: row.position ?? 0, })); } /** * Top keywords (queries) by clicks for a date range. * Used by Report Writer for the organic keywords section. */ async getTopKeywords( integration: Integration, options: { startDate: string; endDate: string; limit?: number }, ): Promise<{ keyword: string; clicks: number; impressions: number; ctr: number; position: number }[]> { const rows = await this.getPerformance(integration, { startDate: options.startDate, endDate: options.endDate, dimensions: ['query'], rowLimit: options.limit ?? 25, }); return rows.map(r => ({ keyword: r.keys[0] ?? '', clicks: r.clicks, impressions: r.impressions, ctr: r.ctr, position: r.position, })); } /** * Top pages by clicks for a date range. * Used by Report Writer for the content performance section. */ async getTopPages( integration: Integration, options: { startDate: string; endDate: string; limit?: number }, ): Promise<{ page: string; clicks: number; impressions: number; position: number }[]> { const rows = await this.getPerformance(integration, { startDate: options.startDate, endDate: options.endDate, dimensions: ['page'], rowLimit: options.limit ?? 25, }); return rows.map(r => ({ page: r.keys[0] ?? '', clicks: r.clicks, impressions: r.impressions, position: r.position, })); } /** * Weekly click trend for a specific keyword over the past N weeks. * Used by Keyword Researcher and Site Auditor for trend analysis. */ async getKeywordTrend( integration: Integration, options: { keyword: string; weeks?: number }, ): Promise<{ weekStart: string; clicks: number; impressions: number; position: number }[]> { const weeks = options.weeks ?? 12; const endDate = subtractDays(new Date(), 3); // GSC 3-day lag const startDate = subtractDays(endDate, weeks * 7); const rows = await this.getPerformance(integration, { startDate: toISODate(startDate), endDate: toISODate(endDate), dimensions: ['date', 'query'], rowLimit: 25000, filters: [{ dimension: 'query', operator: 'equals', expression: options.keyword, }], }); // Aggregate daily rows into weekly buckets const weeks_map = new Map<string, { clicks: number; impressions: number; positions: number[]; }>(); for (const row of rows) { const date = row.keys[0]; // 'YYYY-MM-DD' const weekStart = getWeekStart(date); const bucket = weeks_map.get(weekStart) ?? { clicks: 0, impressions: 0, positions: [] }; bucket.clicks += row.clicks; bucket.impressions += row.impressions; bucket.positions.push(row.position); weeks_map.set(weekStart, bucket); } return [...weeks_map.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([weekStart, b]) => ({ weekStart, clicks: b.clicks, impressions: b.impressions, position: b.positions.length ? b.positions.reduce((s, p) => s + p, 0) / b.positions.length : 0, })); } /** * Inspect the indexing status of a specific URL. * Used by Site Auditor to check whether key pages are indexed. */ async getUrlInspection( integration: Integration, url: string, ): Promise<{ indexingState: 'INDEXED' | 'NOT_INDEXED' | 'EXCLUDED' | 'UNKNOWN'; coverageState?: string; // Human-readable GSC coverage state lastCrawlTime?: string; // ISO 8601 robotsTxtState?: 'ALLOWED' | 'DISALLOWED'; indexingAllowed?: boolean; googleCanonical?: string; verdict?: string; // e.g. 'PASS', 'FAIL' }> { const client = await getGSCClient(integration); const meta = integration.metadata as GSCIntegrationMetadata; const response = await client.urlInspection.index.inspect({ requestBody: { inspectionUrl: url, siteUrl: meta.siteUrl, }, }); const result = response.data.inspectionResult; const idx = result?.indexStatusResult; return { indexingState: (idx?.verdict as any) === 'PASS' ? 'INDEXED' : 'NOT_INDEXED', coverageState: idx?.coverageState ?? undefined, lastCrawlTime: idx?.lastCrawlTime ?? undefined, robotsTxtState: idx?.robotsTxtState as any ?? undefined, indexingAllowed: idx?.indexingState === 'INDEXING_ALLOWED', googleCanonical: idx?.googleCanonicalUrl ?? undefined, verdict: idx?.verdict ?? undefined, }; } /** * List all sitemaps submitted for the property. * Used by Site Auditor to validate sitemap submission. */ async getSitemaps( integration: Integration, ): Promise<{ path: string; type: string; // e.g. 'sitemap', 'atomFeed' lastSubmitted?: string; // ISO 8601 lastDownloaded?: string; // ISO 8601 errors: number; warnings: number; urlCount?: number; }[]> { const client = await getGSCClient(integration); const meta = integration.metadata as GSCIntegrationMetadata; const response = await client.sitemaps.list({ siteUrl: meta.siteUrl }); return (response.data.sitemap ?? []).map(s => ({ path: s.path ?? '', type: s.type ?? 'sitemap', lastSubmitted: s.lastSubmitted ?? undefined, lastDownloaded: s.lastDownloaded ?? undefined, errors: Number(s.errors ?? 0), warnings: Number(s.warnings ?? 0), urlCount: s.contents?.[0]?.submitted ? Number(s.contents[0].submitted) : undefined, })); } /** * Verify that the integration is valid and has at least one accessible property. */ async verify(integration: Integration): Promise<void> { const properties = await this.listProperties(integration); if (properties.length === 0) { throw new Error('No verified Search Console properties found for this account'); } } async listProperties(integration: Integration): Promise<string[]> { const client = await getGSCClient(integration); const response = await client.sites.list(); return (response.data.siteEntry ?? []) .filter(s => s.permissionLevel === 'siteOwner' || s.permissionLevel === 'siteFullUser') .map(s => s.siteUrl!) .filter(Boolean); } }

Data Latency

GSC data is delayed by 2–3 days. When generating reports for a given month, always use endDate = last day of month − 3 days, not yesterday. The platform uses dataState: 'final' to avoid partial data.


Test Cases

Unit tests (packages/tools/src/google-search-console.test.ts)

TestApproach
getPerformance() builds correct request bodyMock searchanalytics.query; assert startDate, endDate, dimensions
getPerformance() includes dimensionFilterGroups when filters providedMock query; assert filter array in request body
getPerformance() omits dimensionFilterGroups when no filtersAssert key absent from request body when filters not passed
getPerformance() maps rows to typed objectsFeed mock rows; assert clicks, impressions, ctr, position
getPerformance() returns empty array when rows is nullMock { rows: null }; assert [] returned
getTopKeywords() defaults limit to 25Assert rowLimit: 25 passed to getPerformance
getTopKeywords() maps keys[0] to keywordFeed row with keys: ['seo tools']; assert keyword: 'seo tools'
getTopPages() maps keys[0] to pageFeed row with keys: ['/blog/seo']; assert page: '/blog/seo'
getKeywordTrend() aggregates daily rows into weekly bucketsFeed 14 daily rows spanning 2 weeks; assert 2 weekly results
getKeywordTrend() filters by the target keywordAssert filters contains { dimension: 'query', operator: 'equals', expression: keyword }
getKeywordTrend() computes average position per weekFeed 3 daily rows with positions 2, 4, 6; assert avg = 4
getUrlInspection() maps verdict PASS to INDEXEDMock verdict: 'PASS'; assert indexingState: 'INDEXED'
getUrlInspection() maps non-PASS verdict to NOT_INDEXEDMock verdict: 'FAIL'; assert indexingState: 'NOT_INDEXED'
getSitemaps() maps error/warning counts as numbersMock string values '3'; assert Number conversion
verify() throws when no properties returnedMock listProperties returning []; assert throws
verify() resolves when at least one property existsMock returning ['https://example.com/']; assert resolves
Token refresh triggered when access token expiredMock expired accessTokenExpiresAt; assert refreshAccessToken called
Throws when OAuth scope revoked (401)Mock googleapis throws 401; assert error propagated

Integration tests

TestApproach
List properties for test accountUse test OAuth tokens; assert at least one property returned
Fetch performance for known siteAssert clicks/impressions > 0 for previous month

© 2026 Leadmetrics — Internal use only