Scenario
Your Google Ads campaigns expose which age ranges and genders actually convert — not who you think your buyer is, but who Google proves is buying. You pullgender_view and age_range_view with conversion metrics, then create or update Mavera personas that match your real converting demographics. The result is a persona library calibrated to paid data.
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")
gender_query = """
SELECT
ad_group_criterion.gender.type,
metrics.impressions, metrics.clicks, metrics.conversions,
metrics.cost_micros, metrics.conversions_value
FROM gender_view
WHERE segments.date DURING LAST_90_DAYS
ORDER BY metrics.conversions DESC
"""
age_query = """
SELECT
ad_group_criterion.age_range.type,
metrics.impressions, metrics.clicks, metrics.conversions,
metrics.cost_micros, metrics.conversions_value
FROM age_range_view
WHERE segments.date DURING LAST_90_DAYS
ORDER BY metrics.conversions DESC
"""
gender_data = {}
for row in ga_service.search(customer_id=CUSTOMER_ID, query=gender_query):
g = row.ad_group_criterion.gender.type_.name
gender_data.setdefault(g, {"impressions": 0, "clicks": 0, "conversions": 0, "value": 0})
gender_data[g]["impressions"] += row.metrics.impressions
gender_data[g]["clicks"] += row.metrics.clicks
gender_data[g]["conversions"] += row.metrics.conversions
gender_data[g]["value"] += row.metrics.conversions_value
age_data = {}
for row in ga_service.search(customer_id=CUSTOMER_ID, query=age_query):
a = row.ad_group_criterion.age_range.type_.name
age_data.setdefault(a, {"impressions": 0, "clicks": 0, "conversions": 0, "value": 0})
age_data[a]["impressions"] += row.metrics.impressions
age_data[a]["clicks"] += row.metrics.clicks
age_data[a]["conversions"] += row.metrics.conversions
age_data[a]["value"] += row.metrics.conversions_value
total_conv = sum(d["conversions"] for d in age_data.values()) or 1
top_ages = sorted(age_data.items(), key=lambda x: x[1]["conversions"], reverse=True)[:3]
top_gender = sorted(gender_data.items(), key=lambda x: x[1]["conversions"], reverse=True)[:2]
created = []
for age_name, age_metrics in top_ages:
for gender_name, gender_metrics in top_gender:
conv_share = (age_metrics["conversions"] + gender_metrics["conversions"]) / (2 * total_conv)
if conv_share < 0.05:
continue
cpa = ((age_metrics.get("cost_micros", 0) or 0) / 1_000_000) / max(age_metrics["conversions"], 1)
persona = requests.post(f"{MB}/personas", headers=MH, json={
"name": f"Google Ads: {gender_name.replace('_', ' ').title()} {age_name.replace('AGE_RANGE_', '').replace('_', '-')}",
"description": (
f"Data-backed persona from Google Ads (last 90 days). "
f"Age: {age_name.replace('AGE_RANGE_', '').replace('_', '-')}. Gender: {gender_name}. "
f"Conversions: {age_metrics['conversions']:.0f} ({age_metrics['conversions']/total_conv:.0%} of total). "
f"CPA: ${cpa:.2f}. Conv value: ${age_metrics['value']:.0f}."
),
"demographic": {
"age_range": age_name.replace("AGE_RANGE_", "").replace("_", "-"),
"gender": gender_name.lower(),
},
}).json()
created.append({"name": persona.get("name"), "id": persona["id"]})
time.sleep(0.3)
print(f"Created {len(created)} demographic personas")
for p in created:
print(f" {p['name']} → {p['id']}")
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" };
async function gaQuery(query) {
const res = 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 }),
}
).then((r) => r.json());
return res[0]?.results || [];
}
const genderRows = await gaQuery(`
SELECT ad_group_criterion.gender.type, metrics.impressions, metrics.clicks,
metrics.conversions, metrics.cost_micros, metrics.conversions_value
FROM gender_view WHERE segments.date DURING LAST_90_DAYS ORDER BY metrics.conversions DESC`);
const ageRows = await gaQuery(`
SELECT ad_group_criterion.age_range.type, metrics.impressions, metrics.clicks,
metrics.conversions, metrics.cost_micros, metrics.conversions_value
FROM age_range_view WHERE segments.date DURING LAST_90_DAYS ORDER BY metrics.conversions DESC`);
function aggregate(rows, field) {
const data = {};
for (const row of rows) {
const key = field(row);
data[key] ??= { impressions: 0, clicks: 0, conversions: 0, value: 0 };
data[key].impressions += parseInt(row.metrics.impressions);
data[key].clicks += parseInt(row.metrics.clicks);
data[key].conversions += parseFloat(row.metrics.conversions);
data[key].value += parseFloat(row.metrics.conversionsValue || "0");
}
return data;
}
const genderData = aggregate(genderRows, (r) => r.adGroupCriterion.gender.type);
const ageData = aggregate(ageRows, (r) => r.adGroupCriterion.ageRange.type);
const totalConv = Object.values(ageData).reduce((s, d) => s + d.conversions, 0) || 1;
const topAges = Object.entries(ageData).sort(([, a], [, b]) => b.conversions - a.conversions).slice(0, 3);
const topGenders = Object.entries(genderData).sort(([, a], [, b]) => b.conversions - a.conversions).slice(0, 2);
const created = [];
for (const [ageName, ageM] of topAges) {
for (const [genderName, genderM] of topGenders) {
const convShare = (ageM.conversions + genderM.conversions) / (2 * totalConv);
if (convShare < 0.05) continue;
const label = `${genderName.replace(/_/g, " ")} ${ageName.replace("AGE_RANGE_", "").replace(/_/g, "-")}`;
const p = await fetch(`${MB}/personas`, {
method: "POST", headers: MH,
body: JSON.stringify({
name: `Google Ads: ${label}`,
description: `Data-backed persona (90d). Age: ${ageName}. Gender: ${genderName}. Conv: ${ageM.conversions.toFixed(0)} (${(ageM.conversions / totalConv * 100).toFixed(0)}%).`,
demographic: { age_range: ageName.replace("AGE_RANGE_", "").replace(/_/g, "-"), gender: genderName.toLowerCase() },
}),
}).then((r) => r.json());
created.push({ name: `Google Ads: ${label}`, id: p.id });
await new Promise((r) => setTimeout(r, 300));
}
}
console.log(`Created ${created.length} demographic personas`);
created.forEach((p) => console.log(` ${p.name} → ${p.id}`));
Example Output
{
"created": 4,
"personas": [
{ "name": "Google Ads: Male 25-34", "id": "per_gads_m25", "conv_share": "34%", "cpa": "$12.40" },
{ "name": "Google Ads: Female 25-34", "id": "per_gads_f25", "conv_share": "22%", "cpa": "$15.80" },
{ "name": "Google Ads: Male 35-44", "id": "per_gads_m35", "conv_share": "18%", "cpa": "$18.20" },
{ "name": "Google Ads: Female 35-44", "id": "per_gads_f35", "conv_share": "12%", "cpa": "$21.50" }
]
}
Error Handling
UNDETERMINED gender/age
UNDETERMINED gender/age
Google can’t classify all users.
UNDETERMINED often has high volume. Exclude it from persona creation but monitor its conversion share.Low conversion counts
Low conversion counts
With fewer than 30 conversions per segment, data is noisy. Use 90-day windows or aggregate across campaigns for statistical significance.
Persona duplicates on re-run
Persona duplicates on re-run
Check existing personas with
GET /api/v1/personas?search=Google Ads before creating. Use PATCH to update demographics instead.All Google Ads jobs
View all 7 Google Ads integration jobs
Personas API
Full reference for POST /api/v1/personas