tva
← Insights

Google Ads API: Setup, Authentication, Read and Write Operations

A technical walkthrough for setting up programmatic Google Ads access — from architecture to the first API call. Covers MCC configuration, OAuth 2.0, Developer Token levels, the google-ads Python library, GAQL read queries, and the validate_only write pattern.

Architecture

Three separate entities are required before a single API call works:

EntityPurposeExample
**Manager Account (MCC)**Issues the Developer Token`login_customer_id`
**Google Cloud Project**OAuth 2.0 client for authenticationClient ID + Secret
**Google Ads Account**Target of all API operations`customer_id`

They are not the same account. An MCC can own a Developer Token without owning the target account — it only needs to be linked as a manager. The Google Cloud Project can be under a different Google identity than the MCC. The target account is the advertiser account where campaigns live.

This separation matters because API Center (where Developer Tokens are provisioned) is only available inside Manager Accounts. A standalone Google Ads account cannot access API Center, even if it has administrative users.

The API call header assembles these three identities plus an OAuth Bearer token:

developer-token: <from MCC API Center>
login-customer-id: <MCC numeric ID, no dashes>
customer-id: <target account numeric ID>
Authorization: Bearer <OAuth 2.0 access token>

Prerequisites

  • A Google Ads Manager Account with completed setup
  • The target ad account linked to the Manager Account and the link accepted
  • A Google Cloud Project with the Google Ads API enabled
  • Python 3.9+ with the google-ads library (pip install google-ads)
  • A browser for the one-time OAuth consent flow

Step 1: Manager Account and Developer Token

API Center lives under Tools & Settings → Setup → API Center inside a Manager Account. If API Center is missing, the account is either not a Manager Account or its setup is incomplete.

From API Center, request a Developer Token. Set the account type to Advertiser (not Agency or Third-Party) when the token is for your own accounts. The token is issued immediately at Test Access level.

Test Access allows:

  • Read operations against any linked account
  • Write operations against test accounts only
  • No write operations against production accounts

The error for writes against production accounts with Test Access is RESOURCE_NOT_FOUND — not PERMISSION_DENIED. This is confusing because the resource does exist and is readable. The error means the API refuses to route the mutation under the current access level.

Basic Access Application

To write against production accounts, apply for Basic Access from the same API Center page. The application form asks 12 questions. For internal use on your own accounts, the relevant answers are:

QuestionAnswer
Q4: Relationship with a Google RepresentativeNo
Q6: Business ModelDescribe your own use case (e-commerce, lead gen, internal reporting)
Q8: Who will have accessInternal users — employees only
Q9: Third-party developed toolNo (own scripts/agents)
Q10: Conversion Tracking & Remarketing APINo (unless you actually use it)
Q11: Campaign TypesSearch, Performance Max, Shopping (as applicable)
Q12: CapabilitiesCampaign Creation, Campaign Management, Reporting

A design document (PDF) is required for Q7. It should describe the API architecture, authentication flow, rate-limiting strategy, error handling, and the internal-only use case. Keep it factual — the compliance team reviews for consistency with the declared use case.

Processing time as of June 2026: approximately 3 working days, with a note about higher-than-usual application volume.

Step 2: OAuth 2.0

Google Cloud Project Setup

In the Google Cloud Console under the relevant project:

  1. Enable the Google Ads API (APIs & Services → Library)
  2. Configure the OAuth consent screen:
  • User Type: Internal (only users in your Google Workspace can authorize — skips external app verification)
  • App name, support email, developer contact email
  1. Add the scope under Data Access: https://www.googleapis.com/auth/adwords
  2. Create an OAuth Client ID:
  • Application type: Desktop app
  • Download the client secret JSON

The Desktop app type uses http://localhost as the redirect URI. This is correct — the OAuth flow opens a browser, Google redirects to localhost with the authorization code, and a local HTTP server captures it.

Refresh Token Generation

A Python script that runs a local HTTP server on 127.0.0.1:0 (random port), prints the authorization URL, waits for one request, extracts the code parameter, and exchanges it for tokens:

