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:
| Entity | Purpose | Example |
|---|---|---|
| Manager Account (MCC) | Issues the Developer Token | login_customer_id |
| Google Cloud Project | OAuth 2.0 client for authentication | Client 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-adslibrary (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:
| Question | Answer |
|---|---|
| Q4: Relationship with a Google Representative | No |
| Q6: Business Model | Describe your own use case (e-commerce, lead gen, internal reporting) |
| Q8: Who will have access | Internal users — employees only |
| Q9: Third-party developed tool | No (own scripts/agents) |
| Q10: Conversion Tracking & Remarketing API | No (unless you actually use it) |
| Q11: Campaign Types | Search, Performance Max, Shopping (as applicable) |
| Q12: Capabilities | Campaign 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:
- Enable the Google Ads API (APIs & Services → Library)
- 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
- Add the scope under Data Access:
https://www.googleapis.com/auth/adwords - 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 tokenprompt='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_onlyis 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 ascampaign_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 Code | Cause |
|---|---|
RESOURCE_NOT_FOUND | Referenced resource does not exist, was removed, or has a malformed resource name |
DEVELOPER_TOKEN_NOT_APPROVED | Test Access token against a production account (CLOUD_PROJECT_NOT_APPROVED_FOR_PRODUCTION since the September 2026 sunset) |
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:
| Level | Provisioning | Read (Production) | Write (Production) | Write (Test Accounts) | Daily Ops Limit |
|---|---|---|---|---|---|
| Test Access | Instant (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 Access | Application + Compliance review | ✅ | ✅ | ✅ | 15,000 |
| Standard Access | Application 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_streammethod is available in all current versions and is the recommended read path. use_proto_plusmust be set explicitly (trueorfalse); 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 asvalidate_only; Google’s current examples still passcustomer_idandoperationsas 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
| Operation | Required 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 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 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
- Google Ads API Documentation
- google-ads Python Library
- GAQL Reference
- OAuth 2.0 for Desktop Apps
- Building a Project-Specific AI Assistant via Telegram — same OAuth pattern applied to a different Google scope
- Reference Architecture for a Thread-Based AI Operations Layer — the operational model that API automation plugs into
- Solo Operations at Scale: Managing Dozens of Projects with a Small Team — why API automation replaces manual UI work at scale