Full SOP - Step by Step

Build an AI-Powered
Revenue Engine

Replace your $10,000/year lead routing tool with a system you own. Capture, qualify, route, book, enrich, and recover - all serverless, under $50/month.

The Pipeline

Capture

Lead enters email
on your website

-

Qualify

Multi-step form
collects info

-

Route

Custom logic picks
the next step

-

Book

Qualified leads
book a call

-

Enrich

Score + brief
before the call

-

Recover

Drop-off detected
+ auto nurture

The end result: Every lead gets the right experience. Your team gets context before every call. No lead falls through the cracks. When the logic needs to change, describe it in a sentence - live in 5 minutes.
Tech Stack
Next.js + Vercel

Frontend + API routes
+ zero-config hosting

Supabase

Postgres database
for permanent logs

Upstash

Redis for temp state
QStash for task queue

Your CRM

Attio, HubSpot,
or Salesforce

Email Tool

Customer.io, Loops,
or ActiveCampaign

Slack + Calendly

Real-time alerts +
calendar booking

Why this stack: Everything is serverless. No servers to manage, scales automatically. Upstash, Supabase, and Vercel all have generous free tiers. Total cost at low volume: ~$0. At production volume: under $50/month.
Architecture
How the System Connects
Your website captures the email and redirects to the lead router. The router is a Next.js app on Vercel with serverless API routes. Each route talks to your integrations independently.
Your Site (any CMS)         Lead Router (Next.js on Vercel)       Integrations
┌─────────────────┐       ┌──────────────────────────┐       ┌─────────────┐
│                 │       │                          │       │             │
│  Email form     │──────>│  /api/lead               │──────>│  CRM        │
│  redirects to   │       │  /api/qualify            │       │  Slack      │
│  lead router    │       │  /api/booking            │       │  Email tool │
│                 │       │  /api/precall            │       │  Calendly   │
│                 │       │  /api/check-progress     │       │  Enrichment │
└─────────────────┘       └────────────┬─────────────┘       └─────────────┘
                                       │
                          ┌────────────┴─────────────┐
                          │  State Layer              │
                          │  Redis = temp lead state  │
                          │  Postgres = permanent log │
                          └──────────────────────────┘
Serverless
Each endpoint is an independent function. Scales to zero. No servers to manage.
Dual-Layer State
Redis holds temp lead state (24h TTL). Postgres keeps the permanent audit trail.
Graceful Degradation
Every integration is try/catch. If Slack is down, the lead still gets routed.
Phase 1

Project Setup

Create the project, install dependencies, configure environment variables, set up the database, and organize the file structure.

01

Create the Next.js Project

Terminal commands - takes 2 minutes
  1. 1
    Initialize the project

    Creates a Next.js app with TypeScript, Tailwind, and the App Router.

  2. 2
    Install core dependencies

    Upstash for Redis + QStash, Supabase client, Zod for validation.

  3. 3
    Optional: install Framer Motion

    For animated transitions between form steps. Not required but makes it feel smooth.

npx create-next-app@latest lead-router --typescript --tailwind --app
cd lead-router

npm install @upstash/redis @upstash/qstash @supabase/supabase-js zod
npm install framer-motion  # optional
02

Set Up Environment Variables

Create .env.local in your project root
Every integration needs its own credentials. Create a .env.local file and fill these in as you set up each service.
  • Redis (Upstash)
  • UPSTASH_REDIS_REST_URL
  • UPSTASH_REDIS_REST_TOKEN
  • Task Queue (QStash)
  • QSTASH_TOKEN
  • QSTASH_CURRENT_SIGNING_KEY
  • QSTASH_NEXT_SIGNING_KEY
  • Database (Supabase)
  • SUPABASE_URL
  • SUPABASE_SERVICE_KEY
  • CRM
  • CRM_API_KEY
  • Calendar
  • CALENDLY_API_TOKEN
  • Email Tool
  • EMAIL_TOOL_WEBHOOK_URL
  • Slack
  • SLACK_WEBHOOK_URL
  • SLACK_BOOKED_WEBHOOK_URL
  • App
  • APP_URL=http://localhost:3000
Important: Never prefix API keys with NEXT_PUBLIC_ - those get exposed to the browser. All secrets stay server-side only.
03

Create the Supabase Tables

