# Create and advance CRM deals from replies and meetings

A webhook handler that opens a CRM deal on a lead's first positive reply, and a scheduled poll of bookings that moves the deal on when a meeting is booked.

Works with: Victoria CRM, cron.

Endpoints used:

- [`GET /v1/leads`](https://docs.versionseven.ai/api-reference/leads/list-leads) List leads
- [`GET /v1/crm/deals`](https://docs.versionseven.ai/api-reference/crm-deals/list-deals) List deals
- [`GET /v1/crm/pipelines`](https://docs.versionseven.ai/api-reference/crm-pipelines/list-pipelines) List pipelines
- [`GET /v1/crm/pipelines/{pipeline_id}`](https://docs.versionseven.ai/api-reference/crm-pipelines/retrieve-pipeline) Retrieve a pipeline
- [`POST /v1/crm/deals`](https://docs.versionseven.ai/api-reference/crm-deals/create-deal) Create a deal
- [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) List campaigns
- [`GET /v1/campaigns/{campaign_id}/bookings`](https://docs.versionseven.ai/api-reference/campaigns/list-bookings) List meetings booked
- [`PATCH /v1/crm/deals/{deal_id}`](https://docs.versionseven.ai/api-reference/crm-deals/update-deal) Update a deal

Victoria AI's CRM tracks deals, but a reply doesn't create one by itself. This recipe does, in two parts. A handler for [Receive replies once and fan them out](https://docs.versionseven.ai/cookbook/webhook-receiver) opens a deal the first time a lead replies positively, in the stage you choose. A small script you run on a schedule reads each campaign's bookings and moves the lead's deal to your meeting stage when a meeting is booked. Together they give you a pipeline that fills itself from outreach, without anyone retyping a name.

## Before you start

- The receiver from [Receive replies once and fan them out](https://docs.versionseven.ai/cookbook/webhook-receiver), for the handler.
- An API key with `leads:read`, `crm:write` and `campaigns:read` in `VICTORIA_API_KEY`. See [Authentication](https://docs.versionseven.ai/guides/authentication).
- A pipeline in **CRM** in the app. A new workspace has one with the stages New Lead, Contacted, Qualified, Proposal Sent, Closed Won and Closed Lost; the scripts find the default pipeline and look stages up by name.
- Optionally `DEAL_STAGE_REPLY` (default `Qualified`) and `DEAL_STAGE_MEETING` (default: a stage called `Meeting Booked` or `Booked`, else `Closed Won`, which is what the app does when a person marks a meeting booked).
- Node.js 18 or later, or Python 3.10 or later with `requests`.

## How the two parts fit

1. **On a positive reply**, the handler finds the lead with [`GET /v1/leads`](https://docs.versionseven.ai/api-reference/leads/list-leads): the payload carries the lead's email and name but no id. A lead with that exact email, or (for LinkedIn-only leads) one lead matching the name and company, is the one. If it finds none or several, it logs and stops rather than guess.
2. It checks [`GET /v1/crm/deals`](https://docs.versionseven.ai/api-reference/crm-deals/list-deals) with `lead_id` for an existing deal. If there is one, it stops: a later positive reply from the same lead doesn't open a second deal.
3. It reads the default pipeline from [`GET /v1/crm/pipelines`](https://docs.versionseven.ai/api-reference/crm-pipelines/list-pipelines) and [`GET /v1/crm/pipelines/{pipeline_id}`](https://docs.versionseven.ai/api-reference/crm-pipelines/retrieve-pipeline), finds the reply stage by name, and creates the deal with [`POST /v1/crm/deals`](https://docs.versionseven.ai/api-reference/crm-deals/create-deal), named after the lead and company, with the reply in its notes.
4. **On a schedule**, the bookings script lists campaigns with [`GET /v1/campaigns`](https://docs.versionseven.ai/api-reference/campaigns/list-campaigns) and reads [`GET /v1/campaigns/{campaign_id}/bookings`](https://docs.versionseven.ai/api-reference/campaigns/list-bookings) with `since` set to its last run. Each booking carries the matched `lead`, so for every new one it finds the lead's deal and moves it with [`PATCH /v1/crm/deals/{deal_id}`](https://docs.versionseven.ai/api-reference/crm-deals/update-deal). A lead with no deal yet (a meeting booked without a positive-reply delivery first) gets one created in the meeting stage.

Meetings are polled, not received: Victoria AI's webhook fires on a lead's first reply and again when a later reply turns positive, never for a booking on its own. `bookings` holds calendar bookings matched to the campaign (Calendly, when connected in the app) and leads the Appointment Setter or a person confirmed as booked.

## Part 1: the handler

**Node.js**

```javascript
// handlers/deals.mjs
const API_URL = "https://api.versionseven.ai/v1";
const API_KEY = process.env.VICTORIA_API_KEY;
const REPLY_STAGE = process.env.DEAL_STAGE_REPLY ?? "Qualified";

export async function call(method, path, body) {
  const response = await fetch(`${API_URL}${path}`, {
    method,
    headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  const data = await response.json().catch(() => ({}));
  if (!response.ok) throw new Error(`${method} ${path} answered ${response.status} ${data.error ?? ""}`);
  return data;
}

// The lead the payload describes, by exact email or by name and company; null when ambiguous.
export async function findLead(lead) {
  if (lead.email) {
    const { leads } = await call("GET", `/leads?search=${encodeURIComponent(lead.email)}&limit=100`);
    const exact = leads.filter((candidate) => candidate.email?.toLowerCase() === lead.email.toLowerCase());
    return exact.length === 1 ? exact[0] : null;
  }
  const name = [lead.first_name, lead.last_name].filter(Boolean).join(" ");
  if (!name || !lead.company) return null;
  const { leads } = await call("GET", `/leads?search=${encodeURIComponent(name)}&limit=100`);
  const matches = leads.filter((candidate) => candidate.company?.toLowerCase() === lead.company.toLowerCase());
  return matches.length === 1 ? matches[0] : null;
}

// The default pipeline's stages, by lower-case name.
export async function stages() {
  const { pipelines } = await call("GET", "/crm/pipelines?limit=100");
  const pipeline = pipelines.find((candidate) => candidate.is_default) ?? pipelines[0];
  if (!pipeline) throw new Error("No CRM pipeline; create one in the app first");
  const detail = await call("GET", `/crm/pipelines/${pipeline.id}`);
  return new Map(detail.stages.filter((stage) => stage.is_active).map((stage) => [stage.name.toLowerCase(), stage]));
}

export async function existingDeal(leadId) {
  const { deals } = await call("GET", `/crm/deals?lead_id=${leadId}&limit=10`);
  return deals[0] ?? null;
}

export default async function handle({ event }) {
  if (event.ai_response?.sentiment !== "positive") return;
  const payloadLead = event.lead ?? {};
  const lead = await findLead(payloadLead);
  if (!lead) {
    console.log(`No single lead matches ${payloadLead.email ?? payloadLead.first_name}; no deal created`);
    return;
  }
  if (await existingDeal(lead.id)) return; // one deal per lead

  const stage = (await stages()).get(REPLY_STAGE.toLowerCase());
  if (!stage) throw new Error(`No stage called "${REPLY_STAGE}" in the default pipeline`);
  const name = [lead.first_name, lead.last_name].filter(Boolean).join(" ");
  const deal = await call("POST", "/crm/deals", {
    name: lead.company ? `${lead.company}: ${name}` : name,
    lead_id: lead.id,
    stage_id: stage.id,
    notes: `Positive reply to "${event.campaign}" (${event.channel}):\n${event.prospect_message ?? ""}`,
  });
  console.log(`Deal ${deal.deal.id} opened for ${name} in ${stage.name}`);
}
```

**Python**

```python
# handlers/deals.py
import os

import requests

API_URL = "https://api.versionseven.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['VICTORIA_API_KEY']}"}
REPLY_STAGE = os.environ.get("DEAL_STAGE_REPLY", "Qualified")


def call(method, path, body=None):
    response = requests.request(method, f"{API_URL}{path}", headers=HEADERS, json=body, timeout=30)
    try:
        data = response.json()
    except ValueError:
        data = {}
    if not response.ok:
        raise RuntimeError(f"{method} {path} answered {response.status_code} {data.get('error', '')}")
    return data


def find_lead(lead: dict):
    """The lead the payload describes, by exact email or by name and company; None when ambiguous."""
    if lead.get("email"):
        leads = call("GET", f"/leads?search={requests.utils.quote(lead['email'])}&limit=100")["leads"]
        exact = [c for c in leads if (c.get("email") or "").lower() == lead["email"].lower()]
        return exact[0] if len(exact) == 1 else None
    name = " ".join(part for part in (lead.get("first_name"), lead.get("last_name")) if part)
    if not name or not lead.get("company"):
        return None
    leads = call("GET", f"/leads?search={requests.utils.quote(name)}&limit=100")["leads"]
    matches = [c for c in leads if (c.get("company") or "").lower() == lead["company"].lower()]
    return matches[0] if len(matches) == 1 else None


def stages():
    """The default pipeline's stages, by lower-case name."""
    pipelines = call("GET", "/crm/pipelines?limit=100")["pipelines"]
    pipeline = next((p for p in pipelines if p.get("is_default")), pipelines[0] if pipelines else None)
    if not pipeline:
        raise RuntimeError("No CRM pipeline; create one in the app first")
    detail = call("GET", f"/crm/pipelines/{pipeline['id']}")
    return {stage["name"].lower(): stage for stage in detail["stages"] if stage.get("is_active")}


def existing_deal(lead_id):
    deals = call("GET", f"/crm/deals?lead_id={lead_id}&limit=10")["deals"]
    return deals[0] if deals else None


def handle(delivery: dict) -> None:
    event = delivery["event"]
    if (event.get("ai_response") or {}).get("sentiment") != "positive":
        return
    payload_lead = event.get("lead") or {}
    lead = find_lead(payload_lead)
    if not lead:
        print(f"No single lead matches {payload_lead.get('email') or payload_lead.get('first_name')}; no deal created", flush=True)
        return
    if existing_deal(lead["id"]):
        return  # one deal per lead

    stage = stages().get(REPLY_STAGE.lower())
    if not stage:
        raise RuntimeError(f'No stage called "{REPLY_STAGE}" in the default pipeline')
    name = " ".join(part for part in (lead.get("first_name"), lead.get("last_name")) if part)
    deal = call(
        "POST",
        "/crm/deals",
        {
            "name": f"{lead['company']}: {name}" if lead.get("company") else name,
            "lead_id": lead["id"],
            "stage_id": stage["id"],
            "notes": f'Positive reply to "{event.get("campaign")}" ({event.get("channel")}):\n{event.get("prospect_message") or ""}',
        },
    )
    print(f"Deal {deal['deal']['id']} opened for {name} in {stage['name']}", flush=True)
```

Start the receiver with `HANDLERS=deals` (plus anything else) and the API key in the environment. Send the first signed example from `GET /webhooks/examples`, a positive reply from John Doe at Example Corp: a deal called `Example Corp: John Doe` appears in Qualified, if a lead with that email exists in your workspace. Send it again and nothing changes.

## Part 2: the bookings poll

**Node.js**

```javascript
// bookings.mjs
import { readFile, writeFile } from "node:fs/promises";
import { call, existingDeal, stages } from "./handlers/deals.mjs";

const STATE_FILE = process.env.STATE_FILE ?? "bookings-state.json";
const MEETING_STAGE = process.env.DEAL_STAGE_MEETING;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const state = await readFile(STATE_FILE, "utf8").then(JSON.parse).catch(() => ({}));
const runStarted = new Date().toISOString();
const allStages = await stages();
const meetingStage =
  (MEETING_STAGE && allStages.get(MEETING_STAGE.toLowerCase())) ||
  allStages.get("meeting booked") ||
  allStages.get("booked") ||
  allStages.get("closed won");
if (!meetingStage) throw new Error("No meeting stage found; set DEAL_STAGE_MEETING");

const summary = { bookings: 0, moved: 0, created: 0, unmatched: 0 };
const { campaigns } = await call("GET", "/campaigns?limit=500");
for (const campaign of campaigns) {
  const since = state[campaign.id] ?? "1970-01-01T00:00:00Z";
  const { bookings } = await call("GET", `/campaigns/${campaign.id}/bookings?since=${encodeURIComponent(since)}`);
  for (const booking of bookings) {
    if (booking.canceled_at || booking.status === "canceled") continue;
    summary.bookings++;
    if (!booking.lead) {
      summary.unmatched++;
      console.log(`${campaign.name}: booking ${booking.id} isn't matched to a lead (${booking.invitee_email ?? "no invitee email"})`);
      continue;
    }
    const deal = await existingDeal(booking.lead.id);
    const name = [booking.lead.first_name, booking.lead.last_name].filter(Boolean).join(" ");
    if (deal) {
      if (deal.stage_id !== meetingStage.id) {
        await call("PATCH", `/crm/deals/${deal.id}`, { stage_id: meetingStage.id });
        summary.moved++;
        console.log(`${campaign.name}: ${name} → ${meetingStage.name}`);
      }
    } else {
      await call("POST", "/crm/deals", {
        name: booking.lead.company ? `${booking.lead.company}: ${name}` : name,
        lead_id: booking.lead.id,
        stage_id: meetingStage.id,
        notes: `Meeting booked from "${campaign.name}" (${booking.kind}, ${booking.source})${booking.start_time ? ` for ${booking.start_time}` : ""}`,
      });
      summary.created++;
      console.log(`${campaign.name}: ${name} → new deal in ${meetingStage.name}`);
    }
    await sleep(650);
  }
  state[campaign.id] = runStarted; // next run reads only bookings made after this one started
}

await writeFile(STATE_FILE, JSON.stringify(state, null, 2));
console.log(JSON.stringify(summary));
```

**Python**

```python
# bookings.py
import json
import os
import time
from datetime import datetime, timezone
from urllib.parse import quote

from handlers.deals import call, existing_deal, stages

STATE_FILE = os.environ.get("STATE_FILE", "bookings-state.json")
MEETING_STAGE = os.environ.get("DEAL_STAGE_MEETING")


def main():
    try:
        with open(STATE_FILE, encoding="utf-8") as file:
            state = json.load(file)
    except (FileNotFoundError, ValueError):
        state = {}
    run_started = datetime.now(timezone.utc).isoformat()
    all_stages = stages()
    meeting_stage = (
        (MEETING_STAGE and all_stages.get(MEETING_STAGE.lower()))
        or all_stages.get("meeting booked")
        or all_stages.get("booked")
        or all_stages.get("closed won")
    )
    if not meeting_stage:
        raise SystemExit("No meeting stage found; set DEAL_STAGE_MEETING")

    summary = {"bookings": 0, "moved": 0, "created": 0, "unmatched": 0}
    for campaign in call("GET", "/campaigns?limit=500")["campaigns"]:
        since = state.get(campaign["id"], "1970-01-01T00:00:00Z")
        bookings = call("GET", f"/campaigns/{campaign['id']}/bookings?since={quote(since)}")["bookings"]
        for booking in bookings:
            if booking.get("canceled_at") or booking.get("status") == "canceled":
                continue
            summary["bookings"] += 1
            lead = booking.get("lead")
            if not lead:
                summary["unmatched"] += 1
                print(f"{campaign['name']}: booking {booking['id']} isn't matched to a lead ({booking.get('invitee_email') or 'no invitee email'})")
                continue
            name = " ".join(part for part in (lead.get("first_name"), lead.get("last_name")) if part)
            deal = existing_deal(lead["id"])
            if deal:
                if deal.get("stage_id") != meeting_stage["id"]:
                    call("PATCH", f"/crm/deals/{deal['id']}", {"stage_id": meeting_stage["id"]})
                    summary["moved"] += 1
                    print(f"{campaign['name']}: {name} → {meeting_stage['name']}")
            else:
                when = f" for {booking['start_time']}" if booking.get("start_time") else ""
                call(
                    "POST",
                    "/crm/deals",
                    {
                        "name": f"{lead['company']}: {name}" if lead.get("company") else name,
                        "lead_id": lead["id"],
                        "stage_id": meeting_stage["id"],
                        "notes": f'Meeting booked from "{campaign["name"]}" ({booking["kind"]}, {booking["source"]}){when}',
                    },
                )
                summary["created"] += 1
                print(f"{campaign['name']}: {name} → new deal in {meeting_stage['name']}")
            time.sleep(0.65)
        state[campaign["id"]] = run_started  # next run reads only bookings made after this one started

    with open(STATE_FILE, "w", encoding="utf-8") as file:
        json.dump(state, file, indent=2)
    print(json.dumps(summary))


if __name__ == "__main__":
    main()
```

The poll imports the handler's helpers, so run it from the receiver's directory:

```bash
node bookings.mjs
python bookings.py
```

It prints a line per booking it acted on and a summary such as `{"bookings": 1, "moved": 1, "created": 0, "unmatched": 0}`. The next run reads only bookings made since this one started, so hourly is fine; a cron line like [the digest's](https://docs.versionseven.ai/cookbook/daily-campaign-digest#schedule-it) works.

## What to expect

- **Matching is deliberately strict.** The webhook payload has no lead id. The handler matches by exact email, or by name plus company for LinkedIn-only leads, and does nothing when that's ambiguous; the receiver's log says so, and the deal can be created by hand. Bookings carry the matched `lead` with its id, so the poll doesn't guess.
- **One deal per lead.** Both parts check `GET /crm/deals?lead_id=` first. A lead who replies positively twice, or books after replying, has one deal that moves forward.
- **A meeting is a claim, not a calendar event, unless Calendly is connected.** Bookings of `kind: "confirmation"` are leads the Setter or a person marked as booked (`goal_confirmed`, `manual`), with no time; `kind: "meeting"` bookings come from a connected Calendly and carry `start_time`. [Meetings in analytics](https://docs.versionseven.ai/help/analytics-metrics) are counted the same way.
- **Cancellations don't move deals back.** A canceled Calendly booking has `canceled_at` set and is skipped; the deal stays where it is for a person to decide.
- The default meeting stage mirrors the app: when someone marks a meeting booked in the Inbox, the deal moves to a stage called Meeting Booked, Booked or Closed Won, in that order of preference.

## Next steps

- [Receive replies once and fan them out](https://docs.versionseven.ai/cookbook/webhook-receiver), the receiver the handler runs in.
- [Turn Appointment Setter hand-offs into Linear or Asana tasks](https://docs.versionseven.ai/cookbook/route-handoffs-to-tasks), the handler for the conversations that need a person.
- [Simple CRM](https://docs.versionseven.ai/guides/crm) for pipelines, stages and deal fields.
