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.
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¶
- Spend —
metrics.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 / impressionsover the whole window, never averaged from the per-dayctrcolumn. Stored as a percentage (the API reports a 0–1 fraction). - Conversions / cost per conversion —
metrics.conversionskeeps its fractional attribution credit (numeric, never rounded to an integer);metrics.all_conversionsis 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_clampedso 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_adrow, so summing would under-report. - One failing account does not abort the others — the error lands in
google_ads_accounts.last_sync_errorand the run continues. Aninvalid_grantis rewritten to a "reconnect this account" message. verify_jwt = false+authenticateRequest, so a cron or service-role caller can drive it by passingorganization_idin the body (same pattern asmeta-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-adsrendersGoogleAdsLockedPage— 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_adsto the org'sfeature_restrictions.enabled_features). There is no self-serve Stripe add-on for it yet; if one is created later, wire it the waybot_builder_addonis.
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.