Run these in the Supabase SQL editor
Two tables. One logs every lead submission. One logs every step of the pipeline for full observability.
-- Table 1: Every lead submission
CREATE TABLE lead_submissions (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  email TEXT NOT NULL,
  status TEXT DEFAULT 'email_captured',
  form_data JSONB,
  tracking JSONB,
  source_url TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Table 2: Audit trail for every pipeline step
CREATE TABLE pipeline_logs (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  email TEXT,
  domain TEXT,
  route TEXT,
  step TEXT NOT NULL,
  status TEXT NOT NULL,
  details JSONB,
  duration_ms INTEGER,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
04

Organize the File Structure

One file = one responsibility
Five API routes, four frontend components, one state machine hook, and helper libraries for each integration.
src/
├── app/
│   ├── api/
│   │   ├── lead/route.ts            ← Email capture
│   │   ├── qualify/route.ts         ← Form submission + routing
│   │   ├── booking/route.ts         ← Calendar webhook handler
│   │   ├── precall/route.ts         ← Enrichment + scoring pipeline
│   │   └── check-progress/route.ts  ← Drop-off detection
│   ├── page.tsx                     ← Entry point
│   └── layout.tsx
│
├── components/
│   ├── LeadFlow.tsx                 ← Main flow controller
│   ├── QualificationForm.tsx        ← Multi-step form
│   ├── CalendlyEmbed.tsx            ← Calendar embed
│   └── ThankYou.tsx                 ← Confirmation screen
│
├── hooks/
│   └── useLeadFlow.ts               ← Flow state machine
│
└── lib/
    ├── crm.ts                       ← CRM API helpers
    ├── redis.ts                     ← Lead state management
    ├── qstash.ts                    ← Task scheduling
    ├── slack.ts                     ← Notifications
    ├── email-tool.ts                ← Marketing webhooks
    ├── supabase.ts                  ← Database logging
    ├── routing.ts                   ← Your routing logic
    └── validation.ts                ← Zod schemas
Phase 2

Build the Frontend

A single-page state machine that guides leads through capture, qualification, routing, booking, and thank-you. No URL changes - just animated transitions.

05

Build the Flow State Machine

The frontend is a state machine, not separate pages
  1. 1
    Lead lands on page with ?email=xxx

    Redirected from your main website. Email captured from URL param.

  2. 2
    Frontend calls POST /api/lead immediately

    Email is stored in Redis and Supabase. Drop-off timer starts. Lead doesn't see any of this.

  3. 3
    Qualification form renders

    Collects name, company, revenue, needs. Use conditional fields based on previous answers.

  4. 4
    Form submits to POST /api/qualify

    API runs your routing logic and returns: "high-value", "needs-qualification", or "not-qualified".

  5. 5
    Route determines the next screen

    High-value = show Calendly embed. Needs qualification = show pricing gate. Not qualified = nurture.

  6. 6
    Lead books via Calendly, frontend calls POST /api/booking

    Listen for the Calendly event via window message. Fire the booking endpoint on completion.

  7. 7
    Thank-you screen

    Different messaging based on the route they took. Booking confirmation, nurture message, or pricing gate follow-up.

06

Key Frontend Patterns

Conditional fields, Calendly embed, and booking listener
Conditional form fields - only show questions that are relevant based on previous answers.
// Only show VC question for lower revenue tiers
{revenue === "0-50k" && (
  <Select name="vcBacked" label="Are you VC-backed?" options={["Yes", "No"]} />
)}
Calendly embed - pre-fill with the lead's form data so they don't re-enter info.
<InlineWidget
  url="https://calendly.com/your-team/discovery"
  prefill={{
    email: formData.email,
    firstName: formData.firstName,
    lastName: formData.lastName,
  }}
/>
Booking listener - detect when Calendly fires the booking event.
useEffect(() => {
  const handler = (e) => {
    if (e.data.event === "calendly.event_scheduled") {
      onBookingComplete(e.data.payload);
    }
  };
  window.addEventListener("message", handler);
  return () => window.removeEventListener("message", handler);
}, []);
Phase 3

Build the API Layer

Five independent serverless endpoints. Each one handles a specific stage of the lead's journey. They share nothing except the state layer.

07

Build /api/lead - Email Capture

Triggered by the frontend on page load
POST /api/lead
Triggered by: frontend on page load when ?email= param exists
  • Validate email with Zod schema
  • Log to Supabase (lead_submissions table, status: email_captured)
  • Save state to Redis: lead:{email} with 24h TTL
  • Fire email tool webhook (journey start event)
  • Schedule 20-minute check via QStash to /api/check-progress
Important: Don't create CRM records yet. Wait to see if they fill the form. Creating a record on email capture alone creates noise in your CRM.
08

Build /api/qualify - Form + Routing

Triggered when the lead submits the qualification form
POST /api/qualify
Triggered by: frontend form submission
  • Validate all fields with Zod
  • Run your routing logic (returns: high-value, needs-qualification, not-qualified)
  • Update Redis state: step = "form", store all form data
  • Update Supabase: status = form_submitted
  • Fire email tool webhook (form submission event)
  • Schedule another 20-minute check via QStash
  • Return the route to the frontend
09

Write the Routing Logic

One function, one file. The core of the system.
Layer your rules: geographic filtering first, then revenue, then sub-qualification, then budget gate. Keep it in lib/routing.ts. This is the one function you'll change most often.
// lib/routing.ts

function routeLead(formData, headers) {
  // Layer 1: Geographic filtering
  const country = headers.get("x-vercel-ip-country");
  if (isBlockedRegion(country) && !isHighRevenue(formData.revenue)) {
    return { route: "geo-blocked", reason: "Region not served" };
  }

  // Layer 2: Revenue-based routing
  if (isHighRevenue(formData.revenue)) {
    return { route: "high-value", reason: "Revenue qualifies" };
  }

  // Layer 3: Sub-qualification (e.g., VC-backed startups)
  if (formData.vcBacked === "yes") {
    return { route: "high-value", reason: "VC-backed upgrade" };
  }

  // Layer 4: Budget gate
  return { route: "needs-qualification", reason: "Needs budget confirmation" };
}
Key principle: Your routing logic is just a function. When you want to change who qualifies or how leads are routed, you change this one file. Describe the change to Claude Code in a sentence, and the code updates in minutes.
10

Build /api/booking - Calendar Webhook

Triggered by two sources: your frontend AND the Calendly org webhook
POST /api/booking
Triggered by: frontend booking event + Calendly webhook (invitee.created)
  • Deduplicate with a Redis lock (prevents double-processing from both sources)
  • Create/update CRM records - upsert person by email, upsert company by domain, create or update deal, store form response
  • Fire Slack notification to #booked channel
  • Fire email tool: booking confirmation + pre-frame email journey
  • Log booking to Supabase
  • Schedule pre-call enrichment via QStash to /api/precall
Use Promise.allSettled for parallel integration calls. If Slack fails, the CRM update and email still run. Never let one integration failure kill the whole pipeline.
Phase 4

Connect the Integrations

Wire up CRM records, email automation, Slack notifications, and Calendly webhooks. Each integration is decoupled - swap any tool without touching the others.

11

Set Up CRM Integration

Always upsert, never blind-create
Whatever CRM you use, follow this pattern: check if the record exists first, then update or create. Email is the unique key for people, domain for companies.
// lib/crm.ts - the pattern applies to any CRM

async function upsertCompany(domain, data) {
  // 1. Check if company exists by domain
  // 2. If exists -> update with new data
  // 3. If not -> create new record
}

async function upsertPerson(email, data) {
  // Same pattern - email as unique key
}

async function createOrUpdateDeal(email, data) {
  // 1. Query existing deals for this email
  // 2. If active deal exists -> update stage/data
  // 3. If not -> create new deal
}
Domain handling gotcha: Users will type "Acme Inc" in the website field. Always validate it looks like a domain. Fall back to extracting domain from email.
function extractDomain(input) {
  // "https://example.com/about" -> "example.com"
  // "TopLead LLC" -> null (not a domain!)
  if (!input.includes(".") || input.includes(" ")) return null;
  return input.replace(/^https?:\/\//, "").split("/")[0];
}
12

Set Up Email / Marketing Automation

Webhook events keep your system decoupled from the email tool
Instead of direct API integration, fire webhook events at each stage. Your email tool (Customer.io, Loops, ActiveCampaign) picks them up and triggers the right journey.
// lib/email-tool.ts

async function trackEvent(eventName, data) {
  await fetch(process.env.EMAIL_TOOL_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ event: eventName, ...data }),
  });
}
Events to fire at each stage:
Email captured
"email_captured"
Start awareness sequence
Form submitted
"form_submitted"
Track conversion, segment by answers
Meeting booked
"meeting_booked"
Stop nurture, start pre-frame emails
Email drop-off
"email_dropped_off"
Trigger nurture / re-engagement
Form drop-off
"form_dropped_off"
Follow-up with context from answers
13

