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

# Contact Export → Bulk Persona Creation

> Export SendGrid marketing contacts and batch-create Mavera personas from subscriber segments at scale

### Scenario

Your SendGrid marketing contacts hold thousands of subscribers with custom fields — company size, industry, role, signup source, engagement score. Creating personas one by one won't scale. This job triggers a SendGrid contact export, downloads the CSV, segments contacts by meaningful clusters, then batch-creates Mavera personas for each segment. The result is a persona library that mirrors your actual subscriber base, ready for focus groups and content generation.

**Flow:** SendGrid `POST /v3/marketing/contacts/exports` → Poll → Download CSV → Segment by custom fields → Mavera `POST /api/v1/personas` (batch) → Persona library at scale

### Architecture

```mermaid theme={"dark"}
flowchart LR
A["POST /v3/marketing/contacts/exports"] --> B["Poll until ready"] --> C["Download + parse CSV"] --> D["Cluster by industry × role"] --> E["POST /api/v1/personas"] --> F["Bulk persona library"]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, csv, io, requests, time
  from collections import defaultdict

  SG = os.environ["SENDGRID_API_KEY"]
  MV = os.environ["MAVERA_API_KEY"]
  SG_BASE = "https://api.sendgrid.com/v3"
  MB = "https://app.mavera.io/api/v1"
  SG_H = {"Authorization": f"Bearer {SG}", "Content-Type": "application/json"}
  MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  # 1. Trigger export
  export = requests.post(f"{SG_BASE}/marketing/contacts/exports",
      headers=SG_H, json={
          "list_ids": [],
          "segment_ids": [],
          "file_type": "csv",
          "max_file_size": 5000,
      }).json()

  export_id = export.get("id")
  print(f"Export triggered: {export_id}")

  # 2. Poll until ready
  download_url = None
  for attempt in range(30):
      time.sleep(10)
      status = requests.get(f"{SG_BASE}/marketing/contacts/exports/{export_id}",
          headers=SG_H).json()
      state = status.get("status", "pending")
      print(f"  Export status: {state} (attempt {attempt + 1})")

      if state == "ready":
          urls = status.get("urls", [])
          if urls:
              download_url = urls[0]
          break
      elif state == "failure":
          print(f"Export failed: {status.get('message', 'unknown')}")
          exit()

  if not download_url:
      print("Export timed out")
      exit()

  # 3. Download and parse CSV
  csv_data = requests.get(download_url).text
  reader = csv.DictReader(io.StringIO(csv_data))
  contacts = list(reader)
  print(f"Downloaded {len(contacts)} contacts")

  # 4. Cluster by industry × role
  clusters = defaultdict(list)
  for contact in contacts:
      industry = (contact.get("industry") or contact.get("custom_industry") or "unknown").strip().lower()
      role = (contact.get("job_title") or contact.get("custom_role") or "unknown").strip().lower()
      engagement = "high" if int(contact.get("engagement_score", 0) or 0) > 70 else \
                   "medium" if int(contact.get("engagement_score", 0) or 0) > 30 else "low"

      key = f"{industry}|{role}|{engagement}"
      clusters[key].append(contact)

  # 5. Create personas for significant clusters
  significant = {k: v for k, v in clusters.items() if len(v) >= 10}
  print(f"Significant clusters (10+): {len(significant)}")

  personas = []
  for key, members in sorted(significant.items(), key=lambda x: -len(x[1]))[:20]:
      industry, role, engagement = key.split("|")
      emails_domains = list({
          m.get("email", "").split("@")[-1]
          for m in members if "@" in m.get("email", "")
      })[:5]
      sources = list({m.get("signup_source", "") for m in members if m.get("signup_source")})[:3]

      r = requests.post(f"{MB}/personas", headers=MV_H, json={
          "name": f"SG: {role.title()} / {industry.title()} / {engagement.title()}",
          "description": (
              f"SendGrid segment. Role: {role}. Industry: {industry}. "
              f"Engagement: {engagement}. N={len(members)}. "
              f"Top domains: {', '.join(emails_domains[:3])}. "
              f"Sources: {', '.join(sources)}."
          ),
          "demographic": {"job_titles": [role], "industries": [industry]},
          "psychographic": {"engagement_level": engagement, "signup_sources": sources},
      })
      r.raise_for_status()
      personas.append({"cluster": key, "id": r.json()["id"], "n": len(members)})
      print(f"  {role.title()} / {industry.title()} / {engagement.title()}: "
            f"{r.json()['id']} ({len(members)})")
      time.sleep(0.3)

  print(f"\nCreated {len(personas)} personas from {len(contacts)} contacts")
  ```

  ```javascript JavaScript theme={"dark"}
  const SG = process.env.SENDGRID_API_KEY;
  const MV = process.env.MAVERA_API_KEY;
  const SG_BASE = "https://api.sendgrid.com/v3";
  const MB = "https://app.mavera.io/api/v1";
  const sgH = { Authorization: `Bearer ${SG}`, "Content-Type": "application/json" };
  const mvH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };

  // 1. Trigger export
  const exportRes = await fetch(`${SG_BASE}/marketing/contacts/exports`, {
    method: "POST", headers: sgH,
    body: JSON.stringify({ list_ids: [], segment_ids: [], file_type: "csv", max_file_size: 5000 }),
  }).then(r => r.json());

  const exportId = exportRes.id;
  console.log(`Export triggered: ${exportId}`);

  // 2. Poll until ready
  let downloadUrl = null;
  for (let i = 0; i < 30; i++) {
    await new Promise(r => setTimeout(r, 10000));
    const status = await fetch(`${SG_BASE}/marketing/contacts/exports/${exportId}`,
      { headers: sgH }).then(r => r.json());
    console.log(`  Status: ${status.status} (attempt ${i + 1})`);

    if (status.status === "ready" && status.urls?.length) {
      downloadUrl = status.urls[0]; break;
    }
    if (status.status === "failure") { console.log("Export failed"); process.exit(1); }
  }
  if (!downloadUrl) { console.log("Timed out"); process.exit(1); }

  // 3. Download and parse CSV
  const csvText = await fetch(downloadUrl).then(r => r.text());
  const lines = csvText.split("\n");
  const headers = lines[0].split(",").map(h => h.trim().replace(/"/g, ""));
  const contacts = lines.slice(1).filter(l => l.trim()).map(line => {
    const vals = line.match(/(".*?"|[^,]*)/g) || [];
    const obj = {};
    headers.forEach((h, i) => { obj[h] = (vals[i] || "").replace(/"/g, "").trim(); });
    return obj;
  });
  console.log(`Downloaded ${contacts.length} contacts`);

  // 4. Cluster
  const clusters = {};
  for (const c of contacts) {
    const industry = (c.industry || c.custom_industry || "unknown").toLowerCase();
    const role = (c.job_title || c.custom_role || "unknown").toLowerCase();
    const score = parseInt(c.engagement_score || "0");
    const engagement = score > 70 ? "high" : score > 30 ? "medium" : "low";
    const key = `${industry}|${role}|${engagement}`;
    (clusters[key] ??= []).push(c);
  }

  // 5. Create personas for clusters with 10+
  const significant = Object.entries(clusters)
    .filter(([, v]) => v.length >= 10)
    .sort(([, a], [, b]) => b.length - a.length)
    .slice(0, 20);

  console.log(`Significant clusters: ${significant.length}`);
  const personas = [];

  for (const [key, members] of significant) {
    const [industry, role, engagement] = key.split("|");
    const domains = [...new Set(
      members.map(m => (m.email || "").split("@")[1]).filter(Boolean)
    )].slice(0, 5);
    const sources = [...new Set(
      members.map(m => m.signup_source).filter(Boolean)
    )].slice(0, 3);
    const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);

    const res = await fetch(`${MB}/personas`, {
      method: "POST", headers: mvH,
      body: JSON.stringify({
        name: `SG: ${cap(role)} / ${cap(industry)} / ${cap(engagement)}`,
        description: `SendGrid segment. Role: ${role}. Industry: ${industry}. Engagement: ${engagement}. N=${members.length}. Domains: ${domains.slice(0, 3).join(", ")}. Sources: ${sources.join(", ")}.`,
        demographic: { job_titles: [role], industries: [industry] },
        psychographic: { engagement_level: engagement, signup_sources: sources },
      }),
    });
    const persona = await res.json();
    personas.push({ cluster: key, id: persona.id, n: members.length });
    console.log(`  ${cap(role)} / ${cap(industry)} / ${cap(engagement)}: ${persona.id} (${members.length})`);
    await new Promise(r => setTimeout(r, 300));
  }

  console.log(`\nCreated ${personas.length} personas from ${contacts.length} contacts`);
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
Export triggered: exp_8f3a2b1c
  Export status: pending (attempt 1)
  Export status: pending (attempt 2)
  Export status: ready (attempt 3)
Downloaded 8,432 contacts
Significant clusters (10+): 14

  Marketing Manager / Saas / High: per_sg_b1c2 (342)
  Director / Technology / Medium: per_sg_d3e4 (218)
  Founder / Ecommerce / High: per_sg_f5g6 (187)
  Vp Marketing / Fintech / Medium: per_sg_h7i8 (93)
  Product Manager / Healthcare / Low: per_sg_j9k0 (64)
  ...

Created 14 personas from 8,432 contacts
```

### Error Handling

<AccordionGroup>
  <Accordion title="Export takes too long">Large contact lists (100K+) can take 5-10 minutes to export. The code polls for 5 minutes (30 attempts × 10s). Increase for very large lists or use segment-specific exports.</Accordion>
  <Accordion title="CSV parsing edge cases">Contact fields may contain commas within quoted strings. The JavaScript parser uses regex matching for quoted fields. For production, use a proper CSV parser like `csv-parse` (Node) or Python's built-in `csv` module.</Accordion>
  <Accordion title="Custom field names">Custom fields in SendGrid use the names you defined. Check your fields at `GET /v3/marketing/field_definitions`. Common patterns: `custom_industry`, `custom_role`, or the exact name you set.</Accordion>
  <Accordion title="Export rate limit">You can only have one active export at a time. If a previous export is in progress, the API returns 429. Wait for it to complete before triggering a new one.</Accordion>
</AccordionGroup>
