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.

Update (October 2026): Google sunset developer tokens on September 9, 2026. API access levels are now assigned per Google Cloud project and managed on the Google Ads API Overview page in the Cloud Console, the developer-token header is optional and ignored, and API Center no longer handles signup or access changes. Google says existing code keeps working. The developer-token and API Center steps below describe the setup as published in June 2026. The current setup is covered in our follow-up, Google Ads API Access in 2026: Cloud Projects Replace Developer Tokens.

Architecture

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

EntityPurposeExample
Manager Account (MCC)Issues the Developer Tokenlogin_customer_id
Google Cloud ProjectOAuth 2.0 client for authenticationClient ID + Secret
Google Ads AccountTarget of all API operationscustomer_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 test accounts only
  • Write operations against test accounts only
  • No read or write operations against production accounts

Requests against production accounts with Test Access fail with DEVELOPER_TOKEN_NOT_APPROVED (“The developer token is only approved for use with test accounts”). Since the September 2026 sunset, current API versions return CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION instead (ACTION_NOT_PERMITTED in v24 and earlier).

Basic Access Application

To access 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 minimal Python script (it does not verify the returned state parameter or set file permissions on the token file; add both before using it beyond a one-off local run) 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 has no fixed lifetime, but it stops working if the user revokes access, it goes unused for six months, the user changes their password (for tokens with Gmail scopes), the account exceeds Google’s limit on live refresh tokens, or a Workspace admin restricts the scope or enforces a session length. Plan for re-authorization.

Scope Pitfall with Existing Clients

If the authorization URL sets include_granted_scopes=true and the user previously granted this OAuth client 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, leave include_granted_scopes=true out of the authorization URL (the script above does not set it), so the token covers only the explicitly requested 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 the authenticated user accesses the target account through, and it is required whenever access goes through a manager account. The target customer_id is passed at call time, not in the config.

use_proto_plus is a required configuration key: true selects the proto-plus message interface used in this guide, false selects plain protobuf messages, which Google recommends for performance-sensitive work.

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; the examples here use v22. As of October 2026, current library releases no longer include v22 (removed in 33.0.0) and 34.0.0 adds v26, so substitute a current version. 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 examples below use request objects rather than keyword arguments, because the request object carries validate_only. 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 points of this pattern (with use_proto_plus: true):

  • 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); .add() belongs to the plain-protobuf interface (use_proto_plus: false)
  • Resource paths are written as explicit strings here ('customers/1305475941/campaigns/22479990461'); the services also provide path helpers such as campaign_service.campaign_path(customer_id, campaign_id), which Google recommends over hand-built names

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 meanings:

Error CodeCause
RESOURCE_NOT_FOUNDReferenced resource does not exist, was removed, or has a malformed resource name
DEVELOPER_TOKEN_NOT_APPROVEDTest Access token against a production account (CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION since the September 2026 sunset)
REQUIREDMissing mandatory field (e.g. bidding strategy on campaign creation)
UNRECOGNIZED_FIELDField from a different API version or incorrect field name
INVALID_ARGUMENTField value fails validation (e.g. negative budget)
PERMISSION_DENIEDOAuth 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 AccessInstant (from MCC API Center until September 2026; now automatic when the API is enabled in the Cloud project)❌❌✅15,000
Explorer Access (listed as of October 2026)Application on the Cloud Console Overview page✅✅ (some services restricted)✅2,880 production / 15,000 test
Basic AccessApplication + Compliance review✅✅✅15,000
Standard AccessApplication after Basic + manual audit✅✅✅Unlimited

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

Standard Access removes the daily operation cap (per-feature rate limits still apply). It requires a separate application, available only after Basic Access, and a manual audit; Google’s access-level documentation names no spend threshold.

Test Accounts

Test accounts are free Google Ads accounts with no billing that never serve ads. Test and production accounts cannot interact, so a test account cannot sit under your production MCC: create a separate test manager account while signed in with a Google Account that is not linked to your production MCC, create test client accounts under it, and use one as the target customer_id (with the test manager as login_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 the same authorization error as any other request. 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 October 2026, the latest release (34.0.0) adds v26, and v22 and older have been removed from the library. 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 in all current versions and is the recommended read path.
  • use_proto_plus must be set explicitly (true or false); it selects the Python message interface, not an API version.
  • The request-object pattern for mutations (mutate_campaigns(request=req)) carries request-level fields such as validate_only; Google’s current examples still pass customer_id and operations as keyword arguments for plain mutates.

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, contains_eu_political_advertising
Create Ad Group (Search)name, status, type_, cpc_bid_micros, campaign
Create Keywordad_group, status, keyword.text, keyword.match_type
Update Campaign Statusresource_name, status, update_mask (paths: ['status'])
Update Budgetresource_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 rejected with FieldMaskError.FIELD_MASK_MISSING.

Condensed Test Script

A condensed script (GAQL field lists and filters are abbreviated with ... and IDs are placeholders, so fill them in before running) 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.')

References

Further Reading