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