Skip to main content

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.

Scenario

Your GA4 property has thousands of converting visitors segmented by age, gender, country, and device — but your personas are based on gut feel. You pull a RunReport crossing userAgeBracket, userGender, country, and deviceCategory with conversion metrics, identify the top-performing demographic intersections, and create a Mavera Custom Persona for each. The result is a persona library calibrated to your actual converting audience.

Architecture

Code

import os, requests, time
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="userAgeBracket"),
        Dimension(name="userGender"),
        Dimension(name="country"),
        Dimension(name="deviceCategory"),
    ],
    metrics=[
        Metric(name="totalUsers"),
        Metric(name="conversions"),
        Metric(name="sessions"),
        Metric(name="engagementRate"),
        Metric(name="averageSessionDuration"),
    ],
    date_ranges=[DateRange(start_date="30daysAgo", end_date="today")],
    order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="conversions"), desc=True)],
    limit=200,
))

intersections = []
for row in report.rows:
    age = row.dimension_values[0].value
    gender = row.dimension_values[1].value
    country = row.dimension_values[2].value
    device = row.dimension_values[3].value
    users = int(row.metric_values[0].value)
    conversions = int(row.metric_values[1].value)
    sessions = int(row.metric_values[2].value)
    engagement = float(row.metric_values[3].value)
    avg_duration = float(row.metric_values[4].value)

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

    intersections.append({
        "age": age, "gender": gender, "country": country, "device": device,
        "users": users, "conversions": conversions, "sessions": sessions,
        "engagement": engagement, "avg_duration": avg_duration,
        "conv_rate": conversions / max(sessions, 1),
    })

intersections.sort(key=lambda x: x["conversions"], reverse=True)
top = intersections[:10]

total_conv = sum(i["conversions"] for i in intersections) or 1
print(f"Total intersections: {len(intersections)} | Creating personas for top {len(top)}")

created = []
for seg in top:
    conv_share = seg["conversions"] / total_conv
    name = f"GA4: {seg['gender'].title()} {seg['age']}{seg['country']} ({seg['device']})"
    desc = (
        f"Data-backed persona from GA4 (last 30 days). "
        f"Age: {seg['age']}, Gender: {seg['gender']}, Country: {seg['country']}, Device: {seg['device']}. "
        f"Conversions: {seg['conversions']} ({conv_share:.0%} of total). "
        f"Engagement rate: {seg['engagement']:.1%}. Avg session: {seg['avg_duration']:.0f}s. "
        f"Conv rate: {seg['conv_rate']:.2%}."
    )

    r = requests.post(f"{MB}/personas", headers=MH, json={
        "name": name,
        "description": desc,
        "demographic": {
            "age_range": seg["age"],
            "gender": seg["gender"],
            "countries": [seg["country"]],
        },
        "psychographic": {
            "device_preference": seg["device"],
            "engagement_level": "high" if seg["engagement"] > 0.6 else "medium",
            "conversion_share": f"{conv_share:.1%}",
        },
    })
    r.raise_for_status()
    persona = r.json()
    created.append({"name": name, "id": persona["id"], "conversions": seg["conversions"]})
    print(f"  {name}{persona['id']} ({seg['conversions']} conv, {conv_share:.0%})")
    time.sleep(0.3)

print(f"\nCreated {len(created)} demographic personas from GA4 data")

Example Output

{
  "total_intersections": 142,
  "created": 10,
  "personas": [
    { "name": "GA4: male 25-34 — United States (desktop)", "id": "per_ga4_01", "conversions": 312, "share": "18%" },
    { "name": "GA4: female 25-34 — United States (mobile)", "id": "per_ga4_02", "conversions": 245, "share": "14%" },
    { "name": "GA4: male 35-44 — United Kingdom (desktop)", "id": "per_ga4_03", "conversions": 189, "share": "11%" },
    { "name": "GA4: female 35-44 — United States (desktop)", "id": "per_ga4_04", "conversions": 156, "share": "9%" },
    { "name": "GA4: male 25-34 — Canada (mobile)", "id": "per_ga4_05", "conversions": 98, "share": "6%" }
  ]
}

Error Handling

GA4 can’t classify all users by age/gender — especially those without Google sign-in. The code filters these out. Expect 20–40% of traffic to be unclassified.
GA4 applies thresholding to protect user privacy. Small segments may be hidden. Widen your date range or reduce dimension cardinality to get more data.
50,000 requests/day is generous, but bursts of 10+ concurrent requests hit the per-property limit. Add 300ms delays between calls and retry on 429 with exponential backoff.
If you get 403 PERMISSION_DENIED, verify the service account email is added as a Viewer (or higher) in GA4 → Admin → Property Access Management.

What’s Next

GA4 Integration

Back to GA4 integration overview

Interest Category → Content Strategy

Build a content plan from audience interests

Personas API

Full reference for POST /api/v1/personas

Mave Agent

Full reference for POST /api/v1/mave/chat