Set Up Slack Notifications

One webhook per channel. Rich messages with Block Kit.
Create a Slack app, enable Incoming Webhooks, create one webhook URL per channel. Use Block Kit for formatted messages with context.
#leads

Email captures +
form submissions

#booked

Meeting confirmations
with full context

#sales-intel

Pre-call briefs
+ lead scores

#errors

Pipeline errors
(catch issues fast)

// Rich Slack message with Block Kit
const blocks = [
  { type: "header", text: { type: "plain_text", text: "New Meeting Booked" } },
  { type: "section", fields: [
    { type: "mrkdwn", text: `*Name:* ${name}` },
    { type: "mrkdwn", text: `*Company:* ${company}` },
    { type: "mrkdwn", text: `*Revenue:* ${revenue}` },
  ]},
  { type: "divider" },
  { type: "context", elements: [
    { type: "mrkdwn", text: `Booked with ${hostName} - ${meetingDate}` },
  ]},
];
14

Wire Up Calendly Webhooks

Org-level webhook catches all bookings automatically
  1. 1
    Go to Calendly Developer settings

    Create a new webhook subscription.

  2. 2
    Set event to invitee.created

    This fires every time someone books a meeting.

  3. 3
    Point it to your /api/booking endpoint

    URL: https://your-app.vercel.app/api/booking

  4. 4
    Set scope to Organization

    Catches all team members' bookings, not just yours.

