Google Ads API: OAuth, REST and Keywords | LlamaPress Cookbook
LlamaPress
Cookbook
Integrations Stable

Google Ads API — OAuth, REST Client & Keyword Research

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.

Proven on llamapress.ai model · controller · view

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.

# 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_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 TSVsiconv 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:

---
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.

Related