Skip to Content
ProvidersGoogle Ads

Google Ads

Category: Paid Ads
Integration type: Tenant OAuth (stored in integrations table)
External SDK: google-ads-api (community SDK) or googleapis


Purpose

Google Ads integration covers two directions:

  1. Pull — Import live campaign performance data (impressions, clicks, conversions, spend) into the platform for reporting and anomaly detection
  2. Push — Upload AI-generated RSA (Responsive Search Ad) copy created by the Google Ads Writer agent into the tenant’s Google Ads account for HITL approval before going live

Tenants connect via Google Ads OAuth. The platform requires Manager Account (MCC) access to support agencies managing multiple client accounts under one OAuth connection.


Config Structure

OAuth flow

scope: https://www.googleapis.com/auth/adwords Stored in integrations: provider: 'google_ads' api_key: encrypt(refresh_token) metadata: { customerId: '123-456-7890', // Google Ads customer ID (dashes format) managerCustomerId: '999-888-7777', // MCC/manager account ID (if any) accessToken: string, accessTokenExpiresAt: string, developerToken: string, // Required by Google Ads API — stored separately }

Platform env vars

GOOGLE_ADS_DEVELOPER_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxx # Platform developer token (required by all API calls) GOOGLE_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxx

The developer token is a platform credential, not tenant-specific. Google Ads API requires it on every request.


Integration Pattern

Pulling campaign performance

class GoogleAdsTool { async getCampaignPerformance( integration: Integration, options: { startDate: string; // YYYY-MM-DD endDate: string; }, ): Promise<CampaignPerformanceRow[]> { const client = await this.getClient(integration); const meta = integration.metadata as GoogleAdsMetadata; const query = ` SELECT campaign.id, campaign.name, campaign.status, metrics.impressions, metrics.clicks, metrics.conversions, metrics.cost_micros, metrics.average_cpc FROM campaign WHERE segments.date BETWEEN '${options.startDate}' AND '${options.endDate}' AND campaign.status = 'ENABLED' ORDER BY metrics.cost_micros DESC LIMIT 50 `; const response = await client.query(meta.customerId, query); return response.map(row => ({ campaignId: row.campaign.id, campaignName: row.campaign.name, impressions: row.metrics.impressions, clicks: row.metrics.clicks, conversions: row.metrics.conversions, costMicros: row.metrics.cost_micros, // Divide by 1,000,000 for USD avgCpc: row.metrics.average_cpc, })); }

Pushing RSA copy (HITL-gated)

Ad copy generated by the Google Ads Writer agent is not pushed automatically. It goes through HITL review in the DM Portal first. On approval, the push is triggered:

async createResponsiveSearchAd( integration: Integration, options: { campaignId: string; adGroupId: string; headlines: string[]; // 3–15 headlines, max 30 chars each descriptions: string[]; // 2–4 descriptions, max 90 chars each finalUrl: string; }, ): Promise<{ adId: string; status: string }> { const client = await this.getClient(integration); const meta = integration.metadata as GoogleAdsMetadata; // Validate character limits before API call for (const h of options.headlines) { if (h.length > 30) throw new ValidationError(`Headline too long: "${h}"`); } const mutateResponse = await client.mutate(meta.customerId, [{ adGroupAdOperation: { create: { adGroup: `customers/${meta.customerId}/adGroups/${options.adGroupId}`, status: 'PAUSED', // Always create as paused — tenant activates manually ad: { responsiveSearchAd: { headlines: options.headlines.map(text => ({ text })), descriptions: options.descriptions.map(text => ({ text })), }, finalUrls: [options.finalUrl], }, }, }, }]); const resource = mutateResponse.mutateAdGroupAds.results[0].resourceName; const adId = resource.split('/').pop()!; return { adId, status: 'paused' }; } }

Data sync to PostgreSQL

Campaign data pulled from Google Ads is stored in the ad_campaigns table for reporting:

// Sync job — runs nightly const campaigns = await googleAds.getCampaignPerformance(integration, { startDate, endDate }); for (const campaign of campaigns) { await db.insert(adCampaigns) .values({ tenantId, platform: 'google_ads', externalId: campaign.campaignId, ...campaign }) .onConflictDoUpdate({ target: [adCampaigns.tenantId, adCampaigns.externalId], set: campaign }); }

Test Cases

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

TestApproach
getCampaignPerformance() builds correct GAQL queryMock client; assert query string contains date range
getCampaignPerformance() converts cost_micros correctlyAssert division by 1,000,000
createResponsiveSearchAd() creates ad as PAUSEDAssert status: 'PAUSED' in mutate payload
createResponsiveSearchAd() validates headline lengthPass 31-char headline; assert ValidationError
createResponsiveSearchAd() extracts adId from resource nameMock resource name; assert correct ID extracted

Integration tests

TestApproach
Fetch campaigns from test accountUse Google Ads test account; assert campaigns returned
Create RSA in test accountCreate ad; assert adId returned and ad exists in paused state

© 2026 Leadmetrics — Internal use only