from pathlib import Path
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
from google_auth_oauthlib.flow import Flow

class CallbackHandler(BaseHTTPRequestHandler):
    code_value = None
    def do_GET(self):
        qs = parse_qs(urlparse(self.path).query)
        CallbackHandler.code_value = (qs.get('code') or [None])[0]
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'OAuth complete.')
    def log_message(self, format, *args):
        return

server = HTTPServer(('127.0.0.1', 0), CallbackHandler)
redirect_uri = f'http://127.0.0.1:{server.server_port}/'
flow = Flow.from_client_secrets_file(
    'client_secret.json',
    scopes=['https://www.googleapis.com/auth/adwords'],
    redirect_uri=redirect_uri
)
auth_url, _ = flow.authorization_url(
    access_type='offline',
    prompt='consent'
)
print(auth_url)
server.handle_request()
flow.fetch_token(code=CallbackHandler.code_value)
Path('google_ads_token.json').write_text(flow.credentials.to_json())

Two parameters matter:

  • access_type='offline' — returns a refresh token, not just an access token
  • prompt='consent' — forces a new consent screen even if the user previously authorized; required to get a new refresh token if scopes changed

The refresh token is permanent unless revoked, the user changes their password, or the token goes unused for 6 months.

Scope Pitfall with Existing Clients

If the OAuth client was previously authorized for other scopes (Gmail, Drive, Calendar), Google returns the union of old and new scopes. The google-auth-oauthlib library rejects this scope mismatch by default. Set the environment variable before loading the flow:

import os
os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] = '1'

Alternatively, omit include_granted_scopes=true from the authorization URL, which tells Google to request only the explicitly passed scope.

Step 3: `google-ads.yaml`

The library reads configuration from a YAML file:

developer_token: <value>
client_id: <value>
client_secret: <value>
refresh_token: <value>
login_customer_id: 1911599764
use_proto_plus: true

Security rules:

  • File permissions: 0600 (owner read/write only)
  • Never commit to version control
  • Store outside any Git repository
  • For team setups, distribute via a secrets manager, not a shared file

login_customer_id is the numeric MCC ID without dashes. It tells the API which manager account's Developer Token to use. The target customer_id is passed at call time, not in the config.

use_proto_plus: true enables the protobuf-plus interface, which is required for current API versions.

Step 4: Python Client Initialization

from google.ads.googleads.client import GoogleAdsClient

client = GoogleAdsClient.load_from_storage(
    '/path/to/google-ads.yaml',
    version='v22'
)
gs = client.get_service('GoogleAdsService')

The version parameter explicitly selects the API version. The google-ads library ships with multiple versions installed in parallel (v19 through v22 as of June 2026). Always specify the version — the default may lag behind or not support newer fields.

Verify connectivity:

cs = client.get_service('CustomerService')
customers = cs.list_accessible_customers()
ids = [r.split('/')[-1] for r in customers.resource_names]
# Example output: ['1305475941', '3977581086', '1911599764']

Step 5: Read Operations (GAQL)

Google Ads uses GAQL (Google Ads Query Language), a SQL-like syntax. All read operations go through GoogleAdsService.search_stream(), which avoids pagination and is preferred over search() for most queries.

Campaigns with Performance Metrics

SELECT
  campaign.id,
  campaign.name,
  campaign.status,
  campaign.advertising_channel_type,
  campaign.serving_status,
  campaign.bidding_strategy_type,
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.conversions,
  metrics.conversions_value,
  metrics.ctr,
  metrics.average_cpc
FROM campaign
ORDER BY campaign.id

Campaign Budgets

SELECT
  campaign_budget.id,
  campaign_budget.name,
  campaign_budget.amount_micros,
  campaign_budget.delivery_method,
  campaign_budget.status,
  campaign.id,
  campaign.name
FROM campaign_budget

Ad Groups

SELECT
  ad_group.id,
  ad_group.name,
  ad_group.status,
  ad_group.cpc_bid_micros,
  campaign.id,
  campaign.name
