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

# Webhook Event → Mave Research Trigger

> Listen for Customer.io reporting webhooks on churn signals and trigger Mave Agent retention research tailored to the customer's persona type

### Scenario

Customer.io's reporting webhooks fire on critical events — email bounces, unsubscribes, conversions, subscription changes. When a high-value customer unsubscribes, you don't want to find out in a weekly report. This job sets up a webhook receiver that listens for specific events, maps the customer to a persona type using their attributes, then triggers Mave Agent to research retention strategies tailored to that persona. The result is an immediate, actionable research brief every time a churn signal fires.

**Flow:** Customer.io Reporting Webhook → Filter high-value events → Look up customer attributes → Map to persona type → Mavera `POST /api/v1/mave/chat`: "Research retention strategies for \{persona type}" → Research brief

### Architecture

```mermaid theme={"dark"}
flowchart LR
A["Reporting Webhook"] --> B["Filter high-value unsubscribes"] --> C["GET customer attributes"] --> D["Classify persona type"] --> E["POST /api/v1/mave/chat"] --> F["Store brief + alert team"]
```

### Code

<CodeGroup>
  ```python Python theme={"dark"}
  import os, json, requests, hmac, hashlib
  from http.server import HTTPServer, BaseHTTPRequestHandler

  CIO_APP = os.environ["CIO_APP_KEY"]
  CIO_WEBHOOK_SECRET = os.environ.get("CIO_WEBHOOK_SECRET", "")
  MV = os.environ["MAVERA_API_KEY"]
  APP_BASE = "https://api.customer.io/v1"
  MB = "https://app.mavera.io/api/v1"
  APP_H = {"Authorization": f"Bearer {CIO_APP}"}
  MV_H = {"Authorization": f"Bearer {MV}", "Content-Type": "application/json"}

  HIGH_VALUE_PLANS = {"pro", "enterprise", "business"}
  ALERT_EVENTS = {"unsubscribed", "bounced", "complained"}

  def verify_signature(payload: bytes, signature: str) -> bool:
      if not CIO_WEBHOOK_SECRET:
          return True
      expected = hmac.new(
          CIO_WEBHOOK_SECRET.encode(), payload, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, signature)

  def classify_persona(attrs: dict) -> str:
      plan = (attrs.get("plan_type") or "free").lower()
      industry = (attrs.get("industry") or "general").lower()
      usage = (attrs.get("usage_tier") or "low").lower()
      mrr = float(attrs.get("mrr", 0) or 0)

      if mrr > 500:
          return f"High-MRR {industry.title()} ({plan.title()} plan)"
      if plan in ("enterprise", "business"):
          return f"Enterprise {industry.title()} ({usage} usage)"
      return f"{plan.title()} {industry.title()} User"

  def handle_churn_signal(event: dict):
      customer_id = event.get("customer_id", event.get("identifiers", {}).get("id"))
      event_type = event.get("event_type", event.get("metric", "unknown"))
      timestamp = event.get("timestamp", "")

      if not customer_id:
          print(f"No customer_id in event: {event_type}")
          return

      # 1. Enrich with customer attributes
      r = requests.get(f"{APP_BASE}/customers/{customer_id}/attributes",
          headers=APP_H)
      if not r.ok:
          print(f"Could not fetch attributes for {customer_id}: {r.status_code}")
          attrs = {}
      else:
          attrs = r.json().get("customer", {})

      plan = (attrs.get("plan_type") or "free").lower()
      if plan not in HIGH_VALUE_PLANS:
          print(f"Skipping free-tier {event_type} for {customer_id}")
          return

      persona_type = classify_persona(attrs)
      email = attrs.get("email", "unknown")
      mrr = attrs.get("mrr", "N/A")
      tenure_days = attrs.get("tenure_days", "N/A")
      last_active = attrs.get("last_active_at", "N/A")

      # 2. Research retention strategies
      research = requests.post(f"{MB}/mave/chat", headers=MV_H, json={
          "message": f"""A high-value customer just triggered a churn signal.

  EVENT: {event_type}
  PERSONA TYPE: {persona_type}
  MRR: ${mrr}
  TENURE: {tenure_days} days
  LAST ACTIVE: {last_active}
  PLAN: {plan}
  INDUSTRY: {attrs.get('industry', 'N/A')}

  Research retention strategies for this persona type:
  1) Common reasons this persona type churns in {attrs.get('industry', 'SaaS')}
  2) Proven retention tactics (with examples from similar companies)
  3) Win-back email sequence outline (3 emails)
  4) Offer structure that works for {plan} tier
  5) Timing recommendations for outreach
  6) Signals to watch for re-engagement potential"""
      }).json()

      brief = research.get("content", "")
      sources = research.get("sources", [])

      print(f"\n{'='*60}")
      print(f"CHURN ALERT: {event_type} | {email} | {persona_type}")
      print(f"MRR: ${mrr} | Tenure: {tenure_days}d | Plan: {plan}")
      print(f"{'='*60}")
      print(brief[:2000])
      print(f"\nSources: {len(sources)}")

      return {
          "customer_id": customer_id,
          "event_type": event_type,
          "persona_type": persona_type,
          "research_brief": brief,
          "sources": sources,
      }


  class WebhookHandler(BaseHTTPRequestHandler):
      def do_POST(self):
          length = int(self.headers.get("Content-Length", 0))
          body = self.rfile.read(length)
          sig = self.headers.get("X-CIO-Signature", "")

          if not verify_signature(body, sig):
              self.send_response(401)
              self.end_headers()
              return

          event = json.loads(body)
          event_type = event.get("event_type", event.get("metric", ""))

          if event_type in ALERT_EVENTS:
              result = handle_churn_signal(event)
              self.send_response(200)
              self.end_headers()
              self.wfile.write(json.dumps(result or {}).encode())
          else:
              self.send_response(200)
              self.end_headers()

      def log_message(self, format, *args):
          pass


  if __name__ == "__main__":
      server = HTTPServer(("0.0.0.0", 8080), WebhookHandler)
      print("Webhook listener on :8080")
      server.serve_forever()
  ```

  ```javascript JavaScript theme={"dark"}
  import http from "node:http";
  import crypto from "node:crypto";

  const CIO_APP = process.env.CIO_APP_KEY;
  const CIO_WEBHOOK_SECRET = process.env.CIO_WEBHOOK_SECRET || "";
  const MV = process.env.MAVERA_API_KEY;
  const APP_BASE = "https://api.customer.io/v1";
  const MB = "https://app.mavera.io/api/v1";
  const appH = { Authorization: `Bearer ${CIO_APP}` };
  const mvH = { Authorization: `Bearer ${MV}`, "Content-Type": "application/json" };

  const HIGH_VALUE_PLANS = new Set(["pro", "enterprise", "business"]);
  const ALERT_EVENTS = new Set(["unsubscribed", "bounced", "complained"]);

  function verifySignature(payload, signature) {
    if (!CIO_WEBHOOK_SECRET) return true;
    const expected = crypto
      .createHmac("sha256", CIO_WEBHOOK_SECRET)
      .update(payload)
      .digest("hex");
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  }

  function classifyPersona(attrs) {
    const plan = (attrs.plan_type || "free").toLowerCase();
    const industry = (attrs.industry || "general").toLowerCase();
    const usage = (attrs.usage_tier || "low").toLowerCase();
    const mrr = parseFloat(attrs.mrr || "0");
    const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);

    if (mrr > 500) return `High-MRR ${cap(industry)} (${cap(plan)} plan)`;
    if (["enterprise", "business"].includes(plan))
      return `Enterprise ${cap(industry)} (${usage} usage)`;
    return `${cap(plan)} ${cap(industry)} User`;
  }

  async function handleChurnSignal(event) {
    const customerId = event.customer_id || event.identifiers?.id;
    const eventType = event.event_type || event.metric || "unknown";

    if (!customerId) { console.log(`No customer_id in: ${eventType}`); return null; }

    // 1. Enrich
    const attrRes = await fetch(`${APP_BASE}/customers/${customerId}/attributes`,
      { headers: appH });
    const attrs = attrRes.ok ? (await attrRes.json()).customer || {} : {};

    const plan = (attrs.plan_type || "free").toLowerCase();
    if (!HIGH_VALUE_PLANS.has(plan)) {
      console.log(`Skipping free-tier ${eventType} for ${customerId}`);
      return null;
    }

    const personaType = classifyPersona(attrs);

    // 2. Research
    const research = await fetch(`${MB}/mave/chat`, {
      method: "POST", headers: mvH,
      body: JSON.stringify({
        message: `A high-value customer triggered a churn signal.
  EVENT: ${eventType} | PERSONA: ${personaType} | MRR: $${attrs.mrr || "N/A"}
  TENURE: ${attrs.tenure_days || "N/A"} days | PLAN: ${plan} | INDUSTRY: ${attrs.industry || "N/A"}

  Research retention strategies for this persona type:
  1) Common churn reasons for this type 2) Proven retention tactics
  3) Win-back email sequence (3 emails) 4) Offer structure for ${plan} tier
  5) Timing recommendations 6) Re-engagement signals`,
      }),
    }).then(r => r.json());

    console.log(`\n${"=".repeat(60)}`);
    console.log(`CHURN ALERT: ${eventType} | ${attrs.email || "?"} | ${personaType}`);
    console.log(`MRR: $${attrs.mrr || "N/A"} | Tenure: ${attrs.tenure_days || "?"}d`);
    console.log(`${"=".repeat(60)}`);
    console.log((research.content || "").slice(0, 2000));

    return { customer_id: customerId, event_type: eventType, persona_type: personaType,
      research_brief: research.content, sources: research.sources || [] };
  }

  const server = http.createServer(async (req, res) => {
    if (req.method !== "POST") { res.writeHead(405); res.end(); return; }

    const chunks = [];
    for await (const chunk of req) chunks.push(chunk);
    const body = Buffer.concat(chunks);
    const sig = req.headers["x-cio-signature"] || "";

    if (!verifySignature(body, sig)) { res.writeHead(401); res.end(); return; }

    const event = JSON.parse(body.toString());
    const eventType = event.event_type || event.metric || "";

    if (ALERT_EVENTS.has(eventType)) {
      const result = await handleChurnSignal(event);
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify(result || {}));
    } else {
      res.writeHead(200); res.end();
    }
  });

  server.listen(8080, () => console.log("Webhook listener on :8080"));
  ```
