Skip to content

Google Ads (Google Ads API)

Pulls campaign structure, ads and daily performance from an organization's Google Ads accounts into ConnectGain — the Google counterpart of the Meta Ads integration, whose shape (tables, sync contract, currency handling) it deliberately mirrors.

Google Ads reporting inside ConnectGain Google Ads reporting inside ConnectGain Google Ads reporting inside ConnectGain.

Surfaced at /google-ads, behind the google_ads feature gate. Status: beta / early access — see Feature gating below.


What it reads

Data Source (GAQL) Stored in
Customer accounts customers:listAccessibleCustomers + customer_client google_ads_accounts
Campaigns FROM campaign google_ads_campaigns
Ad groups FROM ad_group google_ads_ad_groups
Ads + copy FROM ad_group_ad google_ads_ads
Daily metrics (ad / campaign / account) FROM ad_group_ad / campaign / customer with segments.date google_ads_insights

Headline metrics

  • Spendmetrics.cost_micros / 1e6, reported in the org's own currency where a rate is available (same per-day FX pipeline as Meta Ads).
  • Click-through rate — recomputed as clicks / impressions over the whole window, never averaged from the per-day ctr column. Stored as a percentage (the API reports a 0–1 fraction).
  • Conversions / cost per conversionmetrics.conversions keeps its fractional attribution credit (numeric, never rounded to an integer); metrics.all_conversions is stored alongside for completeness.

The integration is read-only. It never creates, edits, pauses or spends against an ad account.


Credentials

Three edge secrets, two of which already exist:

Secret Status Purpose
GOOGLE_CLIENT_ID reused — same Google Cloud OAuth app as Google Calendar OAuth consent + token exchange
GOOGLE_CLIENT_SECRET reused OAuth token exchange / refresh
GOOGLE_ADS_DEVELOPER_TOKEN new Mandatory on every Google Ads API call

The developer token is issued once per Google Cloud app in Google Ads → Tools → API Center, under any manager (MCC) account. A fresh token starts at Test access (can only query test accounts); apply for Basic access there to read production accounts. This is a one-time step, independent of which customers later connect.

The OAuth consent screen must list the https://www.googleapis.com/auth/adwords scope. Since the Calendar app is already verified for its scopes, adding adwords may re-trigger Google's verification flow for the consent screen.

Connect flow

google-ads-connect builds the consent URL server-side (action oauth_url — the client id is an edge secret, not a VITE_ variable), the browser opens it in a popup, and /auth/google-ads/callback posts the code back to the opener. The code is exchanged server-side with access_type=offline&prompt=consent, so a refresh token comes back; it is stored per account in google_ads_account_secrets, a service-role-only table with RLS on and no policies (the same shape as meta_ad_account_secrets). Refresh tokens do not expire on a schedule — a sync failing with invalid_grant means the user revoked access, and the account row says "reconnect".

Account discovery

Every directly-accessible customer is expanded one level through customer_client (level ≤ 1, hidden accounts excluded), so accounts managed through an MCC are found. Manager accounts themselves are skipped — they carry no metrics — and each stored account remembers the root it was found under in login_customer_id, which is sent as the login-customer-id header on every call for that account.


Currency

Identical to Meta Ads: Google reports in the ad account's currency, and google-ads-sync resolves the rate that applied on each row's own day from the shared public.fx_rates cache, storing it as google_ads_insights.fx_rate + fx_org_currency. Client-side precedence per row: own rate → nearest neighbouring row's rate → the account's manual fallback (google_ads_accounts.fx_rate_to_org_currency, edited in the Accounts tab) → no conversion, with the page saying so. Conversion happens before summing, so every derived figure comes out in one currency.


Sync

google-ads-sync accepts { account_uuid?, since?, until? }.

  • Defaults to the last 30 days; a window longer than 92 days is clamped, and the response sets window_clamped so the UI can say so.
  • Insights are stored one row per (level, object, day) and upserted on that key, so re-running an overlapping window corrects those days rather than double-counting spend.
  • Campaign- and account-level rows are pulled from the API rather than summed from ad-level rows: campaign totals include spend from since-removed ads and Performance Max spend that has no ad_group_ad row, so summing would under-report.
  • One failing account does not abort the others — the error lands in google_ads_accounts.last_sync_error and the run continues. An invalid_grant is rewritten to a "reconnect this account" message.
  • verify_jwt = false + authenticateRequest, so a cron or service-role caller can drive it by passing organization_id in the body (same pattern as meta-ads-sync).
  • No cron job is wired up yet — the page syncs on demand via its Sync button.

Feature gating — beta / early access

google_ads is registered in RESTRICTED_FEATURES:

  • Internal orgs (@appgain.io / @ikhair.net) and the demo org see it unlocked, with a Beta badge in the sidebar and on the page.
  • Everyone else sees a locked sidebar entry, and /google-ads renders GoogleAdsLockedPage — a beta pitch listing what the reporting includes and offering early access at $20/month, with a request CTA that emails support.
  • Granting early access after payment = toggling Google Ads in the platform-admin Org entitlements editor (it adds google_ads to the org's feature_restrictions.enabled_features). There is no self-serve Stripe add-on for it yet; if one is created later, wire it the way bot_builder_addon is.

Notes and limits

  • Removed ads are not pruned. Structure rows are upserted, never deleted; historical insight rows remain valid and still count toward totals.
  • Ad-level daily data is large. the app pages explicitly rather than silently truncating at PostgREST's 1000-row cap.
  • Rates are recomputed from totals, never averaged from daily CTR/CPC columns.
  • Budgets are micros of the account currency, as the API returns them, and are not FX-converted.
  • Google ads are text-first — the Ads tab is a table of headline / description / destination rather than Meta's creative image grid. The first headline and description of a responsive ad are flattened into columns; the full payload stays in google_ads_ads.ad_details.
  • Ad-group-level insights are supported by the schema but not synced or surfaced yet (mirrors Meta's unsynced adset level).
  • API version is pinned in the platform (GOOGLE_ADS_API_VERSION); Google sunsets each version roughly a year after release, so expect to bump it periodically.