---
title: Google Ads API — OAuth, REST Client & Keyword Research
slug: google-ads-api-integration
category: Integrations
summary: Complete Google Ads API integration with zero gems — org-level OAuth connect flow storing only a refresh token, a plain Net::HTTP REST client (the official gem sunsets too fast to bake), GAQL reporting, Keyword Planner research (ideas + historical volumes), the no-API CSV fallback, and a template for saving the working setup as a local agent skill.
tags: [google-ads, oauth, keyword-research, api, marketing, rest, service, skill]
status: stable
visibility: public
source_project: llamapress.ai
layers: [model, controller, view]
related:
  - title: Plaid — Bank Account Linking & Transaction Sync
    url: /cookbook/plaid-bank-account-sync
    summary: Sibling integration recipe — same zero-gem Net::HTTP service pattern with encrypted credentials.
  - title: Google Ads REST API reference
    url: https://developers.google.com/google-ads/api/rest/overview
    summary: Official docs for the REST endpoints, GAQL, and Keyword Planner services used below.
  - title: Google Ads API access levels
    url: https://developers.google.com/google-ads/api/docs/access-levels
    summary: Explorer vs Basic vs Standard developer-token tiers — what each unlocks and how to apply.
---

# 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

1. In Google Cloud Console, create (or reuse) an **OAuth web client**; add
   `https://yourapp.example.com/google_ads_auth/callback` to its redirect URIs. Put
   `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` in `.env`, then **recreate the web
   container** (a plain restart does not reload `.env`).
2. 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.
3. Run the migration below — credential columns on `organizations`, encrypted.
4. Copy `GoogleAdsRest` into `app/services/google_ads_rest.rb` and the auth
   controller into `app/controllers/google_ads_auth_controller.rb`; add the routes
   and the connect button (with `data: { turbo: false }` — load-bearing).
5. Connect via `/google_ads_auth/new`, pick the **non-manager** customer ID, then:
   `GoogleAdsRest.new(org).generate_keyword_ideas(keywords: ["your seed"])`.
6. **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.

```ruby
# 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
```

```ruby
# 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.

```ruby
# 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
```

```ruby
# 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):

```erb
<%# 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.

```ruby
# 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

```ruby
# 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:

```bash
# 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_VERSION` on every call.
  The REST client above survives with a one-line bump: set `GOOGLE_ADS_API_VERSION`
  in `.env` (or bump the `API_VERSION` default) 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 to `accounts.google.com` dies on CORS with no useful error.
  (Same failure class as Stripe checkout redirects.)
- **Keyword Planner rejects manager (MCC) accounts.** `generateKeywordIdeas` and
  `generateKeywordHistoricalMetrics` need the **non-manager** customer ID.
  `listAccessibleCustomers` returns every account the OAuth user can touch — don't
  blindly take the first one; pick the real (non-manager) ads account and store it
  as `google_ads_customer_id`.
- **`login-customer-id` header 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 the `login-customer-id` header (the client handles this when
  `google_ads_mcc_id` is 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_token` at all. Request `access_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-level
  `error.message` is generic; the actionable code (e.g.
  `DEVELOPER_TOKEN_NOT_APPROVED`, `CUSTOMER_NOT_ENABLED`, `UNSUPPORTED_VERSION`)
  lives in `error.details[].errors[].errorCode`. The client's `request` method
  surfaces it — keep that part.
- **Keyword Planner CSV exports are UTF-16LE TSVs** — `iconv` them 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 `mutate` endpoints — 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:

```markdown
---
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:

1. **Write it immediately after the first successful API call**, while the working
   customer ID and tier are known-good facts.
2. **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.
3. 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

1. **No `Organization` model?** Hang the columns on whatever your tenant/settings
   model is (`Account`, `Setting`, even `User` for single-user apps) — the client
   only needs the five `resolved_*`/credential readers and `google_ads_connected?`.
2. **Single-tenant / no UI wanted?** Skip the controller and view entirely: put
   `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_ADS_DEVELOPER_TOKEN` in
   `.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 `.env` loads.
3. **Different market?** Pass `location_ids:` (geo target constant IDs — 2840 US,
   2036 AU, 2826 UK) and `language_id:` (1000 = English) to both keyword methods.
4. **Need campaign creation?** Add `mutate` POST endpoints to `GoogleAdsRest` — same
   `headers`/`request` plumbing, endpoints like
   `customers/{id}/campaignBudgets:mutate`. Do conversion tracking first.
5. **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`.