</CodeGroup>

### Example Output

```text theme={"dark"}
============================================================
CHURN ALERT: unsubscribed | jane@acme.co | High-MRR Fintech (Pro plan)
MRR: $850 | Tenure: 342d | Plan: pro
============================================================

## Retention Analysis: High-MRR Fintech Pro User

### Common Churn Reasons
1. Feature ceiling — Pro plan lacks API access they've outgrown
2. Compliance concerns — Fintech customers need SOC 2 + audit logs
3. Champion departure — Primary user left; no secondary adopter
4. Competitor poaching — 60% of fintech churn involves direct outreach

### Win-Back Sequence
**Email 1 (Day 0):** Personal note from CS lead. "We noticed you
unsubscribed — was something off?" Low pressure, genuine curiosity.
**Email 2 (Day 3):** Share roadmap preview relevant to fintech.
"We're shipping audit logs in 3 weeks — you asked for this."
**Email 3 (Day 7):** Offer: 30-day Enterprise trial at Pro price.
Include case study from similar fintech customer.

### Offer Structure
- 30-day Enterprise upgrade at current rate (test if features fix it)
- Quarterly billing option (reduce perceived commitment)
- Dedicated onboarding session for team beyond primary user

### Timing
- First outreach within 4 hours of unsubscribe
- Avoid end-of-month (budget stress in fintech)
- Best response rates: Tuesday 10am–12pm local time

Sources: 3
```

