Scenario
Static personas go stale. Your HubSpot contacts have live engagement data — opens, clicks, page views — that reveal which segments are active. You pull engagement metrics, weight persona attributes by behavior, then update Mavera personas so they evolve with your audience.Architecture
Code
import os, requests, time
from collections import defaultdict
HS = os.environ["HUBSPOT_ACCESS_TOKEN"]
MV = os.environ["MAVERA_API_KEY"]
MB = "https://app.mavera.io/api/v1"
MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
ENG_PROPS = ["hs_email_open_count", "hs_email_click_count",
"hs_analytics_num_page_views", "num_conversion_events",
"jobtitle", "industry", "lifecyclestage"]
# 1. Pull engaged contacts
contacts, after = [], None
while len(contacts) < 300:
payload = {
"filterGroups": [{"filters": [{"propertyName": "hs_email_open_count", "operator": "GT", "value": "0"}]}],
"properties": ENG_PROPS,
"sorts": [{"propertyName": "hs_email_open_count", "direction": "DESCENDING"}],
"limit": 100,
}
if after: payload["after"] = after
r = requests.post("https://api.hubapi.com/crm/v3/objects/contacts/search",
headers={"Authorization": f"Bearer {HS}"}, json=payload)
if r.status_code == 429: time.sleep(1); continue
r.raise_for_status()
data = r.json()
contacts.extend(data.get("results", []))
after = data.get("paging", {}).get("next", {}).get("after")
if not after: break
# 2. Score and tier
def score(c):
p = c.get("properties", {})
return int(p.get("hs_email_open_count") or 0) + int(p.get("hs_email_click_count") or 0)*3 + int(p.get("hs_analytics_num_page_views") or 0)*2 + int(p.get("num_conversion_events") or 0)*10
scored = sorted([(c, score(c)) for c in contacts], key=lambda x: -x[1])
third = len(scored) // 3
tiers = {"High": scored[:third], "Medium": scored[third:2*third], "Low": scored[2*third:]}
def profile(tier):
titles, inds = defaultdict(int), defaultdict(int)
for c, _ in tier:
p = c.get("properties", {})
if p.get("jobtitle"): titles[p["jobtitle"]] += 1
if p.get("industry"): inds[p["industry"]] += 1
return {"n": len(tier), "avg": sum(s for _,s in tier)/max(len(tier),1),
"titles": sorted(titles, key=titles.get, reverse=True)[:5],
"industries": sorted(inds, key=inds.get, reverse=True)[:3]}
# 3. Get existing personas
existing = requests.get(f"{MB}/personas", headers=MH).json()
hs_personas = {p["name"]: p for p in (existing if isinstance(existing, list) else []) if "HubSpot" in p.get("name","")}
# 4. Update or create
for tier_name, tier_data in tiers.items():
prof = profile(tier_data)
name = f"HubSpot {tier_name} Engagement"
desc = f"{tier_name} engagement. Avg: {prof['avg']:.1f}. N={prof['n']}. Titles: {', '.join(prof['titles'][:3])}."
payload = {"name": name, "description": desc, "demographic": {"job_titles": prof["titles"], "industries": prof["industries"]},
"psychographic": {"engagement_level": tier_name.lower(), "avg_score": prof["avg"]}}
if name in hs_personas:
r = requests.patch(f"{MB}/personas/{hs_personas[name]['id']}", headers=MH, json=payload)
r.raise_for_status()
print(f"Updated: {name} ({hs_personas[name]['id']})")
else:
r = requests.post(f"{MB}/personas", headers=MH, json=payload)
r.raise_for_status()
print(f"Created: {name} ({r.json()['id']})")
time.sleep(0.3)
const HS = process.env.HUBSPOT_ACCESS_TOKEN;
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 ENG_PROPS = ["hs_email_open_count", "hs_email_click_count",
"hs_analytics_num_page_views", "num_conversion_events", "jobtitle", "industry", "lifecyclestage"];
// 1. Pull engaged contacts
const contacts = [];
let after = null;
while (contacts.length < 300) {
const payload = {
filterGroups: [{ filters: [{ propertyName: "hs_email_open_count", operator: "GT", value: "0" }] }],
properties: ENG_PROPS,
sorts: [{ propertyName: "hs_email_open_count", direction: "DESCENDING" }],
limit: 100,
};
if (after) payload.after = after;
const res = await fetch("https://api.hubapi.com/crm/v3/objects/contacts/search", {
method: "POST", headers: { Authorization: `Bearer ${HS}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.status === 429) { await new Promise(r => setTimeout(r, 1000)); continue; }
const data = await res.json();
contacts.push(...(data.results || []));
after = data.paging?.next?.after; if (!after) break;
}
// 2. Score + tier
const score = (c) => { const p = c.properties || {};
return parseInt(p.hs_email_open_count||"0") + parseInt(p.hs_email_click_count||"0")*3
+ parseInt(p.hs_analytics_num_page_views||"0")*2 + parseInt(p.num_conversion_events||"0")*10; };
const scored = contacts.map(c => ({ c, s: score(c) })).sort((a,b) => b.s - a.s);
const third = Math.floor(scored.length / 3);
const tiers = { High: scored.slice(0, third), Medium: scored.slice(third, third*2), Low: scored.slice(third*2) };
const profile = (tier) => {
const titles = {}, inds = {};
tier.forEach(({ c }) => { const p = c.properties || {};
if (p.jobtitle) titles[p.jobtitle] = (titles[p.jobtitle]||0)+1;
if (p.industry) inds[p.industry] = (inds[p.industry]||0)+1; });
const sort = o => Object.keys(o).sort((a,b) => o[b]-o[a]);
return { n: tier.length, avg: tier.reduce((s,t)=>s+t.s,0)/(tier.length||1),
titles: sort(titles).slice(0,5), industries: sort(inds).slice(0,3) };
};
// 3. Existing personas
const existing = await fetch(`${MB}/personas`, { headers: MH }).then(r => r.json());
const hsPersonas = Object.fromEntries(
(Array.isArray(existing) ? existing : []).filter(p => (p.name||"").includes("HubSpot")).map(p => [p.name, p])
);
// 4. Update or create
for (const [tierName, tierData] of Object.entries(tiers)) {
const prof = profile(tierData);
const name = `HubSpot ${tierName} Engagement`;
const payload = {
name, description: `${tierName} engagement. Avg: ${prof.avg.toFixed(1)}. N=${prof.n}. Titles: ${prof.titles.slice(0,3).join(", ")}.`,
demographic: { job_titles: prof.titles, industries: prof.industries },
psychographic: { engagement_level: tierName.toLowerCase(), avg_score: prof.avg },
};
if (hsPersonas[name]) {
await fetch(`${MB}/personas/${hsPersonas[name].id}`, { method: "PATCH", headers: MH, body: JSON.stringify(payload) });
console.log(`Updated: ${name} (${hsPersonas[name].id})`);
} else {
const p = await fetch(`${MB}/personas`, { method: "POST", headers: MH, body: JSON.stringify(payload) }).then(r => r.json());
console.log(`Created: ${name} (${p.id})`);
}
await new Promise(r => setTimeout(r, 300));
}
Example Output
HIGH (100 contacts, avg: 142.3) — VP Marketing, Head of Growth | Tech, SaaS
MEDIUM (100, avg: 38.7) — Marketing Manager, Content Strategist | Tech, Healthcare
LOW (100, avg: 5.2) — Analyst, Coordinator | Tech, Education
Updated: HubSpot High Engagement (per_eng_high_1)
Updated: HubSpot Medium Engagement (per_eng_med_2)
Created: HubSpot Low Engagement (per_eng_low_3)
Error Handling
Missing engagement properties
Missing engagement properties
hs_email_open_count requires Marketing Hub with email tracking. Verify with GET /crm/v3/properties/contacts.PATCH not supported
PATCH not supported
If Mavera doesn’t support PATCH, delete and recreate:
DELETE /api/v1/personas/{id} then POST /api/v1/personas.Score weighting
Score weighting
Default weights (opens: 1×, clicks: 3×, views: 2×, conversions: 10×) are starting points. Adjust per your business.