FROM ad_group
ORDER BY campaign.id, ad_group.id

Keywords (by Impressions)

SELECT
  ad_group_criterion.keyword.text,
  ad_group_criterion.keyword.match_type,
  ad_group_criterion.status,
  ad_group_criterion.criterion_id,
  ad_group.id,
  ad_group.name,
  campaign.id,
  campaign.name,
  metrics.impressions,
  metrics.clicks,
  metrics.cost_micros,
  metrics.conversions
FROM keyword_view
WHERE ad_group_criterion.type = 'KEYWORD'
ORDER BY metrics.impressions DESC
LIMIT 100

Ads with Policy Status

SELECT
  ad_group_ad.ad.id,
  ad_group_ad.ad.name,
  ad_group_ad.ad.type,
  ad_group_ad.status,
  ad_group_ad.policy_summary.approval_status,
  ad_group.id,
  ad_group.name,
  campaign.id,
  campaign.name
FROM ad_group_ad

Conversion Actions

SELECT
  conversion_action.id,
  conversion_action.name,
  conversion_action.status,
  conversion_action.type,
  conversion_action.category,
  conversion_action.include_in_conversions_metric,
  conversion_action.counting_type
FROM conversion_action

Python Execution Pattern

def gaql_query(client, customer_id, query):
    gs = client.get_service('GoogleAdsService')
    results = []
    for batch in gs.search_stream(customer_id=customer_id, query=query):
        for row in batch.results:
            results.append(row)
    return results

Step 6: Write Operations with `validate_only`

The v22 API requires request objects, not keyword arguments. Every mutation service (CampaignService, AdGroupService, AdGroupCriterionService, CampaignBudgetService) follows the same pattern:

from google.protobuf import field_mask_pb2

req = client.get_type('MutateCampaignsRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('CampaignOperation')
op.create.name = 'Campaign Name'
op.create.status = client.enums.CampaignStatusEnum.PAUSED
op.create.advertising_channel_type = client.enums.AdvertisingChannelTypeEnum.SEARCH
op.create.campaign_budget = 'customers/1305475941/campaignBudgets/123456789'
op.create.manual_cpc.enhanced_cpc_enabled = False
req.operations.append(op)  # .append(), not .add()
client.get_service('CampaignService').mutate_campaigns(request=req)

Key differences from older API versions:

  • validate_only is a field on the request object, not a keyword argument to the mutate method
  • Operations are appended via req.operations.append(op), not req.operations.add(op)
  • Resource paths are explicit strings ('customers/1305475941/campaigns/22479990461'), not helper method return values

Three-Phase Mutation Gate

For production safety, mutations pass through three gates:

Gate 1 — validate_only=True: The API validates the request structure, required fields, and resource references. No object is created. Run this first for every mutation.

Gate 2 — Paused Creation: Set validate_only=False and create the object with status=PAUSED. Verify the result with a read query before proceeding.

Gate 3 — Live Enable: A separate API call that updates only the status field to ENABLED. This should require explicit approval, not be folded into creation.

# Gate 3: enable a previously created paused campaign
req = client.get_type('MutateCampaignsRequest')
req.customer_id = '1305475941'
req.validate_only = False
op = client.get_type('CampaignOperation')
op.update.resource_name = 'customers/1305475941/campaigns/22479990461'
op.update.status = client.enums.CampaignStatusEnum.ENABLED
op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=['status']))
req.operations.append(op)
client.get_service('CampaignService').mutate_campaigns(request=req)

Budget Update

req = client.get_type('MutateCampaignBudgetsRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('CampaignBudgetOperation')
op.update.resource_name = 'customers/1305475941/campaignBudgets/123456789'
op.update.amount_micros = 1_000_000  # $1.00 per day
op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=['amount_micros']))
req.operations.append(op)
client.get_service('CampaignBudgetService').mutate_campaign_budgets(request=req)

Ad Group Bid Update

