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

# Acquisition Channel × Persona Mapping

> Cross GA4 session source/medium with demographics to map each channel-audience intersection to a Mavera persona for channel-specific messaging

### Scenario

Different channels attract different people — your organic search visitors behave differently than your paid social visitors. You pull `sessionSource` and `sessionMedium` crossed with `userAgeBracket` and `userGender`, then map each channel-demographic pair to a Mavera persona. The result is a persona library where each entry represents a specific channel-audience intersection, enabling channel-specific messaging.

### Architecture

```mermaid theme={"dark"}
flowchart LR
    A["GA4 RunReport (source × medium × age × gender)"] --> B[Group by channel + demographic profile] --> C["POST /api/v1/personas"] --> D[Channel-specific persona library]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, requests, time
  from collections import defaultdict
  from google.analytics.data_v1beta import BetaAnalyticsDataClient
  from google.analytics.data_v1beta.types import (
      RunReportRequest, Dimension, Metric, DateRange, OrderBy,
  )

  PROPERTY_ID = os.environ["GA4_PROPERTY_ID"]
  MV = os.environ["MAVERA_API_KEY"]
  MB = "https://app.mavera.io/api/v1"
  MH = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  client = BetaAnalyticsDataClient()

  report = client.run_report(RunReportRequest(
      property=f"properties/{PROPERTY_ID}",
      dimensions=[
          Dimension(name="sessionSource"),
          Dimension(name="sessionMedium"),
          Dimension(name="userAgeBracket"),
          Dimension(name="userGender"),
      ],
      metrics=[
          Metric(name="totalUsers"),
          Metric(name="conversions"),
          Metric(name="engagementRate"),
          Metric(name="averageSessionDuration"),
      ],
      date_ranges=[DateRange(start_date="30daysAgo", end_date="today")],
      order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="totalUsers"), desc=True)],
      limit=500,
  ))

  channels = defaultdict(lambda: {
      "users": 0, "conversions": 0, "engagement_sum": 0, "duration_sum": 0,
      "age_dist": defaultdict(int), "gender_dist": defaultdict(int), "count": 0,
  })

  for row in report.rows:
      source = row.dimension_values[0].value
      medium = row.dimension_values[1].value
      age = row.dimension_values[2].value
      gender = row.dimension_values[3].value
      users = int(row.metric_values[0].value)
      conversions = int(row.metric_values[1].value)
      engagement = float(row.metric_values[2].value)
      duration = float(row.metric_values[3].value)

      if age == "(not set)" or gender == "(not set)":
          continue

      key = f"{source}/{medium}"
      channels[key]["users"] += users
      channels[key]["conversions"] += conversions
      channels[key]["engagement_sum"] += engagement * users
      channels[key]["duration_sum"] += duration * users
      channels[key]["age_dist"][age] += users
      channels[key]["gender_dist"][gender] += users
      channels[key]["count"] += 1

  channel_profiles = []
  for channel, data in channels.items():
      if data["users"] < 50:
          continue
      top_age = max(data["age_dist"], key=data["age_dist"].get) if data["age_dist"] else "unknown"
      top_gender = max(data["gender_dist"], key=data["gender_dist"].get) if data["gender_dist"] else "unknown"
      channel_profiles.append({
          "channel": channel,
          "users": data["users"],
          "conversions": data["conversions"],
          "avg_engagement": data["engagement_sum"] / max(data["users"], 1),
          "avg_duration": data["duration_sum"] / max(data["users"], 1),
          "top_age": top_age,
          "top_gender": top_gender,
          "conv_rate": data["conversions"] / max(data["users"], 1),
          "age_dist": dict(data["age_dist"]),
          "gender_dist": dict(data["gender_dist"]),
      })

  channel_profiles.sort(key=lambda c: c["users"], reverse=True)

  created = []
  for cp in channel_profiles[:8]:
      name = f"GA4 Channel: {cp['channel']} ({cp['top_gender']} {cp['top_age']})"
      age_breakdown = ", ".join(f"{a}: {n}" for a, n in sorted(cp["age_dist"].items(), key=lambda x: -x[1])[:3])
      gender_breakdown = ", ".join(f"{g}: {n}" for g, n in sorted(cp["gender_dist"].items(), key=lambda x: -x[1]))

      persona = requests.post(f"{MB}/personas", headers=MH, json={
          "name": name,
          "description": (
              f"Persona from GA4 channel {cp['channel']} (30d). "
              f"Primary: {cp['top_gender']} {cp['top_age']}. "
              f"Users: {cp['users']}, Conv: {cp['conversions']} ({cp['conv_rate']:.2%}). "
              f"Engagement: {cp['avg_engagement']:.0%}. Session: {cp['avg_duration']:.0f}s. "
              f"Age mix: {age_breakdown}. Gender: {gender_breakdown}."
          ),
          "demographic": {
              "age_range": cp["top_age"],
              "gender": cp["top_gender"],
          },
          "psychographic": {
              "acquisition_channel": cp["channel"],
              "engagement_level": "high" if cp["avg_engagement"] > 0.6 else "medium",
          },
      }).json()
      created.append({"channel": cp["channel"], "id": persona["id"], "users": cp["users"]})
      print(f"  {name} → {persona['id']}")
      time.sleep(0.3)

  print(f"\nMapped {len(created)} channel-persona pairs")
  ```

  ```javascript JavaScript theme={"dark"}
  const MV = process.env.MAVERA_API_KEY;
  const PROPERTY_ID = process.env.GA4_PROPERTY_ID;
  const KEY_FILE = JSON.parse(require("fs").readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, "utf8"));
  const MB = "https://app.mavera.io/api/v1";
  const MH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };

  const { GoogleAuth } = require("google-auth-library");
  const auth = new GoogleAuth({
    credentials: KEY_FILE,
    scopes: ["https://www.googleapis.com/auth/analytics.readonly"],
  });
  const accessToken = await auth.getAccessToken();

  const gaRes = await fetch(
    `https://analyticsdata.googleapis.com/v1beta/properties/${PROPERTY_ID}:runReport`,
    {
      method: "POST",
      headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        dimensions: [
          { name: "sessionSource" }, { name: "sessionMedium" },
          { name: "userAgeBracket" }, { name: "userGender" },
        ],
        metrics: [
          { name: "totalUsers" }, { name: "conversions" },
          { name: "engagementRate" }, { name: "averageSessionDuration" },
        ],
        dateRanges: [{ startDate: "30daysAgo", endDate: "today" }],
        orderBys: [{ metric: { metricName: "totalUsers" }, desc: true }],
        limit: 500,
      }),
    }
  ).then((r) => r.json());

  const channels = {};
  for (const row of gaRes.rows || []) {
    const source = row.dimensionValues[0].value;
    const medium = row.dimensionValues[1].value;
    const age = row.dimensionValues[2].value;
    const gender = row.dimensionValues[3].value;
    const users = parseInt(row.metricValues[0].value);
    const conv = parseInt(row.metricValues[1].value);
    const eng = parseFloat(row.metricValues[2].value);
    const dur = parseFloat(row.metricValues[3].value);

    if (age === "(not set)" || gender === "(not set)") continue;
    const key = `${source}/${medium}`;
    channels[key] ??= { users: 0, conv: 0, engSum: 0, durSum: 0, ageDist: {}, genderDist: {} };
    channels[key].users += users;
    channels[key].conv += conv;
    channels[key].engSum += eng * users;
    channels[key].durSum += dur * users;
    channels[key].ageDist[age] = (channels[key].ageDist[age] || 0) + users;
    channels[key].genderDist[gender] = (channels[key].genderDist[gender] || 0) + users;
  }

  const profiles = Object.entries(channels)
    .filter(([, d]) => d.users >= 50)
    .map(([channel, d]) => {
      const topAge = Object.entries(d.ageDist).sort(([, a], [, b]) => b - a)[0]?.[0] || "unknown";
      const topGender = Object.entries(d.genderDist).sort(([, a], [, b]) => b - a)[0]?.[0] || "unknown";
      return {
        channel, users: d.users, conv: d.conv, topAge, topGender,
        avgEng: d.engSum / (d.users || 1), avgDur: d.durSum / (d.users || 1),
        convRate: d.conv / (d.users || 1), ageDist: d.ageDist, genderDist: d.genderDist,
      };
    })
    .sort((a, b) => b.users - a.users);

  const created = [];
  for (const cp of profiles.slice(0, 8)) {
    const name = `GA4 Channel: ${cp.channel} (${cp.topGender} ${cp.topAge})`;
    const p = await fetch(`${MB}/personas`, {
      method: "POST", headers: MH,
      body: JSON.stringify({
        name,
        description: `Channel ${cp.channel} (30d). ${cp.topGender} ${cp.topAge}. Users: ${cp.users}, Conv: ${cp.conv} (${(cp.convRate * 100).toFixed(2)}%). Eng: ${(cp.avgEng * 100).toFixed(0)}%.`,
        demographic: { age_range: cp.topAge, gender: cp.topGender },
        psychographic: { acquisition_channel: cp.channel, engagement_level: cp.avgEng > 0.6 ? "high" : "medium" },
      }),
    }).then((r) => r.json());
    created.push({ channel: cp.channel, id: p.id });
    console.log(`  ${name} → ${p.id}`);
    await new Promise((r) => setTimeout(r, 300));
  }

  console.log(`\nMapped ${created.length} channel-persona pairs`);
  ```
</CodeGroup>

### Example Output

```json theme={"dark"}
{
  "mapped": 8,
  "personas": [
    { "channel": "google/organic", "persona": "GA4 Channel: google/organic (male 25-34)", "id": "per_ch_01", "users": 4200 },
    { "channel": "twitter/social", "persona": "GA4 Channel: twitter/social (male 25-34)", "id": "per_ch_02", "users": 1800 },
    { "channel": "linkedin/social", "persona": "GA4 Channel: linkedin/social (female 35-44)", "id": "per_ch_03", "users": 1200 },
    { "channel": "google/cpc", "persona": "GA4 Channel: google/cpc (male 35-44)", "id": "per_ch_04", "users": 980 },
    { "channel": "(direct)/(none)", "persona": "GA4 Channel: (direct)/(none) (female 25-34)", "id": "per_ch_05", "users": 870 }
  ]
}
```

### Error Handling

<AccordionGroup>
  <Accordion title="High cardinality warning">Crossing 4 dimensions can produce thousands of rows. The code caps at 500 rows and filters out (not set). For large properties, consider running separate reports per channel.</Accordion>
  <Accordion title="(direct)/(none) traffic">Direct traffic includes bookmarks, typed URLs, and unattributable sources. It often has the largest volume but least actionable demographic data. Consider separating it from other channels.</Accordion>
  <Accordion title="UTM tagging gaps">If sources show as `(not set)`, your campaigns may lack UTM parameters. Add `utm_source`, `utm_medium`, and `utm_campaign` to all marketing links.</Accordion>
</AccordionGroup>

***

## What's Next

<CardGroup cols={2}>
  <Card title="GA4 Integration" icon="chart-line" href="/integrations/ga4">
    Back to GA4 integration overview
  </Card>

  <Card title="Real-Time Audience → Trending Response" icon="bolt" href="/integrations/ga4/realtime-trending-response">
    Diagnose traffic spikes in real time
  </Card>

  <Card title="Device Behavior → Creative Format Recommendations" icon="mobile-screen" href="/integrations/ga4/device-creative-recommendations">
    Optimize creative formats per device
  </Card>

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