> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mavera.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Viewer-Level Persona Mapping

> Map Wistia viewers to psychographic personas by clustering visitor-level engagement data and enriching each segment with Mavera Personas and Chat

### 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

```mermaid theme={"dark"}
flowchart LR
    A["Wistia GET /v1/stats/visitors.json"] --> B[Cluster by behavior] --> C["POST /personas (per cluster)"] --> D["POST /mave/chat"] --> E[Viewer persona map]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  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])
  ```

  ```javascript JavaScript theme={"dark"}
  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));
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
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

<AccordionGroup>
  <Accordion title="Visitor identity requires Turnstile">Wistia's email-level visitor tracking requires [Turnstile](https://wistia.com/support/player/turnstile) (email gate) to be enabled on your videos. Without it, visitors are anonymous and identified only by `visitor_key` (a cookie-based ID).</Accordion>
  <Accordion title="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.</Accordion>
  <Accordion title="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.</Accordion>
</AccordionGroup>

***

## What's Next

<CardGroup cols={2}>
  <Card title="Wistia Integration" icon="circle-play" href="/integrations/wistia">
    Back to Wistia integration overview
  </Card>

  <Card title="Heatmap-Informed Creative Optimization" icon="fire" href="/integrations/wistia/heatmap-optimization">
    Diagnose drop-off points with specific edit recommendations
  </Card>

  <Card title="Personas API" icon="users" href="/api-reference/personas">
    Full reference for POST /api/v1/personas
  </Card>

  <Card title="Mave Agent" icon="brain" href="/api-reference/mave">
    Full reference for POST /api/v1/mave/chat
  </Card>
</CardGroup>
