Scenario
Wistia captures individual viewer data that most platforms aggregate away — email addresses, geographic location, percent of video watched, number of visits, and viewing history. This job pulls your visitor-level stats, clusters viewers by their behavior patterns (binge-watchers, skimmers, one-time visitors, repeat engagers), then maps each cluster to a Mavera persona with psychographic depth. The result is not just analytics segments but fully realized personas that explain why viewers behave the way they do — so you can tailor content and outreach to each group.Architecture
Code
import os, requests, time
from collections import defaultdict
WS = os.environ["WISTIA_API_TOKEN"]
MV = os.environ["MAVERA_API_KEY"]
WS_BASE = "https://api.wistia.com"
MV_BASE = "https://app.mavera.io/api/v1"
WS_H = {"Authorization": f"Bearer {WS}", "Accept": "application/json"}
MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}
# 1. Fetch all visitors with engagement data (paginated)
all_visitors = []
page = 1
while True:
resp = requests.get(f"{WS_BASE}/v1/stats/visitors.json", headers=WS_H, params={
"per_page": 100, "page": page,
})
if resp.status_code == 429:
time.sleep(2)
continue
resp.raise_for_status()
visitors = resp.json()
if not visitors:
break
all_visitors.extend(visitors)
page += 1
time.sleep(0.2)
print(f"Total visitors: {len(all_visitors)}")
# 2. Enrich each visitor with per-event details
enriched = []
for visitor in all_visitors[:200]:
visitor_key = visitor.get("visitor_key", "")
events = visitor.get("events", [])
total_watched = 0
total_available = 0
video_count = 0
locations = set()
for event in events:
pct = event.get("percent_viewed", 0)
total_watched += pct
total_available += 1
video_count += 1
avg_percent = total_watched / max(video_count, 1)
enriched.append({
"visitor_key": visitor_key,
"email": visitor.get("visitor_identity", {}).get("email", "anonymous"),
"name": visitor.get("visitor_identity", {}).get("name", ""),
"location": visitor.get("visitor_identity", {}).get("location", "unknown"),
"video_count": video_count,
"avg_percent_watched": round(avg_percent, 1),
"total_visits": visitor.get("total_visits", 0),
"last_active": visitor.get("last_active_at", ""),
})
# 3. Cluster viewers by behavior
clusters = defaultdict(list)
for v in enriched:
if v["video_count"] >= 5 and v["avg_percent_watched"] >= 75:
clusters["binge_watchers"].append(v)
elif v["video_count"] >= 3 and v["avg_percent_watched"] >= 50:
clusters["engaged_explorers"].append(v)
elif v["video_count"] == 1 and v["avg_percent_watched"] < 30:
clusters["bouncers"].append(v)
elif v["video_count"] >= 2 and v["avg_percent_watched"] < 50:
clusters["skimmers"].append(v)
else:
clusters["casual_viewers"].append(v)
print(f"\nClusters:")
for name, members in clusters.items():
print(f" {name}: {len(members)} viewers")
# 4. Create Mavera personas for each cluster
persona_map = {}
cluster_descriptions = {
"binge_watchers": "Watches 5+ videos, completes 75%+ of each. Deeply engaged, likely in active evaluation or already a customer seeking mastery. High intent signal.",
"engaged_explorers": "Watches 3-4 videos at 50-75% completion. Browsing with purpose — comparing options or building understanding. Mid-funnel prospect.",
"bouncers": "Watched 1 video, left before 30%. Either the content missed their intent, or they arrived by accident. Lowest engagement tier.",
"skimmers": "Watches 2+ videos but under 50% each. Scanning for specific information — not consuming narrative content. Wants answers, not stories.",
"casual_viewers": "Moderate engagement that doesn't fit other clusters. May be returning after time away or following a specific recommendation.",
}
for cluster_name, description in cluster_descriptions.items():
if cluster_name not in clusters:
continue
sample = clusters[cluster_name][:5]
sample_text = "\n".join(
f" - {s['email']}: {s['video_count']} videos, {s['avg_percent_watched']}% avg, {s['total_visits']} visits"
for s in sample
)
persona = requests.post(f"{MV_BASE}/personas", headers=MV_H, json={
"name": cluster_name.replace("_", " ").title(),
"description": f"{description}\n\nSample viewers:\n{sample_text}",
}).json()
persona_map[cluster_name] = persona["id"]
time.sleep(0.3)
# 5. Analyze via Mave with persona context
cluster_block = "\n\n".join(
f"CLUSTER: {name.replace('_', ' ').title()} ({len(members)} viewers)\n"
f" Avg videos watched: {sum(m['video_count'] for m in members) / len(members):.1f}\n"
f" Avg completion: {sum(m['avg_percent_watched'] for m in members) / len(members):.1f}%\n"
f" Avg total visits: {sum(m['total_visits'] for m in members) / len(members):.1f}\n"
f" Identified (with email): {sum(1 for m in members if m['email'] != 'anonymous')}"
for name, members in clusters.items() if members
)
analysis = requests.post(f"{MV_BASE}/mave/chat", headers=MV_H, json={
"message": f"""Map these viewer clusters to marketing personas and suggest outreach strategies.
VIEWER CLUSTERS:
{cluster_block}
Total viewers analyzed: {len(enriched)}
For each cluster:
1. **Persona Profile**: Name, motivation, likely job title/role, what they're looking for
2. **Content Affinity**: What video types/topics would resonate most with this persona?
3. **Outreach Strategy**: Best channel (email, retargeting, sales call) and message angle
4. **Conversion Probability**: Estimate likelihood this cluster converts to customer (high/medium/low)
5. **Content Gaps**: What video content are we missing that this persona would need?
End with: Which cluster represents the highest-value opportunity and what single action would convert them?""",
}).json()
print("\nVIEWER-LEVEL PERSONA MAPPING")
print("=" * 60)
for name, members in clusters.items():
avg_pct = sum(m["avg_percent_watched"] for m in members) / len(members) if members else 0
identified = sum(1 for m in members if m["email"] != "anonymous")
print(f" {name.replace('_', ' ').title():<22} {len(members):>4} viewers "
f"Avg:{avg_pct:>5.1f}% Identified:{identified:>3}")
print("\n" + analysis.get("content", "")[:2000])
const WS = process.env.WISTIA_API_TOKEN;
const MV = process.env.MAVERA_API_KEY;
const WS_BASE = "https://api.wistia.com";
const MV_BASE = "https://app.mavera.io/api/v1";
const WS_H = { Authorization: `Bearer ${WS}`, Accept: "application/json" };
const MV_H = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };
// 1. Fetch visitors (paginated)
const allVisitors = [];
let page = 1;
while (true) {
const resp = await fetch(
`${WS_BASE}/v1/stats/visitors.json?per_page=100&page=${page}`, { headers: WS_H }
);
if (resp.status === 429) { await new Promise(r => setTimeout(r, 2000)); continue; }
const visitors = await resp.json();
if (!visitors.length) break;
allVisitors.push(...visitors);
page++;
await new Promise(r => setTimeout(r, 200));
}
console.log(`Total visitors: ${allVisitors.length}`);
// 2. Enrich
const enriched = allVisitors.slice(0, 200).map(visitor => {
const events = visitor.events || [];
const totalWatched = events.reduce((s, e) => s + (e.percent_viewed || 0), 0);
const videoCount = events.length;
return {
visitorKey: visitor.visitor_key || "",
email: visitor.visitor_identity?.email || "anonymous",
name: visitor.visitor_identity?.name || "",
location: visitor.visitor_identity?.location || "unknown",
videoCount, avgPercentWatched: videoCount > 0 ? Math.round(totalWatched / videoCount * 10) / 10 : 0,
totalVisits: visitor.total_visits || 0,
lastActive: visitor.last_active_at || "",
};
});
// 3. Cluster
const clusters = { binge_watchers: [], engaged_explorers: [], bouncers: [], skimmers: [], casual_viewers: [] };
for (const v of enriched) {
if (v.videoCount >= 5 && v.avgPercentWatched >= 75) clusters.binge_watchers.push(v);
else if (v.videoCount >= 3 && v.avgPercentWatched >= 50) clusters.engaged_explorers.push(v);
else if (v.videoCount === 1 && v.avgPercentWatched < 30) clusters.bouncers.push(v);
else if (v.videoCount >= 2 && v.avgPercentWatched < 50) clusters.skimmers.push(v);
else clusters.casual_viewers.push(v);
}
console.log("\nClusters:");
for (const [name, members] of Object.entries(clusters)) {
if (members.length) console.log(` ${name}: ${members.length} viewers`);
}
// 4. Create personas
const clusterDescs = {
binge_watchers: "5+ videos, 75%+ completion. Deep engagement — active evaluation or mastery-seeking.",
engaged_explorers: "3-4 videos, 50-75%. Browsing with purpose, mid-funnel.",
bouncers: "1 video, <30%. Mismatched intent or accidental arrival.",
skimmers: "2+ videos, <50% each. Scanning for answers, not consuming narratives.",
casual_viewers: "Moderate engagement, doesn't fit other clusters.",
};
const personaMap = {};
for (const [name, desc] of Object.entries(clusterDescs)) {
if (!clusters[name]?.length) continue;
const sample = clusters[name].slice(0, 5).map(s =>
` - ${s.email}: ${s.videoCount} videos, ${s.avgPercentWatched}% avg, ${s.totalVisits} visits`
).join("\n");
const persona = await fetch(`${MV_BASE}/personas`, {
method: "POST", headers: MV_H,
body: JSON.stringify({ name: name.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()), description: `${desc}\n\nSample:\n${sample}` }),
}).then(r => r.json());
personaMap[name] = persona.id;
await new Promise(r => setTimeout(r, 300));
}
// 5. Analyze
const clusterBlock = Object.entries(clusters)
.filter(([, m]) => m.length)
.map(([name, members]) => {
const avgVids = members.reduce((s, m) => s + m.videoCount, 0) / members.length;
const avgPct = members.reduce((s, m) => s + m.avgPercentWatched, 0) / members.length;
const identified = members.filter(m => m.email !== "anonymous").length;
return `CLUSTER: ${name.replace(/_/g, " ")} (${members.length} viewers)\n Avg videos: ${avgVids.toFixed(1)} | Avg completion: ${avgPct.toFixed(1)}% | Identified: ${identified}`;
}).join("\n\n");
const analysis = await fetch(`${MV_BASE}/mave/chat`, {
method: "POST", headers: MV_H,
body: JSON.stringify({
message: `Map viewer clusters to personas with outreach strategies.\n\n${clusterBlock}\n\nTotal: ${enriched.length}\n\nPer cluster:\n1. Persona profile (name, motivation, role)\n2. Content affinity\n3. Outreach strategy (channel + message)\n4. Conversion probability\n5. Content gaps\n\nEnd with: highest-value cluster and single action to convert them`,
}),
}).then(r => r.json());
console.log("\nVIEWER-LEVEL PERSONA MAPPING");
console.log("=".repeat(60));
for (const [name, members] of Object.entries(clusters)) {
if (!members.length) continue;
const avgPct = members.reduce((s, m) => s + m.avgPercentWatched, 0) / members.length;
const identified = members.filter(m => m.email !== "anonymous").length;
console.log(` ${name.replace(/_/g, " ").padEnd(22)} ${String(members.length).padStart(4)} viewers ` +
`Avg:${avgPct.toFixed(1).padStart(5)}% Identified:${String(identified).padStart(3)}`);
}
console.log("\n" + (analysis.content || "").slice(0, 2000));
Example Output
Total visitors: 1,247
Clusters:
binge_watchers: 89 viewers
engaged_explorers: 234 viewers
bouncers: 412 viewers
skimmers: 187 viewers
casual_viewers: 325 viewers
VIEWER-LEVEL PERSONA MAPPING
============================================================
Binge Watchers 89 viewers Avg: 82.4% Identified: 67
Engaged Explorers 234 viewers Avg: 61.8% Identified:142
Bouncers 412 viewers Avg: 18.3% Identified: 51
Skimmers 187 viewers Avg: 34.7% Identified: 89
Casual Viewers 325 viewers Avg: 45.2% Identified:118
## Persona Profiles
### Binge Watchers → "The Evaluator"
Motivation: Actively comparing solutions before a purchase decision.
Likely role: Director or VP-level, delegated research phase. Watches
everything to build an internal recommendation document.
Content affinity: Case studies, ROI calculators, integration demos.
Outreach: Direct sales outreach within 48 hours. Message: "I noticed
you've been exploring our platform — want a personalized walkthrough?"
Conversion: HIGH (67% identified = already in your CRM)
### Engaged Explorers → "The Researcher"
Motivation: Building understanding before engaging sales. Wants to
self-serve their way to confidence.
Likely role: Manager or senior IC responsible for evaluation criteria.
Content affinity: Comparison guides, technical deep-dives, FAQ videos.
Outreach: Nurture email sequence with "resources you haven't seen yet."
Conversion: MEDIUM-HIGH (needs one more push)
### Bouncers → "The Drive-By"
Motivation: Arrived from a specific link (ad, social, email) but the
landing video didn't match their expectation.
Content affinity: Short, specific, problem-focused. Under 90 seconds.
Outreach: Retargeting with a different video format. Don't email — you
don't have their address (only 12% identified).
Conversion: LOW unless re-engaged with better-matched content
### HIGHEST-VALUE OPPORTUNITY
Binge Watchers (89 viewers, 75% identified). Single action: Have sales
call every identified Binge Watcher within 48 hours of their last
viewing session. These viewers have already sold themselves — they need
a human to say "let's do this."
Error Handling
Visitor identity requires Turnstile
Visitor identity requires Turnstile
Wistia’s email-level visitor tracking requires Turnstile (email gate) to be enabled on your videos. Without it, visitors are anonymous and identified only by
visitor_key (a cookie-based ID).Pagination ceiling
Pagination ceiling
The
/v1/stats/visitors.json endpoint returns max 100 visitors per page. For accounts with 10,000+ visitors, pagination can take 100+ requests. Stay well within the 600 req/min limit by adding 200ms delays.Event-level granularity
Event-level granularity
The
events array within each visitor contains per-viewing data including percent_viewed, received_at, and media_id. For deeper analysis, join events by media_id to see which specific videos each cluster prefers.What’s Next
Wistia Integration
Back to Wistia integration overview
Heatmap-Informed Creative Optimization
Diagnose drop-off points with specific edit recommendations
Personas API
Full reference for POST /api/v1/personas
Mave Agent
Full reference for POST /api/v1/mave/chat