Scenario
Google assembles RSA headlines and descriptions dynamically, but you only see aggregate performance — not which combinations resonate with which audiences. You pull individual RSA asset performance ratings, extract the top 5 headline/description combos, then run them through a Mavera Focus Group. The result tells you which combinations to pin and which to replace, broken down by persona.Architecture
Code
import os, requests, time
from google.ads.googleads.client import GoogleAdsClient
MV = os.environ["MAVERA_API_KEY"]
CUSTOMER_ID = os.environ["GOOGLE_ADS_CUSTOMER_ID"]
MB = "https://app.mavera.io/api/v1"
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
client = GoogleAdsClient.load_from_env()
ga_service = client.get_service("GoogleAdsService")
query = """
SELECT
ad_group_ad.ad.id,
ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
metrics.impressions,
metrics.conversions,
metrics.ctr
FROM ad_group_ad
WHERE ad_group_ad.ad.type = RESPONSIVE_SEARCH_AD
AND segments.date DURING LAST_30_DAYS
AND metrics.impressions > 500
ORDER BY metrics.impressions DESC
LIMIT 5
"""
response = ga_service.search(customer_id=CUSTOMER_ID, query=query)
combos = []
for row in response:
rsa = row.ad_group_ad.ad.responsive_search_ad
best_headlines = [
a.text for a in rsa.headlines
if a.performance_label and a.performance_label.name in ("BEST", "GOOD")
][:3] or [a.text for a in rsa.headlines[:3]]
best_descs = [
a.text for a in rsa.descriptions
if a.performance_label and a.performance_label.name in ("BEST", "GOOD")
][:2] or [a.text for a in rsa.descriptions[:2]]
combos.append({
"ad_id": row.ad_group_ad.ad.id,
"headlines": best_headlines,
"descriptions": best_descs,
"impressions": row.metrics.impressions,
"ctr": row.metrics.ctr,
"conversions": row.metrics.conversions,
"all_headlines": [{"text": a.text, "perf": a.performance_label.name if a.performance_label else "UNKNOWN"} for a in rsa.headlines],
})
PERSONA_IDS = os.environ.get("RSA_PERSONA_IDS", "").split(",")
if not PERSONA_IDS[0]:
for name, desc in [
("Startup Founder", "CEO/Founder of a 10-50 person startup. Time-poor, skeptical of marketing claims."),
("Marketing Director", "Director of Marketing at mid-market B2B. Manages team of 5-10. Accountable for pipeline."),
("Procurement Manager", "Procurement lead at enterprise. Risk-averse, process-oriented, price-sensitive."),
]:
p = requests.post(f"{MB}/personas", headers=MH, json={"name": name, "description": desc}).json()
PERSONA_IDS.append(p["id"])
time.sleep(0.3)
combo_block = "\n\n".join(
f"Combo {i+1} (CTR: {c['ctr']:.2%}, Conv: {c['conversions']:.0f}):\n"
f" Headlines: {' | '.join(c['headlines'])}\n"
f" Descriptions: {' | '.join(c['descriptions'])}"
for i, c in enumerate(combos)
)
fg = requests.post(f"{MB}/focus-groups", headers=MH, json={
"name": "RSA Headline/Description Testing",
"persona_ids": [pid for pid in PERSONA_IDS if pid],
"questions": [
f"Here are 5 ad combinations:\n\n{combo_block}\n\nRank them 1-5 and explain your top pick.",
"Which headline would stop your scroll? What makes it stand out?",
"Which description would convince you to click? Why?",
"Rewrite the weakest headline to make it compelling for someone like you.",
],
"context": "These are Google Search ads for a B2B marketing platform.",
"responses_per_persona": 2,
}).json()
for _ in range(20):
time.sleep(5)
data = requests.get(f"{MB}/focus-groups/{fg['id']}", headers=MH).json()
if data.get("status") == "completed":
break
print(f"Focus Group: {data.get('id')}")
for resp in data.get("responses", []):
print(f"\n[{resp.get('persona_id','?')}] {resp.get('question','')[:60]}")
print(f" → {resp.get('answer','')[:300]}")
print("\n--- Asset Performance Summary ---")
for c in combos:
for h in c["all_headlines"]:
print(f" [{h['perf']:8s}] {h['text']}")
const DEV_TOKEN = process.env.GOOGLE_ADS_DEVELOPER_TOKEN;
const ACCESS_TOKEN = process.env.GOOGLE_ADS_ACCESS_TOKEN;
const CUSTOMER_ID = process.env.GOOGLE_ADS_CUSTOMER_ID;
const MV = process.env.MAVERA_API_KEY;
const MB = "https://app.mavera.io/api/v1";
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const gaql = `
SELECT ad_group_ad.ad.id, ad_group_ad.ad.responsive_search_ad.headlines,
ad_group_ad.ad.responsive_search_ad.descriptions,
metrics.impressions, metrics.conversions, metrics.ctr
FROM ad_group_ad
WHERE ad_group_ad.ad.type = RESPONSIVE_SEARCH_AD
AND segments.date DURING LAST_30_DAYS AND metrics.impressions > 500
ORDER BY metrics.impressions DESC LIMIT 5`;
const gaRes = await fetch(
`https://googleads.googleapis.com/v23/customers/${CUSTOMER_ID}/googleAds:searchStream`,
{
method: "POST",
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "developer-token": DEV_TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({ query: gaql }),
}
).then((r) => r.json());
const combos = (gaRes[0]?.results || []).map((row) => {
const rsa = row.adGroupAd.ad.responsiveSearchAd;
const bestH = (rsa.headlines || []).filter((h) => ["BEST", "GOOD"].includes(h.performanceLabel)).slice(0, 3);
const bestD = (rsa.descriptions || []).filter((d) => ["BEST", "GOOD"].includes(d.performanceLabel)).slice(0, 2);
return {
headlines: (bestH.length ? bestH : (rsa.headlines || []).slice(0, 3)).map((h) => h.text),
descriptions: (bestD.length ? bestD : (rsa.descriptions || []).slice(0, 2)).map((d) => d.text),
impressions: parseInt(row.metrics.impressions),
ctr: parseFloat(row.metrics.ctr),
conversions: parseFloat(row.metrics.conversions),
allHeadlines: (rsa.headlines || []).map((h) => ({ text: h.text, perf: h.performanceLabel || "UNKNOWN" })),
};
});
const personaIds = [];
for (const [name, desc] of [
["Startup Founder", "CEO at 10-50 person startup. Time-poor, skeptical."],
["Marketing Director", "Director at mid-market B2B. Manages team, pipeline-accountable."],
["Procurement Manager", "Enterprise procurement. Risk-averse, price-sensitive."],
]) {
const p = await fetch(`${MB}/personas`, { method: "POST", headers: MH,
body: JSON.stringify({ name, description: desc }) }).then((r) => r.json());
personaIds.push(p.id);
await new Promise((r) => setTimeout(r, 300));
}
const comboBlock = combos.map((c, i) =>
`Combo ${i + 1} (CTR: ${(c.ctr * 100).toFixed(1)}%, Conv: ${c.conversions.toFixed(0)}):\n Headlines: ${c.headlines.join(" | ")}\n Descriptions: ${c.descriptions.join(" | ")}`
).join("\n\n");
const fg = await fetch(`${MB}/focus-groups`, { method: "POST", headers: MH,
body: JSON.stringify({
name: "RSA Testing", persona_ids: personaIds,
questions: [
`Rank these ad combos 1-5:\n\n${comboBlock}`,
"Which headline stops your scroll?",
"Which description convinces you to click?",
"Rewrite the weakest headline for someone like you.",
],
context: "Google Search ads for a B2B marketing platform.",
responses_per_persona: 2,
}),
}).then((r) => r.json());
let data;
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 5000));
data = await fetch(`${MB}/focus-groups/${fg.id}`, { headers: MH }).then((r) => r.json());
if (data.status === "completed") break;
}
for (const resp of data.responses || []) {
console.log(`\n[${resp.persona_id}] ${(resp.question || "").slice(0, 60)}`);
console.log(` → ${(resp.answer || "").slice(0, 300)}`);
}
console.log("\n--- Asset Performance ---");
combos.forEach((c) => c.allHeadlines.forEach((h) => console.log(` [${h.perf.padEnd(8)}] ${h.text}`)));
Example Output
[Startup Founder] Rank these ad combos 1-5
→ #1: "Launch in 15 Minutes — No Demo Required" — I don't have time for
demos. Self-serve or I'm gone. #5: "Enterprise-Grade Marketing Platform"
— I'm not enterprise. That language pushes me away.
[Procurement Manager] Which description convinces you to click?
→ "SOC 2 certified. No annual commitment." — Addresses my two biggest
concerns in 6 words. The ones with percentage claims feel unverifiable.
--- Asset Performance ---
[BEST ] Launch in 15 Minutes
[GOOD ] Cut Reporting Time 60%
[LOW ] Best Marketing Platform
[UNKNOWN ] Enterprise-Grade Solution
Error Handling
Performance labels unavailable
Performance labels unavailable
Asset performance labels (
BEST, GOOD, LOW) require ~5,000 impressions per ad. New ads show UNKNOWN or LEARNING. Filter for ads with sufficient volume.Pinned assets skew results
Pinned assets skew results
Pinned headlines always appear in position 1/2/3, inflating their performance. Note pinning status (
pinned_field) when interpreting results.All Google Ads jobs
View all 7 Google Ads integration jobs
Focus Groups API
Full reference for POST /api/v1/focus-groups