Scenario
Your Company Page has 15,000 followers, but your personas are based on assumptions from a strategy deck written two years ago. This job pulls real follower demographics — industry verticals, job functions, and seniority levels — from LinkedIn’s organizational statistics API. It compares those distributions against your existing Mavera personas, then updates (or creates) Custom Personas with actual audience data. The result: personas that reflect who actually follows you, not who you wish followed you.Architecture
Code
import os, requests, time
from collections import defaultdict
LI = os.environ["LINKEDIN_ACCESS_TOKEN"]
MV = os.environ["MAVERA_API_KEY"]
LI_BASE = "https://api.linkedin.com/rest"
MV_BASE = "https://app.mavera.io/api/v1"
LI_H = {"Authorization": f"Bearer {LI}", "LinkedIn-Version": "202401", "X-Restli-Protocol-Version": "2.0.0"}
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
ORG_URN = "urn:li:organization:12345678"
# 1. Pull follower statistics
r = requests.get(f"{LI_BASE}/organizationalEntityFollowerStatistics",
headers=LI_H,
params={
"q": "organizationalEntity",
"organizationalEntity": ORG_URN,
})
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 60)))
r = requests.get(f"{LI_BASE}/organizationalEntityFollowerStatistics",
headers=LI_H,
params={"q": "organizationalEntity", "organizationalEntity": ORG_URN})
r.raise_for_status()
stats = r.json().get("elements", [{}])[0]
# 2. Parse demographic breakdowns
def parse_breakdown(data, key):
result = {}
for entry in data:
name = entry.get(key, {}).get("localized", {}).get("en_US", "")
if not name:
name = entry.get(key, "Unknown")
organic = entry.get("followerCounts", {}).get("organicFollowerCount", 0)
paid = entry.get("followerCounts", {}).get("paidFollowerCount", 0)
result[name] = organic + paid
return dict(sorted(result.items(), key=lambda x: -x[1]))
industries = parse_breakdown(stats.get("followerCountsByIndustry", []), "industry")
functions = parse_breakdown(stats.get("followerCountsByFunction", []), "function")
seniorities = parse_breakdown(stats.get("followerCountsBySeniority", []), "seniority")
total = sum(industries.values()) or 1
print(f"Total followers: {total}")
print(f"Top industries: {list(industries.items())[:5]}")
print(f"Top functions: {list(functions.items())[:5]}")
print(f"Top seniorities: {list(seniorities.items())[:5]}")
# 3. Fetch existing Mavera personas
existing = requests.get(f"{MV_BASE}/personas", headers=MV_H).json()
li_personas = {p["name"]: p for p in (existing if isinstance(existing, list) else [])
if "LinkedIn Follower" in p.get("name", "")}
# 4. Build persona segments from top combinations
top_industries = list(industries.keys())[:4]
top_functions = list(functions.keys())[:3]
top_seniorities = list(seniorities.keys())[:3]
created, updated = [], []
for industry in top_industries:
ind_pct = round((industries[industry] / total) * 100, 1)
if ind_pct < 3:
continue
top_func = top_functions[0] if top_functions else "General"
top_sen = top_seniorities[0] if top_seniorities else "Senior"
name = f"LinkedIn Follower: {industry}"
desc = (
f"Derived from Company Page follower analytics. "
f"Industry: {industry} ({ind_pct}% of followers). "
f"Top function: {top_func} ({round((functions.get(top_func, 0) / total) * 100, 1)}%). "
f"Top seniority: {top_sen} ({round((seniorities.get(top_sen, 0) / total) * 100, 1)}%). "
f"Total follower base: {total:,}."
)
payload = {
"name": name,
"description": desc,
"demographic": {
"industries": [industry],
"job_titles": [top_func],
"seniority": top_sen,
},
"psychographic": {
"source": "linkedin_company_page_followers",
"audience_share_pct": ind_pct,
},
}
if name in li_personas:
r = requests.patch(f"{MV_BASE}/personas/{li_personas[name]['id']}",
headers=MV_H, json=payload)
r.raise_for_status()
updated.append({"name": name, "id": li_personas[name]["id"], "pct": ind_pct})
else:
r = requests.post(f"{MV_BASE}/personas", headers=MV_H, json=payload)
r.raise_for_status()
created.append({"name": name, "id": r.json()["id"], "pct": ind_pct})
time.sleep(0.3)
print(f"\nCreated {len(created)} | Updated {len(updated)} personas")
for p in created + updated:
action = "Created" if p in created else "Updated"
print(f" {action}: {p['name']} ({p['pct']}%) → {p['id']}")
const LI = process.env.LINKEDIN_ACCESS_TOKEN;
const MV = process.env.MAVERA_API_KEY;
const LI_BASE = "https://api.linkedin.com/rest";
const MV_BASE = "https://app.mavera.io/api/v1";
const LI_H = { Authorization: `Bearer ${LI}`, "LinkedIn-Version": "202401", "X-Restli-Protocol-Version": "2.0.0" };
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
const ORG_URN = "urn:li:organization:12345678";
// 1. Pull follower statistics
let res = await fetch(
`${LI_BASE}/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(ORG_URN)}`,
{ headers: LI_H }
);
if (res.status === 429) {
await new Promise(r => setTimeout(r, parseInt(res.headers.get("Retry-After") || "60", 10) * 1000));
res = await fetch(
`${LI_BASE}/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent(ORG_URN)}`,
{ headers: LI_H }
);
}
if (!res.ok) throw new Error(`LinkedIn ${res.status}: ${await res.text()}`);
const stats = (await res.json()).elements?.[0] || {};
// 2. Parse breakdowns
function parseBreakdown(data, key) {
const result = {};
for (const entry of data || []) {
const name = entry[key]?.localized?.en_US || entry[key] || "Unknown";
const organic = entry.followerCounts?.organicFollowerCount || 0;
const paid = entry.followerCounts?.paidFollowerCount || 0;
result[name] = organic + paid;
}
return Object.fromEntries(Object.entries(result).sort(([, a], [, b]) => b - a));
}
const industries = parseBreakdown(stats.followerCountsByIndustry, "industry");
const functions = parseBreakdown(stats.followerCountsByFunction, "function");
const seniorities = parseBreakdown(stats.followerCountsBySeniority, "seniority");
const total = Object.values(industries).reduce((s, v) => s + v, 0) || 1;
console.log(`Total followers: ${total}`);
console.log(`Top industries:`, Object.entries(industries).slice(0, 5));
// 3. Fetch existing personas
const existing = await fetch(`${MV_BASE}/personas`, { headers: MV_H }).then(r => r.json());
const liPersonas = Object.fromEntries(
(Array.isArray(existing) ? existing : [])
.filter(p => (p.name || "").includes("LinkedIn Follower"))
.map(p => [p.name, p])
);
// 4. Build persona segments
const topIndustries = Object.keys(industries).slice(0, 4);
const topFunc = Object.keys(functions)[0] || "General";
const topSen = Object.keys(seniorities)[0] || "Senior";
const created = [], updated = [];
for (const industry of topIndustries) {
const indPct = parseFloat(((industries[industry] / total) * 100).toFixed(1));
if (indPct < 3) continue;
const name = `LinkedIn Follower: ${industry}`;
const funcPct = ((functions[topFunc] || 0) / total * 100).toFixed(1);
const senPct = ((seniorities[topSen] || 0) / total * 100).toFixed(1);
const payload = {
name,
description: `Derived from Company Page analytics. Industry: ${industry} (${indPct}%). Top function: ${topFunc} (${funcPct}%). Top seniority: ${topSen} (${senPct}%). Base: ${total.toLocaleString()}.`,
demographic: { industries: [industry], job_titles: [topFunc], seniority: topSen },
psychographic: { source: "linkedin_company_page_followers", audience_share_pct: indPct },
};
if (liPersonas[name]) {
await fetch(`${MV_BASE}/personas/${liPersonas[name].id}`, {
method: "PATCH", headers: MV_H, body: JSON.stringify(payload),
});
updated.push({ name, id: liPersonas[name].id, pct: indPct });
} else {
const p = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H, body: JSON.stringify(payload),
}).then(r => r.json());
created.push({ name, id: p.id, pct: indPct });
}
await new Promise(r => setTimeout(r, 300));
}
console.log(`\nCreated ${created.length} | Updated ${updated.length} personas`);
[...created, ...updated].forEach(p => {
const action = created.includes(p) ? "Created" : "Updated";
console.log(` ${action}: ${p.name} (${p.pct}%) → ${p.id}`);
});
Example Output
Total followers: 14,832
Top industries: [('Technology', 4218), ('Financial Services', 2107), ('Marketing & Advertising', 1890), ('Healthcare', 1260), ('Education', 947)]
Top functions: [('Marketing', 3415), ('Business Development', 2890), ('Engineering', 1740)]
Top seniorities: [('Senior', 4120), ('Manager', 3280), ('Director', 2950)]
Created 1 | Updated 3 personas
Updated: LinkedIn Follower: Technology (28.4%) → per_li_tech_01
Updated: LinkedIn Follower: Financial Services (14.2%) → per_li_fin_02
Updated: LinkedIn Follower: Marketing & Advertising (12.7%) → per_li_mktg_03
Created: LinkedIn Follower: Healthcare (8.5%) → per_li_hc_04
Error Handling
r_organization_followers scope required
r_organization_followers scope required
Follower statistics need the
r_organization_followers scope, which requires the Community Management API product approval. Without it, the endpoint returns 403.Organization URN format
Organization URN format
The organization URN must be
urn:li:organization:{numericId}. Find your numeric ID on the Company Page admin URL or via GET /organizationAcls.Localized field names
Localized field names
Industry and function names are returned in a
localized object. The code extracts en_US first, falling back to the raw value. Adjust for other locales.Low follower counts
Low follower counts
Pages with under 300 followers produce unreliable demographic distributions. Wait for a larger base before running persona refinement.