Google Ads API — OAuth, REST Client & Keyword Research
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
Your app (or your marketing operation) needs Google Ads data — keyword search volumes
and bid estimates for content/ads planning, campaign performance reports, or a
"connect your Ads account" feature for your users. This recipe is the complete
integration: an admin-only OAuth connect flow that stores only a refresh token, a
zero-gem REST client built on Net::HTTP, GAQL (Google Ads Query Language)
reporting with pagination, and Keyword Planner research — keyword ideas from seeds or
a URL, and historical per-month search volumes.
Why zero gems: the official google-ads-googleads gem pins to specific API versions,
and Google now sunsets versions roughly a year after release (four majors per year). A
baked gem goes stale and starts throwing UNSUPPORTED_VERSION — while the REST/JSON
surface needs nothing beyond the standard library and a one-line version bump. This
pattern is proven in production; the REST client replaced a dead gem path there.
When to use: keyword research for SEO/ads planning, campaign reporting dashboards, letting an org connect its own Ads account, automating negative-keyword or budget checks. When not to: you only need to run one small campaign — the Google Ads web UI is faster and needs no developer token. Also not for Google Analytics (different API entirely).
The 80/20 in one breath
- In Google Cloud Console, create (or reuse) an OAuth web client; add
https://yourapp.example.com/google_ads_auth/callbackto its redirect URIs. PutGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRETin.env, then recreate the web container (a plain restart does not reload.env). - Get a developer token from any Google Ads manager account (Ads UI → Admin → API Center). It starts at Explorer tier — enough to verify the connection; apply for Basic to unlock reporting and Keyword Planner.
- Run the migration below — credential columns on
organizations, encrypted. - Copy
GoogleAdsRestintoapp/services/google_ads_rest.rband the auth controller intoapp/controllers/google_ads_auth_controller.rb; add the routes and the connect button (withdata: { turbo: false }— load-bearing). - Connect via
/google_ads_auth/new, pick the non-manager customer ID, then:GoogleAdsRest.new(org).generate_keyword_ideas(keywords: ["your seed"]). - Save what you learned as a local skill (last section) so the working account IDs, token tier, and quirks survive into future agent sessions.
Layer 1 — Model & migration
Credentials live on Organization (or whatever your app's tenant/settings model is),
encrypted at rest, with ENV fallbacks so a single-tenant app can skip the UI entirely.
# db/migrate/XXXXXXXXXXXXXX_add_google_ads_to_organizations.rb
class AddGoogleAdsToOrganizations < ActiveRecord::Migration[7.1]
def change
add_column :organizations, :google_ads_client_id, :string
add_column :organizations, :google_ads_client_secret, :string
add_column :organizations, :google_ads_refresh_token, :string
add_column :organizations, :google_ads_developer_token, :string
add_column :organizations, :google_ads_customer_id, :string # the NON-manager account
add_column :organizations, :google_ads_mcc_id, :string # manager (MCC) id, if any
add_column :organizations, :google_ads_connected_at, :datetime
end
end
# app/models/organization.rb (the Google Ads slice)
class Organization < ApplicationRecord
encrypts :google_ads_client_secret if respond_to?(:encrypts)
encrypts :google_ads_refresh_token if respond_to?(:encrypts)
encrypts :google_ads_developer_token if respond_to?(:encrypts)
def google_ads_connected?
google_ads_refresh_token.present? && google_ads_connected_at.present?
end
# ENV fallbacks: a single-tenant app can set these in .env and never touch the UI.
def resolved_google_ads_client_id
google_ads_client_id.presence || ENV['GOOGLE_CLIENT_ID']
end
def resolved_google_ads_client_secret
google_ads_client_secret.presence || ENV['GOOGLE_CLIENT_SECRET']
end
def resolved_google_ads_developer_token
google_ads_developer_token.presence || ENV['GOOGLE_ADS_DEVELOPER_TOKEN']
end
def disconnect_google_ads!
update!(
google_ads_client_id: nil, google_ads_client_secret: nil,
google_ads_refresh_token: nil, google_ads_developer_token: nil,
google_ads_customer_id: nil, google_ads_mcc_id: nil,
google_ads_connected_at: nil
)
end
end
Layer 2 — Controller: the OAuth connect flow
Admin-only. The OAuth client comes from ENV (reuse your app-wide Google web client if
you have one); only the refresh token is persisted. The developer token is entered
in the UI so connecting never requires a .env change + container recreate.
# app/controllers/google_ads_auth_controller.rb
require 'securerandom'
class GoogleAdsAuthController < ApplicationController
before_action :authenticate_user!
before_action :ensure_admin
SCOPE = 'https://www.googleapis.com/auth/adwords'.freeze
AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'.freeze
TOKEN_URL = 'https://oauth2.googleapis.com/token'.freeze
# GET /google_ads_auth/new — start the OAuth flow
def new
if params[:developer_token].present?
current_organization.update!(google_ads_developer_token: params[:developer_token].strip)
end
state = SecureRandom.hex(24)
session[:google_ads_oauth_state] = state
query = {
client_id: ENV.fetch('GOOGLE_CLIENT_ID'),
redirect_uri: google_ads_auth_callback_url,
response_type: 'code',
scope: SCOPE,
state: state,
access_type: 'offline', # request a refresh token
prompt: 'consent' # force consent so the refresh token is actually returned
}
redirect_to "#{AUTH_URL}?#{query.to_query}", allow_other_host: true
end
# GET /google_ads_auth/callback — handle Google's redirect
def callback
if params[:state].blank? || params[:state] != session.delete(:google_ads_oauth_state)
flash[:error] = "Invalid state parameter. Please try connecting again."
return redirect_to edit_organization_path(current_organization)
end
if params[:error].present?
flash[:error] = "Google Ads authorization failed: #{params[:error]}"
return redirect_to edit_organization_path(current_organization)
end
resp = Net::HTTP.post_form(URI(TOKEN_URL), {
'client_id' => ENV.fetch('GOOGLE_CLIENT_ID'),
'client_secret' => ENV.fetch('GOOGLE_CLIENT_SECRET'),
'code' => params[:code],
'grant_type' => 'authorization_code',
'redirect_uri' => google_ads_auth_callback_url
})
data = JSON.parse(resp.body)
raise "Token exchange failed: #{data['error_description'] || data['error']}" unless resp.is_a?(Net::HTTPSuccess)
attrs = {
google_ads_client_id: ENV['GOOGLE_CLIENT_ID'],
google_ads_client_secret: ENV['GOOGLE_CLIENT_SECRET'],
google_ads_connected_at: Time.current
}
# Google omits the refresh token on silent re-consent — never clobber a good one.
attrs[:google_ads_refresh_token] = data['refresh_token'] if data['refresh_token'].present?
current_organization.update!(attrs)
flash[:success] = "Successfully connected to Google Ads!"
redirect_to edit_organization_path(current_organization)
rescue => e
Rails.logger.error "Google Ads OAuth error: #{e.class}: #{e.message}"
flash[:error] = "Failed to complete authorization: #{e.message}"
redirect_to edit_organization_path(current_organization)
end
# DELETE /google_ads_auth — disconnect
def destroy
current_organization.disconnect_google_ads!
redirect_to edit_organization_path(current_organization), notice: "Google Ads disconnected."
end
private
def current_organization
@current_organization ||= current_user.organization
end
def ensure_admin
redirect_to root_path, alert: "Admin access required." unless current_user.admin?
end
end
# config/routes.rb (add inside the draw block)
namespace :google_ads_auth do
get :new
get :callback
end
delete 'google_ads_auth', to: 'google_ads_auth#destroy'
The connect button — data: { turbo: false } is load-bearing (see Gotchas):
<%# app/views/organizations/edit.html.erb — the "Google Ads Integration" card %>
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="font-semibold text-gray-900">Google Ads Integration</h3>
<% if @organization.google_ads_connected? %>
<p class="text-sm text-green-700 mt-1">
Connected <%= time_ago_in_words(@organization.google_ads_connected_at) %> ago
(customer <%= @organization.google_ads_customer_id || "not selected" %>)
</p>
<%= button_to "Disconnect", google_ads_auth_path, method: :delete,
class: "mt-2 text-sm text-red-600 underline" %>
<% else %>
<%= form_with url: google_ads_auth_new_path, method: :get, data: { turbo: false } do %>
<label class="block text-sm text-gray-600 mt-2">Developer token
<input type="text" name="developer_token" class="mt-1 block w-full rounded border-gray-300">
</label>
<button type="submit" class="mt-3 rounded bg-blue-600 px-4 py-2 text-white">
Connect Google Ads
</button>
<% end %>
<% end %>
</div>
Layer 3 — The REST client (the whole integration, zero gems)
This is the core of the recipe. Token refresh, headers (developer-token +
login-customer-id), GAQL pagination, Keyword Planner, and error surfacing are all
handled here.
# app/services/google_ads_rest.rb
require 'net/http'
require 'json'
# Minimal REST/JSON client for the Google Ads API, driven by the organization's
# stored OAuth credentials. Zero gems: the official client gem pins to API
# versions that Google sunsets within ~a year, while the REST surface needs
# nothing beyond Net::HTTP and a one-line version bump.
class GoogleAdsRest
API_VERSION = ENV.fetch('GOOGLE_ADS_API_VERSION', 'v23') # bump when Google sunsets
BASE_URL = 'https://googleads.googleapis.com'.freeze
TOKEN_URL = 'https://oauth2.googleapis.com/token'.freeze
class ApiError < StandardError; end
attr_reader :organization
def initialize(organization)
@organization = organization
raise ApiError, "Google Ads not connected for organization ##{organization.id}" unless organization.google_ads_connected?
end
# => ["1234567890", ...] — works at ANY developer-token tier (use to verify auth).
def list_accessible_customers
get("customers:listAccessibleCustomers").fetch('resourceNames', []).map { |n| n.split('/').last }
end
# GAQL search, follows pagination. Returns the raw result hashes (camelCase keys).
def search(query, customer_id: default_customer_id)
results = []
page_token = nil
loop do
body = { query: query }
body[:pageToken] = page_token if page_token
resp = post("customers/#{digits(customer_id)}/googleAds:search", body)
results.concat(resp.fetch('results', []))
page_token = resp['nextPageToken']
break if page_token.nil?
end
results
end
# Keyword Planner: ideas from seed keywords and/or a URL.
# Defaults: US (geo 2840), English (language 1000), Google Search only.
# Needs Basic access; needs a NON-manager customer_id.
def generate_keyword_ideas(keywords: [], page_url: nil, location_ids: [2840], language_id: 1000, limit: 100)
keywords = Array(keywords).map(&:to_s).reject(&:empty?)
raise ArgumentError, "Provide keywords and/or page_url" if keywords.empty? && page_url.nil?
body = {
language: "languageConstants/#{language_id}",
geoTargetConstants: location_ids.map { |id| "geoTargetConstants/#{id}" },
includeAdultKeywords: false,
keywordPlanNetwork: 'GOOGLE_SEARCH',
pageSize: [limit, 1000].min
}
if keywords.any? && page_url.present?
body[:keywordAndUrlSeed] = { url: page_url, keywords: keywords }
elsif keywords.any?
body[:keywordSeed] = { keywords: keywords }
else
body[:urlSeed] = { url: page_url }
end
resp = post("customers/#{default_customer_id}:generateKeywordIdeas", body)
resp.fetch('results', []).first(limit).map do |r|
m = r['keywordIdeaMetrics'] || {}
{
keyword: r['text'],
avg_monthly_searches: m['avgMonthlySearches']&.to_i,
competition: m['competition'],
low_top_of_page_bid: micros_to_dollars(m['lowTopOfPageBidMicros']),
high_top_of_page_bid: micros_to_dollars(m['highTopOfPageBidMicros'])
}
end
end
# Keyword Planner: historical metrics (incl. per-month volumes) for an
# explicit keyword list (max 10,000 per request). Needs Basic access.
def generate_keyword_historical_metrics(keywords, location_ids: [2840], language_id: 1000)
keywords = Array(keywords).map(&:to_s).reject(&:empty?)
raise ArgumentError, "Provide at least one keyword" if keywords.empty?
resp = post("customers/#{default_customer_id}:generateKeywordHistoricalMetrics", {
keywords: keywords,
language: "languageConstants/#{language_id}",
geoTargetConstants: location_ids.map { |id| "geoTargetConstants/#{id}" },
keywordPlanNetwork: 'GOOGLE_SEARCH'
})
resp.fetch('results', []).map do |r|
m = r['keywordMetrics'] || {}
{
keyword: r['text'],
close_variants: r.fetch('closeVariants', []),
avg_monthly_searches: m['avgMonthlySearches']&.to_i,
competition: m['competition'],
competition_index: m['competitionIndex']&.to_i,
low_top_of_page_bid: micros_to_dollars(m['lowTopOfPageBidMicros']),
high_top_of_page_bid: micros_to_dollars(m['highTopOfPageBidMicros']),
monthly_search_volumes: m.fetch('monthlySearchVolumes', []).map do |v|
{ year: v['year']&.to_i, month: v['month'], searches: v['monthlySearches']&.to_i }
end
}
end
end
private
def default_customer_id
id = organization.google_ads_customer_id.presence || organization.google_ads_mcc_id
raise ApiError, "No Google Ads customer ID on organization ##{organization.id}" if id.blank?
digits(id)
end
def digits(customer_id)
customer_id.to_s.delete('-')
end
def micros_to_dollars(micros)
micros.to_i / 1_000_000.0
end
def access_token
return @access_token if @access_token && @access_token_expires_at > Time.current
resp = Net::HTTP.post_form(URI(TOKEN_URL), {
'client_id' => organization.resolved_google_ads_client_id,
'client_secret' => organization.resolved_google_ads_client_secret,
'refresh_token' => organization.google_ads_refresh_token,
'grant_type' => 'refresh_token'
})
data = JSON.parse(resp.body)
raise ApiError, "Token refresh failed: #{data['error']}: #{data['error_description']}" unless resp.is_a?(Net::HTTPSuccess)
@access_token_expires_at = Time.current + data.fetch('expires_in', 3600).to_i - 60
@access_token = data.fetch('access_token')
end
def headers
h = {
'Authorization' => "Bearer #{access_token}",
'developer-token' => organization.resolved_google_ads_developer_token,
'Content-Type' => 'application/json'
}
# Required when the OAuth user reaches the account THROUGH a manager (MCC):
h['login-customer-id'] = digits(organization.google_ads_mcc_id) if organization.google_ads_mcc_id.present?
h
end
def get(path)
request(Net::HTTP::Get, path)
end
def post(path, body)
request(Net::HTTP::Post, path, body)
end
def request(klass, path, body = nil)
uri = URI("#{BASE_URL}/#{API_VERSION}/#{path}")
req = klass.new(uri, headers)
req.body = body.to_json if body
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 120) { |http| http.request(req) }
data = JSON.parse(resp.body) rescue { 'raw' => resp.body }
return data if resp.is_a?(Net::HTTPSuccess)
# The actionable error code lives in error.details[].errors[], not error.message.
detail_errors = Array(data.dig('error', 'details')).flat_map { |d| Array(d['errors']) }
detail = detail_errors.map { |e| "#{e['errorCode']&.values&.join(',')}: #{e['message']}" }.join(' | ')
detail = data.dig('error', 'message') || data['raw'].to_s[0, 500] if detail.blank?
raise ApiError, "Google Ads API #{resp.code} on #{path}: #{detail}"
end
end
Layer 4 — Using it: keyword research & reporting
# Anywhere in the app (or bin/rails runner):
rest = GoogleAdsRest.new(Organization.first)
# 1) Verify the connection (works at ANY token tier):
rest.list_accessible_customers
# => ["1234567890", "9876543210"] pick the NON-manager one → org.google_ads_customer_id
# 2) Keyword ideas from seeds and/or a landing page (needs Basic access):
rest.generate_keyword_ideas(
keywords: ["spreadsheet to app", "custom estimating software"],
page_url: "https://yourapp.example.com/your-landing-page",
limit: 100
)
# => [{keyword:, avg_monthly_searches:, competition:, low_top_of_page_bid:, high_top_of_page_bid:}, ...]
# 3) Exact historical volumes with per-month breakdown (needs Basic access):
rest.generate_keyword_historical_metrics(["quoting software", "cpq software"])
# 4) GAQL reporting — campaigns, spend, clicks:
rest.search(<<~GAQL)
SELECT campaign.id, campaign.name, metrics.clicks, metrics.cost_micros
FROM campaign
WHERE segments.date DURING LAST_30_DAYS
GAQL
Useful GAQL resources beyond campaign: search_term_view (what people actually
typed), keyword_view (per-keyword performance), customer_client (account
hierarchy under an MCC), user_interest / detailed_demographic (audience
segments). Non-US/English research: pass location_ids: (geo target constants, e.g.
2036 = Australia) and language_id: to the keyword methods.
Keyword research WITHOUT the API (the CSV path)
While waiting for Basic access — or with no developer token at all — a human can export the same data from the Keyword Planner UI: Google Ads → Tools → Keyword Planner → "Discover new keywords" → set geo/language/12-months → export CSV.
Gotcha: the exports are UTF-16LE tab-separated files, not normal CSVs. Convert before parsing:
# Convert a Keyword Planner export to normal UTF-8 TSV:
iconv -f UTF-16LE -t UTF-8 "Keyword Stats.csv" > keywords_utf8.tsv
The exports carry real (non-bucketed) monthly volumes, per-month breakdowns, and bid ranges. Merge pattern for several exports: parse all files, dedup by keyword keeping the max volume, bucket by intent with regexes, sort by volume.
Developer-token tiers (what "403" usually means)
| Tier | How you get it | What it unlocks |
|---|---|---|
| Explorer (default) | Automatic with the token | listAccessibleCustomers only — enough to prove auth works |
| Basic | Short application in the Ads API Center (~1–2 days) | Everything this recipe uses: reporting, GAQL, Keyword Planner. 15,000 operations/day |
| Standard | Full RMF audit | Higher limits. Do not apply early — under-volume applications are auto-denied |
Application tips that worked: describe an internal-only tool, advertising only your
own site, a handful of admin users, no third-party access or data resale. Declare
only the capabilities you need (e.g. Reporting + Keyword Planning), Search campaigns
only. A 403 DEVELOPER_TOKEN_NOT_APPROVED is a tier problem, not an auth
problem — the OAuth is fine, the token just isn't Basic yet.
Gotchas (the hard-won stuff)
- Don't use the official gem — API versions sunset fast. Google ships ~4 major
API versions a year and blocks each about a year after release. A gem baked into a
Docker image WILL go stale and start throwing
UNSUPPORTED_VERSIONon every call. The REST client above survives with a one-line bump: setGOOGLE_ADS_API_VERSIONin.env(or bump theAPI_VERSIONdefault) when that error appears. - Turbo breaks the OAuth redirect. The connect form MUST have
data: { turbo: false }— otherwise Turbo intercepts the submit and the cross-origin redirect toaccounts.google.comdies on CORS with no useful error. (Same failure class as Stripe checkout redirects.) - Keyword Planner rejects manager (MCC) accounts.
generateKeywordIdeasandgenerateKeywordHistoricalMetricsneed the non-manager customer ID.listAccessibleCustomersreturns every account the OAuth user can touch — don't blindly take the first one; pick the real (non-manager) ads account and store it asgoogle_ads_customer_id. login-customer-idheader when going through an MCC. If the OAuth user's access to the target account is via a manager account, requests must carry the manager's ID in thelogin-customer-idheader (the client handles this whengoogle_ads_mcc_idis set). Customer IDs in URLs/headers must be digits only — strip the dashes.- Google omits the refresh token on re-consent. A second OAuth pass often
returns no
refresh_tokenat all. Requestaccess_type: 'offline'+prompt: 'consent'to force one on first connect, and never overwrite a stored refresh token with a blank. - Internal-only OAuth consent screens 403 personal accounts. If your Google
Cloud OAuth consent screen is set to "Internal" (Workspace-only), a personal-Gmail
sign-in fails with
Error 403: org_internal. Sign in with a Workspace account that has access to the ads account (invite it in Ads → Admin → Access) rather than flipping the consent screen to External — External can add warnings/verification requirements for every other scope on the same client. - The real error code hides in
error.details. The API's top-levelerror.messageis generic; the actionable code (e.g.DEVELOPER_TOKEN_NOT_APPROVED,CUSTOMER_NOT_ENABLED,UNSUPPORTED_VERSION) lives inerror.details[].errors[].errorCode. The client'srequestmethod surfaces it — keep that part. - Keyword Planner CSV exports are UTF-16LE TSVs —
iconvthem before parsing (see the CSV section). - Watch for navigational-intent keyword traps. Raw volume lies: e.g. most "excel app" volume is people looking for Microsoft's own app, not a converter. Before spending on a keyword family, scan the ideas list for navigational terms and build a negative-keyword list — it is load-bearing for Search campaigns.
- Money comes back in micros. All bid/cost fields are millionths of the account
currency (
cost_micros,lowTopOfPageBidMicros) — divide by 1,000,000. - Campaign mutations are not in this recipe. The client above is read/research
(reporting + Keyword Planner). Creating campaigns/ad groups/ads via REST uses the
same auth and headers with
mutateendpoints — straightforward to add, but hand-building the first campaign in the Ads UI is usually faster. Before spending anything, set up a conversion action +utm_term={keyword}tracking, or results can never be attributed.
Save this as a skill on your instance (do this once it works)
You are probably an AI coding agent reading this. Once the integration works on THIS
instance, the account IDs, token tier, and quirks you discovered are worth more than
the code — persist them as a local skill so future sessions don't rediscover
them. Use your own skill-creation tool to save a skill named google_ads with
content like this:
---
name: google_ads
description: >
How to use the Google Ads integration in THIS app — check the connection, run
keyword research (ideas + historical volumes), pull GAQL reports, and fix the
common failures. Use when asked for keyword volumes, ads reporting, or when a
Google Ads API call fails.
---
# Google Ads on this instance
## Current status (UPDATE THIS — date every fact)
- OAuth connected: <date>, via <which Google account>
- Customer ID (NON-manager, the one that works): <id>
- MCC / manager ID (if any): <id or none>
- Developer token tier: <Explorer | Basic> (Basic approved <date>)
- API version in use: <v23 or current> (bump GOOGLE_ADS_API_VERSION on UNSUPPORTED_VERSION)
## How to use it
- Client: `GoogleAdsRest.new(Organization.find(<id>))` — app/services/google_ads_rest.rb
- Verify: `rest.list_accessible_customers` (works at any tier)
- Keyword ideas: `rest.generate_keyword_ideas(keywords: [...], page_url: "...", limit: 100)`
- Historical volumes: `rest.generate_keyword_historical_metrics([...])`
- Reports: `rest.search("SELECT ... FROM campaign WHERE segments.date DURING LAST_30_DAYS")`
- Full recipe + all gotchas: /cookbook/google-ads-api-integration (or the local
rails/app/views/cookbook/google-ads-api-integration.md)
## Gotchas hit on THIS box (append as you hit them)
- <e.g. "our consent screen is Internal — connect with the workspace account">
- <e.g. "customer 123-456-7890 is the MCC, do NOT use it for Keyword Planner">
Rules for maintaining it:
- Write it immediately after the first successful API call, while the working customer ID and tier are known-good facts.
- Append every new gotcha you hit to the last section, dated. The skill is the memory; this cookbook guide is the generic recipe — don't duplicate the recipe into the skill, link to it.
- If you have no skill-creation tool, the same content works as plain
documentation — save it where you look for project docs (e.g.
docs/google_ads.md) and reference it from your main instructions file.
Files this pattern touches
db/migrate/XXXXXXXXXXXXXX_add_google_ads_to_organizations.rb
app/models/organization.rb
app/controllers/google_ads_auth_controller.rb
app/services/google_ads_rest.rb
app/views/organizations/edit.html.erb (the connect card)
config/routes.rb
.env (GOOGLE_CLIENT_ID/SECRET, optional GOOGLE_ADS_* fallbacks)
Plus a google_ads skill saved through your own skill tool after it works (last
section above).
How to adapt to your schema
- No
Organizationmodel? Hang the columns on whatever your tenant/settings model is (Account,Setting, evenUserfor single-user apps) — the client only needs the fiveresolved_*/credential readers andgoogle_ads_connected?. - Single-tenant / no UI wanted? Skip the controller and view entirely: put
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GOOGLE_ADS_DEVELOPER_TOKENin.env, run the OAuth consent once by hand (any OAuth playground works), and store the refresh token + customer ID directly on the record from the console. Then recreate the web container so.envloads. - Different market? Pass
location_ids:(geo target constant IDs — 2840 US, 2036 AU, 2826 UK) andlanguage_id:(1000 = English) to both keyword methods. - Need campaign creation? Add
mutatePOST endpoints toGoogleAdsRest— sameheaders/requestplumbing, endpoints likecustomers/{id}/campaignBudgets:mutate. Do conversion tracking first. - Safe to drop: the MCC handling (
google_ads_mcc_id,login-customer-id) if the OAuth user directly owns the ads account; the developer-token UI field if it lives in.env.