### Error Handling

<AccordionGroup>
  <Accordion title="Webhook signature verification">Customer.io signs webhooks with HMAC SHA-256. Set `CIO_WEBHOOK_SECRET` from your workspace settings. The code gracefully skips verification if no secret is set (dev mode only — always verify in production).</Accordion>
  <Accordion title="Customer attribute lookup fails">If the customer was deleted or the App API is down, attributes will be empty. The code continues with defaults but the persona classification will be generic. Consider caching attributes locally.</Accordion>
  <Accordion title="Webhook event schema variations">Customer.io uses `event_type` in some webhook versions and `metric` in others. The code checks both fields. Test with Customer.io's webhook tester before deploying.</Accordion>
  <Accordion title="High webhook volume">During a campaign blast, hundreds of events may fire simultaneously. Use a queue (Redis, SQS) between the webhook handler and Mave calls to avoid overwhelming Mavera's rate limits.</Accordion>
</AccordionGroup>

***

## What's Next

<CardGroup cols={2}>
  <Card title="Customer.io Integration" icon="message" href="/integrations/customer-io">
    Back to Customer.io integration overview
  </Card>

  <Card title="Customer Attribute Personas" icon="user" href="/integrations/customer-io/attribute-personas">
    Build attribute-clustered personas from Customer.io segments
  </Card>

  <Card title="Campaign Messaging Strategy" icon="chart-line" href="/integrations/customer-io/campaign-messaging-strategy">
    Analyze campaign metrics for winning patterns
  </Card>

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