Google Ads Experiments = A/B Testing via API
Core Principle: Create Google Ads experiments through API proxy, supporting any change type (landing pages, ad copy, keywords, bids) with mandatory validation before mutations.
Experiment Lifecycle (Occurrent - LR):
graph LR
A[Gather params] --> B[Find campaign]
B --> C[Get ad groups + ads]
C --> D[Show current state]
D --> E[Create experiment]
E --> F[Create arms]
F --> G[Schedule]
G --> H[Modify trial]
H --> I[Confirm + summary]
Ontological Rule: LR for experiment creation process
Primary source: Verified Google Ads API v20 access via Improvado Discovery proxy
Session: 47a7a7ef-de8f-4125-898a-8aabc634086c by Konstantin Govorkov (2026-02-23)
Updated: 908be4dd-f1c2-43ac-93c4-6a318e3975c4 — v1.2.0: new RSA ads approach, experiment deletion, async provisioning (2026-02-24)
1. API Access
All calls via mcp__improvado-ai-internal__discoveryRequestTool:
dataSource:google_ads_qlconnectorId:1803(must be string, NOT integer)- Customer ID:
9521562011 - Base URL:
https://googleads.googleapis.com/v20/customers/9521562011
CRITICAL — Discovery API request keys:
request.params→ URL query parameters (use for GAQL queries viasearchStream)request.json→ POST body payload (use for ALL mutations: experiments, arms, ads)- NEVER use
request.paramsfor mutation bodies — causes "Parameters can only be bound to primitive types" error
2. Parameters to Collect
If user provides context, use it. If not, ask for:
| Param | Required | Default | Description |
|-------|----------|---------|-------------|
| campaign | yes | — | Name or ID |
| change_type | yes | — | landing_pages / ad_copy / keywords / bids / mixed |
| changes | yes | — | Map of ad_group → new values |
| split | no | 50 | Traffic % for treatment arm |
| start_date | no | tomorrow | YYYY-MM-DD |
| end_date | no | +30 days | YYYY-MM-DD |
| goal_metric | no | CLICKS | CLICKS / CONVERSIONS / COST_PER_CONVERSION |
| goal_direction | no | INCREASE | INCREASE / DECREASE / NO_CHANGE_OR_INCREASE |
| name | no | auto-gen | Experiment name |
3. Execution Steps
Step 1: Find campaign
POST {base}/googleAds:searchStream
{"query": "SELECT campaign.id, campaign.name, campaign.status FROM campaign WHERE campaign.name = '{name}'"}
Step 2: Get ad groups
{"query": "SELECT ad_group.id, ad_group.name, ad_group.status FROM ad_group WHERE campaign.id = {id} AND ad_group.status = 'ENABLED'"}
Step 3: Get current ads (for LP/copy experiments)
{"query": "SELECT ad_group.id, ad_group.name, ad_group_ad.ad.id, ad_group_ad.ad.final_urls, ad_group_ad.ad.responsive_search_ad.headlines, ad_group_ad.ad.responsive_search_ad.descriptions FROM ad_group_ad WHERE campaign.id = {id} AND ad_group.status = 'ENABLED' AND ad_group_ad.status = 'ENABLED'"}
Step 4: Show diff table to user
Display current vs proposed changes. Example for LP test:
| Ad Group | Current LP | New LP |
|----------|-----------|--------|
| supermetrics | improvado.io/vs | improvado.io/vs/supermetrics |
WAIT FOR USER CONFIRMATION before proceeding.
Step 5: Create experiment (always validateOnly: true first)
POST {base}/experiments:mutate
{
"operations": [{
"create": {
"name": "{name}",
"description": "{description}",
"suffix": "[Experiment]",
"type": "SEARCH_CUSTOM",
"status": "SETUP",
"startDate": "{start}",
"endDate": "{end}",
"goals": [{"metric": "{goal_metric}", "direction": "{goal_direction}"}]
}
}],
"validateOnly": true
}
If validation passes, repeat without validateOnly.
Step 6: Create experiment arms
CRITICAL: Both arms MUST have trafficSplit (NOT trafficSplitPercent). Without it, scheduleExperiment fails with fieldError: REQUIRED.
POST {base}/experimentArms:mutate
{
"operations": [
{
"create": {
"experiment": "customers/9521562011/experiments/{exp_id}",
"campaigns": ["customers/9521562011/campaigns/{campaign_id}"],
"control": true,
"name": "Control",
"trafficSplit": {100 - split}
}
},
{
"create": {
"experiment": "customers/9521562011/experiments/{exp_id}",
"control": false,
"name": "Treatment",
"trafficSplit": {split}
}
}
]
}
Field name mapping: Python SDK traffic_split → REST API trafficSplit (camelCase).
~~trafficSplitPercent~~ is INVALID and will be silently ignored, causing schedule to fail later.
Step 7: Schedule experiment (creates trial campaign)
POST {base}/experiments/{exp_id}:scheduleExperiment
Step 8: Apply changes to trial campaign
After scheduling, Google creates a trial campaign. Query it:
{"query": "SELECT campaign.id, campaign.name FROM campaign WHERE campaign.base_campaign = 'customers/9521562011/campaigns/{campaign_id}' AND campaign.status = 'ENABLED'"}
IMPORTANT — Async provisioning: Trial campaign ads are copied asynchronously. After scheduling, wait 30-60 seconds and verify all expected ads exist before modifying. First query may return partial results.
Then apply changes based on change_type:
Landing pages (NEW RSA ADS approach — RECOMMENDED):
RSA ads have IMMUTABLE final_urls — they cannot be updated via API UPDATE operation, and keyword-level URL overrides are invisible in Google Ads UI. Instead, use the new ads approach:
Step 8a: Get existing ads in trial campaign:
SELECT ad_group.id, ad_group.name, ad_group_ad.ad.id, ad_group_ad.ad.final_urls,
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
ad_group_ad.ad.responsive_search_ad.path1,
ad_group_ad.ad.responsive_search_ad.path2
FROM ad_group_ad
WHERE campaign.id = {trial_campaign_id}
AND ad_group.status = 'ENABLED'
AND ad_group_ad.status = 'ENABLED'
Step 8b: For each target ad group, PAUSE old ads:
POST {base}/googleAds:mutate
{
"mutateOperations": [{
"adGroupAdOperation": {
"update": {
"resourceName": "customers/9521562011/adGroupAds/{trial_ag_id}~{ad_id}",
"status": "PAUSED"
},
"updateMask": "status"
}
}]
}
Step 8c: CREATE new RSA ads with correct URLs:
POST {base}/googleAds:mutate
{
"mutateOperations": [{
"adGroupAdOperation": {
"create": {
"adGroup": "customers/9521562011/adGroups/{trial_ag_id}",
"status": "ENABLED",
"ad": {
"finalUrls": ["https://{new_url}"],
"responsiveSearchAd": {
"headlines": [{"text": "...", "pinnedField": "HEADLINE_1"}, ...],
"descriptions": [{"text": "...", "pinnedField": "DESCRIPTION_1"}, ...],
"path1": "{path1}",
"path2": "{path2}"
}
}
}
}
}]
}
CRITICAL: When copying headlines/descriptions from existing ads, remove any "pinnedField": "UNSPECIFIED" entries — only include pinnedField when it has a real value (HEADLINE_1, HEADLINE_2, etc.).
Landing pages (keyword-level URL override — ALTERNATIVE):
Sets finalUrls at keyword level. Works technically but invisible in Google Ads UI Ads tab. Only use if team doesn't need to verify URLs in UI. See v1.1.0 SKILL.md for details.
Ad copy: Mutate RSA headlines/descriptions in trial Keywords: Add/remove/pause keywords in trial ad groups Bids: Change bid strategy or bid amounts in trial
Step 9: Show summary
Experiment: {name}
Campaign: {campaign_name} (ID: {id})
Type: {change_type}
Split: {split}% treatment / {100-split}% control
Period: {start} → {end}
Goal: {goal_metric} {goal_direction}
Changes: {count} ad groups modified
Trial campaign ID: {trial_id}
4. Safety Rules
- ALWAYS
validateOnly: truebefore real mutations - ALWAYS show diff table and get user confirmation
- NEVER mutate without explicit approval
- NEVER modify the original campaign — only the trial
- If any step fails, stop and report error with full API response
5. Querying Experiment Results
To check running experiment performance:
{"query": "SELECT campaign.name, campaign.experiment_type, metrics.clicks, metrics.impressions, metrics.conversions, metrics.cost_micros FROM campaign WHERE campaign.base_campaign = 'customers/9521562011/campaigns/{campaign_id}' AND segments.date DURING LAST_7_DAYS"}
6. Managing Experiments
List experiments:
{"query": "SELECT campaign.id, campaign.name, campaign.experiment_type, campaign.status FROM campaign WHERE campaign.experiment_type != 'UNSPECIFIED'"}
End experiment early: Promote winner or end without applying
POST {base}/experiments/{exp_id}:promoteExperiment
POST {base}/experiments/{exp_id}:endExperiment
Delete experiment (if endExperiment returns "error code not in this version"):
POST {base}/experiments:mutate
{
"operations": [{
"remove": "customers/9521562011/experiments/{exp_id}"
}]
}
IMPORTANT: Before creating a new experiment on the same campaign, delete or end any existing experiment with overlapping dates. Otherwise arms creation fails with OVERLAPPING_MEMBERS_AND_DATE_RANGE.