req = client.get_type('MutateAdGroupsRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('AdGroupOperation')
op.update.resource_name = 'customers/1305475941/adGroups/165827073530'
op.update.cpc_bid_micros = 250_000  # $0.25
op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=['cpc_bid_micros']))
req.operations.append(op)
client.get_service('AdGroupService').mutate_ad_groups(request=req)

Keyword Creation

req = client.get_type('MutateAdGroupCriteriaRequest')
req.customer_id = '1305475941'
req.validate_only = True
op = client.get_type('AdGroupCriterionOperation')
op.create.ad_group = 'customers/1305475941/adGroups/165827073530'
op.create.status = client.enums.AdGroupCriterionStatusEnum.PAUSED
op.create.keyword.text = 'example keyword'
op.create.keyword.match_type = client.enums.KeywordMatchTypeEnum.EXACT
req.operations.append(op)
client.get_service('AdGroupCriterionService').mutate_ad_group_criteria(request=req)

Error Handling

from google.ads.googleads.errors import GoogleAdsException

try:
    response = service.mutate_campaigns(request=req)
except GoogleAdsException as ex:
    for error in ex.failure.errors:
        print(f'{error.error_code}: {error.message}')

Common error codes and their meaning:

Error CodeCause
`RESOURCE_NOT_FOUND`Test Access token against a production account (expected) or genuinely missing resource
`REQUIRED`Missing mandatory field (e.g. bidding strategy on campaign creation)
`UNRECOGNIZED_FIELD`Field from a different API version or incorrect field name
`INVALID_ARGUMENT`Field value fails validation (e.g. negative budget)
`PERMISSION_DENIED`OAuth user lacks access to the target account

API Access Levels

Google Ads API access is tiered, not binary:

LevelProvisioningRead (Production)Write (Production)Write (Test Accounts)Daily Ops Limit
**Test Access**Instant from MCC API Center15,000
**Basic Access**Application + Compliance review15,000
**Standard Access**Spending threshold + reviewUnlimited

Test Access is sufficient for development: authentication, GAQL queries, validate_only testing, and full read/write against Google Ads test accounts. The jump to Basic Access is gated by compliance review, not by spend.

Standard Access removes the daily operation cap. It requires either a track record of spend managed through the token or a separate application.

Test Accounts

Test accounts are free Google Ads accounts with no billing. They accept any mutation. To create one: in the MCC, go to Accounts → + → Create new account → Test account. Link it to the MCC and use it as the target customer_id during Test-Access development.

`validate_only` Across Access Levels

validate_only=True works at all access levels, but Test Access still rejects validate_only mutations against production accounts with RESOURCE_NOT_FOUND. This is not a validate_only limitation — it is the same access control applied to all write operations regardless of the validate_only flag.

Conversion Tracking: API Read vs. Functional Status

The Google Ads UI may show "Set up conversion tracking" as a recommendation even when a conversion action exists and is enabled. The API can confirm the existence of a conversion action (type WEBPAGE, category PURCHASE, status ENABLED, include_in_conversions_metric: true), but this only proves the action object exists. It does not prove that the website emits the corresponding AW-.../label event, that the Google tag is loaded, or that a conversion signal has been received.

When auditing conversion tracking, separate the API check (conversion action metadata) from the browser check (network tab, tag loading, event firing). The API is the source of truth for configuration; the browser is the source of truth for execution.

The pattern for backend revenue attribution applies here: if client-side conversion events are unreliable, plan for server-side or offline conversion uploads via the Google Ads API ConversionUploadService as a fallback.

API Versioning

The google-ads Python library ships with multiple API versions. As of June 2026, v19 through v22 are available. Each version adds, deprecates, or removes GAQL fields and service methods.

Rules for version selection:

  • Specify the version explicitly in load_from_storage(version='v22'). The default may be outdated.
  • Check the Google Ads API release notes for field deprecations before upgrading.
  • The search_stream method is available from v6+ and is the recommended read path.
  • Protobuf-plus (use_proto_plus: true) is required for v12+.
  • The request-object pattern for mutations (mutate_campaigns(request=req)) replaced keyword-argument style in v17+.

Local Security