Inbound vs outbound: If SDRs also send direct Calendly links, differentiate by checking the event type URI in the payload. Map each Calendly event type to "inbound" or "outbound" so your system knows the source.
Phase 5

Build the Intelligence Layer

Two systems that make your pipeline smart: drop-off detection that recovers lost leads, and a pre-call agent that scores and briefs your team before every meeting.

15

Build Drop-Off Detection

The secret weapon. Most tools don't do this.
Every time a lead enters a step, QStash schedules a check 20 minutes later. If the lead hasn't progressed, the system catches it and fires recovery actions.
POST /api/check-progress
Triggered by: QStash (20-minute delayed task)
  • Check Redis: is the lead still on the same step?
  • If they progressed: do nothing, they're fine
  • If stuck at email step: create basic CRM record + trigger nurture email
  • If stuck at form step: create full CRM record + deal + trigger follow-up
  • Notify Slack that a lead dropped off
// Schedule the check when capturing email:
await qstash.publishJSON({
  url: `${APP_URL}/api/check-progress`,
  body: { email, expectedStep: "email" },
  delay: 20 * 60,  // 20 minutes in seconds
});

// In /api/check-progress:
const state = await redis.get(`lead:${email}`);

if (!state || state.step === expectedStep) {
  // Lead is stuck - handle the drop-off
  if (expectedStep === "email") await handleEmailDropOff(email);
  if (expectedStep === "form")  await handleFormDropOff(email);
}

// Deduplication lock (QStash retries on failure):
const lock = await redis.set(`lock:check:${email}:${step}`, "1", { nx: true, ex: 300 });
if (!lock) return;  // Already processed
16

Build the Pre-Call Intelligence Agent

Enrichment + scoring + brief, delivered before every call
Fires asynchronously via QStash the moment a meeting is booked. By the time the closer opens their CRM, the brief is already there.
  1. 1
    Enrich the company

    Call your enrichment API with the domain. Get industry, headcount, revenue, funding, tech stack.

  2. 2
    Score the lead (0 to 30)

    Score across dimensions that matter to your ICP: sector fit (0-10), company size (0-5), revenue band (0-15). Add hard gates for automatic disqualification.

  3. 3
    Post the pre-call brief to Slack

    Score, company info, focus areas for the call, meeting time and host. Posted to #sales-intelligence.

  4. 4
    Update CRM with score + meeting prep

    Write the score and full brief directly into the CRM record so the closer has it in one place.

// Scoring model - customize to YOUR ICP
function scoreCompany(enrichment) {
  let score = { total: 0, max: 30, breakdown: {}, flags: [] };

  score.breakdown.sectorFit   = scoreSector(enrichment.industry);      // 0-10
  score.breakdown.companySize  = scoreSize(enrichment.employeeCount);   // 0-5
  score.breakdown.revenueBand  = scoreRevenue(enrichment.revenue);     // 0-15

  if (isBlockedSector(enrichment.industry)) {
    score.flags.push("BLOCKED_SECTOR");
  }

  score.total = Object.values(score.breakdown).reduce((a, b) => a + b, 0);
  return score;
}
Pre-call brief format - posted to Slack automatically:
Pre-Call Brief: Acme Corp
━━━━━━━━━━━━━━━━━━━━━━━━
Score: 24/30

Company:   Acme Corp (B2B SaaS)
Employees: ~120
Revenue:   $5M-10M ARR
Funding:   Series B ($25M)

Focus Areas for Call:
- Confirm current outbound motion
- Validate budget for retainer
- Ask about decision timeline

Meeting: Tomorrow 2:00 PM with Sarah
Phase 6

Deploy and Connect

Push to Vercel, set up a custom domain, connect your website, and wire up full pipeline monitoring.

17

Deploy to Vercel

