Cookbook
View as Markdown

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.

Last updated

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 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, for the handler.
  • An API key with leads:read, crm:write and campaigns:read in VICTORIA_API_KEY. See 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: 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 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 and GET /v1/crm/pipelines/{pipeline_id}, finds the reply stage by name, and creates the deal with POST /v1/crm/deals, 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 and reads GET /v1/campaigns/{campaign_id}/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}. 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

// 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}`);
}

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

// 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));

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

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