All credential files must stay outside version control:

~/.hermes/
  google-ads.yaml          # 0600 — dev token, OAuth client, refresh token
  google_ads_token.json    # 0600 — OAuth tokens, adwords scope
  google_client_secret.json # 0600 — OAuth client ID + secret

For the working repository, add a .gitignore:

google-ads.yaml
*_token*.json
*_secret*.json

A template file with placeholder values can be committed to document the expected structure:

# google-ads.yaml.template — commit this, fill locally
developer_token: INSERT_DEV_TOKEN
client_id: INSERT_CLIENT_ID
client_secret: INSERT_CLIENT_SECRET
refresh_token: INSERT_REFRESH_TOKEN
login_customer_id: INSERT_MCC_ID
use_proto_plus: true

Required Fields for Common Operations

OperationRequired Fields
Create Campaign (Search)`name`, `status`, `advertising_channel_type`, `campaign_budget`, `manual_cpc` or bidding strategy
Create Ad Group (Search)`name`, `status`, `type_`, `cpc_bid_micros`, `campaign`
Create Keyword`ad_group`, `status`, `keyword.text`, `keyword.match_type`
Update Campaign Status`resource_name`, `status`, `update_mask` (paths: `['status']`)
Update Budget`resource_name`, `amount_micros`, `update_mask` (paths: `['amount_micros']`)

Missing a required field returns REQUIRED with the field name. Missing update_mask on an update operation is silently ignored — the field is not updated and no error is returned.

Complete Test Script

A single script that reads all entity types and runs validate_only write tests:

# google_ads_api_test.py — read all entities + validate_only write tests
import warnings
warnings.filterwarnings('ignore')
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
from google.protobuf import field_mask_pb2

CFG = '/path/to/google-ads.yaml'
CID = '1305475941'
c = GoogleAdsClient.load_from_storage(CFG, version='v22')
gs = c.get_service('GoogleAdsService')

def gaql(query, label):
    print(f'\n=== {label} ===')
    n = 0
    for batch in gs.search_stream(customer_id=CID, query=query):
        for row in batch.results:
            n += 1
    print(f'{n} rows')

# Read all entities
gaql("SELECT customer.id, customer.descriptive_name FROM customer WHERE customer.id = ...", "CUSTOMER")
gaql("SELECT campaign.id, campaign.name, campaign.status, ...  FROM campaign", "CAMPAIGNS")
gaql("SELECT campaign_budget.id, campaign_budget.name, ...  FROM campaign_budget", "BUDGETS")
gaql("SELECT ad_group.id, ad_group.name, ...  FROM ad_group", "AD GROUPS")
gaql("SELECT ad_group_criterion.keyword.text, ...  FROM keyword_view WHERE ...", "KEYWORDS")
gaql("SELECT ad_group_ad.ad.id, ...  FROM ad_group_ad", "ADS")
gaql("SELECT conversion_action.id, ...  FROM conversion_action", "CONVERSIONS")

# Write tests
def test_write(name, fn):
    print(f'\n--- {name} ---')
    try:
        fn()
        print('PASS')
    except GoogleAdsException as e:
        for err in e.failure.errors:
            print(f'{err.error_code}: {err.message}')

def test_create_campaign():
    req = c.get_type('MutateCampaignsRequest')
    req.customer_id = CID
    req.validate_only = True
    op = c.get_type('CampaignOperation')
    op.create.name = 'TEST_CAMPAIGN'
    op.create.status = c.enums.CampaignStatusEnum.PAUSED
    op.create.advertising_channel_type = c.enums.AdvertisingChannelTypeEnum.SEARCH
    op.create.campaign_budget = f'customers/{CID}/campaignBudgets/123456789'
    op.create.manual_cpc.enhanced_cpc_enabled = False
    req.operations.append(op)
    c.get_service('CampaignService').mutate_campaigns(request=req)

test_write('Create Campaign (validate_only)', test_create_campaign)
print('\nDone.')

Full executable version at the Google Ads API operations skill in the advertising operations repository.

References

Further Reading