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:
- Pull — Import live campaign performance data (impressions, clicks, conversions, spend) into the platform for reporting and anomaly detection
- 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-xxxxxxxxxxxxxxxxxxxxThe 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)
| Test | Approach |
|---|---|
getCampaignPerformance() builds correct GAQL query | Mock client; assert query string contains date range |
getCampaignPerformance() converts cost_micros correctly | Assert division by 1,000,000 |
createResponsiveSearchAd() creates ad as PAUSED | Assert status: 'PAUSED' in mutate payload |
createResponsiveSearchAd() validates headline length | Pass 31-char headline; assert ValidationError |
createResponsiveSearchAd() extracts adId from resource name | Mock resource name; assert correct ID extracted |
Integration tests
| Test | Approach |
|---|---|
| Fetch campaigns from test account | Use Google Ads test account; assert campaigns returned |
| Create RSA in test account | Create ad; assert adId returned and ad exists in paused state |
Related
- Meta Ads Provider — Facebook/Instagram campaigns
- Google Ads Writer Agent — generates RSA copy
- Ads Analyst Agent — analyzes campaign performance
- Tool Integration Layer — OAuth management