Scenario
Your Meta ad account accumulates rich demographic data — age, gender, location, device, interest categories — but it lives in aggregate dashboard charts. This job pulls breakdowns from the Insights API, identifies your strongest audience segments, maps them to existing Mavera personas where possible, and creates custom personas for unmapped segments. The result is a persona library that mirrors your actual paid audience.Architecture
Code
import os, requests, time
from collections import defaultdict
META = os.environ["META_ACCESS_TOKEN"]
ACCT = os.environ["META_AD_ACCOUNT_ID"]
MV = os.environ["MAVERA_API_KEY"]
GRAPH = "https://graph.facebook.com/v24.0"
MB = "https://app.mavera.io/api/v1"
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
# 1. Pull audience insights with demographic breakdowns
insights = requests.get(
f"{GRAPH}/{ACCT}/insights",
params={
"access_token": META,
"fields": "impressions,clicks,spend,actions",
"breakdowns": "age,gender",
"date_preset": "last_30d",
"limit": 100,
},
).json().get("data", [])
# 2. Score segments by conversion efficiency
segments = []
for row in insights:
conversions = 0
for action in row.get("actions", []):
if action.get("action_type") in ("offsite_conversion", "lead", "purchase"):
conversions += int(action.get("value", 0))
segments.append({
"age": row.get("age", "unknown"),
"gender": row.get("gender", "unknown"),
"impressions": int(row.get("impressions", 0)),
"clicks": int(row.get("clicks", 0)),
"spend": float(row.get("spend", 0)),
"conversions": conversions,
"ctr": int(row.get("clicks", 0)) / max(int(row.get("impressions", 1)), 1),
"cpa": float(row.get("spend", 0)) / max(conversions, 1),
})
top_segments = sorted(segments, key=lambda s: s["conversions"], reverse=True)[:10]
# 3. Pull location breakdown separately
geo_insights = requests.get(
f"{GRAPH}/{ACCT}/insights",
params={
"access_token": META,
"fields": "impressions,clicks,spend",
"breakdowns": "country",
"date_preset": "last_30d",
"limit": 20,
},
).json().get("data", [])
top_countries = sorted(geo_insights, key=lambda g: int(g.get("clicks", 0)), reverse=True)[:5]
country_list = [g.get("country", "Unknown") for g in top_countries]
# 4. Fetch existing Mavera personas
existing = requests.get(f"{MB}/personas", headers=MH).json()
existing_names = {p.get("name", "").lower(): p for p in (existing if isinstance(existing, list) else [])}
# 5. Map or create personas
created, mapped = [], []
for seg in top_segments:
name = f"Meta {seg['gender'].title()} {seg['age']}"
search_key = name.lower()
if search_key in existing_names:
mapped.append({"name": name, "id": existing_names[search_key]["id"], "action": "mapped"})
continue
desc = (
f"Meta Ads audience segment: {seg['gender']}, age {seg['age']}. "
f"30-day stats: {seg['impressions']:,} impressions, {seg['clicks']:,} clicks, "
f"CTR {seg['ctr']:.2%}, {seg['conversions']} conversions, CPA ${seg['cpa']:.2f}. "
f"Top markets: {', '.join(country_list[:3])}."
)
r = requests.post(f"{MB}/personas", headers=MH, json={
"name": name,
"description": desc,
"demographic": {
"age_range": seg["age"],
"gender": seg["gender"],
"countries": country_list[:3],
},
"psychographic": {
"conversion_propensity": "high" if seg["conversions"] > 10 else "medium",
"engagement_level": "high" if seg["ctr"] > 0.02 else "moderate",
},
})
r.raise_for_status()
created.append({"name": name, "id": r.json()["id"], "action": "created"})
time.sleep(0.3)
print(f"Mapped: {len(mapped)} existing | Created: {len(created)} new")
for p in mapped + created:
print(f" [{p['action'].upper()}] {p['name']} → {p['id']}")
const META = process.env.META_ACCESS_TOKEN;
const ACCT = process.env.META_AD_ACCOUNT_ID;
const MV = process.env.MAVERA_API_KEY;
const GRAPH = "https://graph.facebook.com/v24.0";
const MB = "https://app.mavera.io/api/v1";
const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
// 1. Pull audience insights
const insights = await fetch(
`${GRAPH}/${ACCT}/insights?access_token=${META}&fields=impressions,clicks,spend,actions&breakdowns=age,gender&date_preset=last_30d&limit=100`
).then(r => r.json()).then(d => d.data || []);
// 2. Score segments
const segments = insights.map(row => {
const conversions = (row.actions || [])
.filter(a => ["offsite_conversion", "lead", "purchase"].includes(a.action_type))
.reduce((sum, a) => sum + parseInt(a.value || "0"), 0);
const impressions = parseInt(row.impressions || "0");
const clicks = parseInt(row.clicks || "0");
const spend = parseFloat(row.spend || "0");
return {
age: row.age || "unknown", gender: row.gender || "unknown",
impressions, clicks, spend, conversions,
ctr: clicks / Math.max(impressions, 1),
cpa: spend / Math.max(conversions, 1),
};
}).sort((a, b) => b.conversions - a.conversions).slice(0, 10);
// 3. Location breakdown
const geoInsights = await fetch(
`${GRAPH}/${ACCT}/insights?access_token=${META}&fields=impressions,clicks,spend&breakdowns=country&date_preset=last_30d&limit=20`
).then(r => r.json()).then(d => d.data || []);
const topCountries = geoInsights
.sort((a, b) => parseInt(b.clicks || "0") - parseInt(a.clicks || "0"))
.slice(0, 5).map(g => g.country || "Unknown");
// 4. Existing personas
const existing = await fetch(`${MB}/personas`, { headers: MH }).then(r => r.json());
const existingNames = new Map(
(Array.isArray(existing) ? existing : []).map(p => [(p.name || "").toLowerCase(), p])
);
// 5. Map or create
const created = [], mapped = [];
for (const seg of segments) {
const name = `Meta ${seg.gender.charAt(0).toUpperCase() + seg.gender.slice(1)} ${seg.age}`;
if (existingNames.has(name.toLowerCase())) {
mapped.push({ name, id: existingNames.get(name.toLowerCase()).id, action: "mapped" });
continue;
}
const res = await fetch(`${MB}/personas`, {
method: "POST", headers: MH,
body: JSON.stringify({
name,
description: `Meta Ads: ${seg.gender}, age ${seg.age}. ${seg.impressions.toLocaleString()} imp, CTR ${(seg.ctr * 100).toFixed(2)}%, ${seg.conversions} conversions, CPA $${seg.cpa.toFixed(2)}.`,
demographic: { age_range: seg.age, gender: seg.gender, countries: topCountries.slice(0, 3) },
psychographic: {
conversion_propensity: seg.conversions > 10 ? "high" : "medium",
engagement_level: seg.ctr > 0.02 ? "high" : "moderate",
},
}),
}).then(r => r.json());
created.push({ name, id: res.id, action: "created" });
await new Promise(r => setTimeout(r, 300));
}
console.log(`Mapped: ${mapped.length} | Created: ${created.length}`);
[...mapped, ...created].forEach(p => console.log(` [${p.action.toUpperCase()}] ${p.name} → ${p.id}`));
Example Output
{
"mapped": 3,
"created": 7,
"personas": [
{ "action": "mapped", "name": "Meta Female 25-34", "id": "per_existing_1" },
{ "action": "created", "name": "Meta Male 35-44", "id": "per_meta_m35_2",
"stats": { "impressions": 245000, "ctr": "3.2%", "conversions": 89, "cpa": "$12.40" } },
{ "action": "created", "name": "Meta Female 18-24", "id": "per_meta_f18_3",
"stats": { "impressions": 180000, "ctr": "4.1%", "conversions": 67, "cpa": "$9.80" } }
],
"top_countries": ["US", "UK", "CA", "AU", "DE"]
}
Error Handling
Insights returns empty data
Insights returns empty data
Insights require at least one active campaign in the date range. Use
date_preset=last_90d for broader coverage. Verify the ad account has had spend.Breakdowns not combinable
Breakdowns not combinable
Some breakdowns can’t be combined (e.g.,
age,gender works but age,placement may not). Check Meta’s breakdown matrix.Actions array varies
Actions array varies
The
actions field contains different action types per objective. Filter for your conversion action (e.g., purchase, lead, offsite_conversion).Meta Ads Integration
All Meta Ads jobs
Personas
Creating and managing personas