Push to GitHub, auto-deploy on every commit
  1. 1
    Push your code to GitHub

    Create a repo, push your project.

  2. 2
    Connect to Vercel

    Import the repo in Vercel Dashboard. Every push to main auto-deploys. Every PR gets a preview.

  3. 3
    Add all environment variables

    Vercel Dashboard -> Project -> Settings -> Environment Variables. Set for Production environment.

  4. 4
    Add custom domain

    Vercel Dashboard -> Domains -> Add (e.g., booking.yourdomain.com). Add the CNAME to your DNS. SSL provisions in under 5 minutes.

  5. 5
    Extend function timeouts for heavy endpoints

    Add export const maxDuration = 30; at the top of /api/booking and /api/precall route files.

18

Connect Your Website

Simple email form redirect with UTM passthrough
  1. 1
    Create an email capture form on your site

    Webflow, WordPress, anything. Just a simple email input.

  2. 2
    On submit, redirect to your lead router

    Pass the email and UTM params so you can track source.

  3. 3
    Set up CORS

    Whitelist your website's domain in every API route. Don't use * in production.

// Redirect URL format:
https://booking.yourdomain.com?email={email}&utm_source={source}&utm_medium={medium}&utm_campaign={campaign}

// CORS whitelist in your API routes:
const ALLOWED_ORIGINS = [
  "https://yourdomain.com",
  "https://www.yourdomain.com",
  "https://booking.yourdomain.com",
];
19

Set Up Pipeline Monitoring

Log every step, debug any lead in one query
Log every step of every lead's journey to pipeline_logs. If anything breaks, the cause is visible immediately.
// Log function - call at every step
async function plog(email, step, status, details) {
  await supabase.from("pipeline_logs").insert({
    email, step, status, details,
    created_at: new Date().toISOString(),
  });
  console.log(`[${step}] ${status}`, email);
}

// Debug any lead in one query:
SELECT step, status, details, created_at
FROM pipeline_logs
WHERE email = 'lead@example.com'
ORDER BY created_at ASC;
Steps to log:
  • email_capture_start / complete
  • form_submission_start / complete
  • routing_decision
  • booking_received / complete
  • precall_enrichment / scoring / complete
  • drop_off_detected / handled
Common Pitfalls
Watch Out For These
Lessons from building and running this system in production.

Race conditions

Calendly webhooks fire at the same time as your frontend's booking POST. Use Redis locks for deduplication.

QStash retries

Make every endpoint idempotent. Calling it twice with the same data should produce the same result. Use locks.

Domain vs company name

Users type "Acme Inc" in the website field. Validate it looks like a domain. Fall back to extracting from email.

CORS issues

Different domains for website and lead router? Whitelist your origin in every API route. Don't use * in production.

Cold start latency

Serverless functions cold start in 1-3s. Fine for API endpoints, but can break QStash signature verification. Set timeouts.

CRM records too early

Don't create CRM records on email capture alone. Wait for form submission or the 20-minute drop-off at minimum.

Test the full flow

Don't test endpoints in isolation. Run email -> form -> route -> book -> enrich end to end. Bugs hide at the boundaries.

Filter test data

Skip email webhooks for test addresses. Skip analytics for internal emails. Keep your data clean from day one.

The Build Order
Start to Finish
Follow this sequence. Each step builds on the previous one. 2-3 days for core flow, another 2-3 for intelligence + polish.
  1. Project setup - Next.js + env vars + Supabase tables
  2. Frontend form - multi-step qualification with state machine
  3. /api/lead - email capture + Redis state + QStash timer
  4. /api/qualify - form processing + routing logic
  5. Routing function - your custom rules (revenue, geo, etc.)
  6. /api/booking - CRM records + Slack + email flows
  7. Calendly webhook - org-level webhook to /api/booking
  8. /api/check-progress - drop-off detection + nurture triggers
  9. /api/precall - enrichment + scoring + Slack brief
  10. Deploy to Vercel - env vars + custom domain
  11. Connect website - email form redirect + UTM passthrough
  12. Pipeline monitoring - log every step + error Slack channel
The real power: The entire system is mutable. When the scoring logic needs to change, the routing rules need updating, or the form needs new fields - describe it to Claude Code in a sentence. Live in 5 minutes. No product roadmap. No support ticket. No waiting on a vendor.
More GTM Engineering Tools
Claude Code GTM Framework
The master framework for structuring Claude Code projects for go-to-market operations. Folder architecture, governance, AI agents, and knowledge hierarchies for B2B SaaS.
LinkedIn Lead Scraper
Track keywords, profiles, and posts on LinkedIn. Collect every person who likes or comments. 1,000 leads for $6. Full setup guide with Limadata and Apify.