Headless Commerce August 6, 2025 12 min read

Building a Real CRM Pipeline on Top of Shopify

Shopify knows about customers. It does not know about leads. Here is how to build the missing 90% of the customer journey with HubSpot, Next.js API routes, and some honest engineering.

Tyler Colby · Founder, Colby's Data Movers

The Gap in Shopify

Shopify is a checkout system. A very good one. It handles product management, inventory, payments, tax calculation, shipping, and order fulfillment. For those things, it is best in class.

But Shopify has no concept of a lead. There is no pipeline. No lifecycle stages. No lead scoring. No follow-up sequences. A visitor either becomes a customer (by purchasing) or they do not exist in Shopify's world.

For commodity products, this is fine. A $25 purchase does not need a pipeline. The buyer sees it, wants it, buys it.

For high-ticket products ($500+), this is a catastrophic blind spot. The buyer visits 3-7 times before purchasing. They research. They compare. They hesitate. During those 3-6 weeks of deliberation, Shopify gives you zero tools to engage them, nurture them, or even know they exist.

The Four Capture Points

A proper CRM pipeline for e-commerce needs to capture leads at four distinct touchpoints. Each one catches a different buyer at a different stage of commitment.

Capture Point 1: Value-Exchange Forms

The simplest and most effective lead capture: offer something valuable in exchange for an email address. Not a "Subscribe to our newsletter" form. Nobody wants a newsletter from a product company. They want information that helps them make a decision.

Examples that actually convert:

  • Product comparison guide (PDF): "Compare the X500 vs X700 vs X900"
  • Buyer's checklist: "8 things to verify before buying [product category]"
  • Spec sheet download: detailed technical specifications
  • Configuration worksheet: "Plan your custom order"

These convert at 15-25% because they provide real value. The buyer was going to research this anyway. You are saving them time.

Here is the API route that handles the form submission:

// app/api/lead-capture/route.js
import { NextResponse } from 'next/server';

const HUBSPOT_TOKEN = process.env.HUBSPOT_ACCESS_TOKEN;

export async function POST(request) {
  const body = await request.json();
  const { email, firstName, source, productInterest } = body;

  // Validate
  if (!email || !email.includes('@')) {
    return NextResponse.json(
      { error: 'Valid email required' },
      { status: 400 }
    );
  }

  // Create or update contact in HubSpot
  const hubspotRes = await fetch(
    'https://api.hubapi.com/crm/v3/objects/contacts',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        properties: {
          email,
          firstname: firstName || '',
          lead_source: source,
          product_interest: productInterest,
          lifecyclestage: 'lead',
          hs_lead_status: 'NEW'
        }
      })
    }
  );

  // HubSpot returns 409 if contact exists. Update instead.
  if (hubspotRes.status === 409) {
    const existing = await hubspotRes.json();
    const contactId = existing.message.match(/ID: (\d+)/)?.[1];

    if (contactId) {
      await fetch(
        `https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
        {
          method: 'PATCH',
          headers: {
            'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
            'Content-Type': 'application/json'
          },
          body: JSON.stringify({
            properties: {
              product_interest: productInterest,
              last_form_submission: source
            }
          })
        }
      );
    }
  }

  return NextResponse.json({ success: true });
}

The key detail: the 409 handler. When a returning visitor submits a second form, we update their existing contact instead of failing. This means every interaction enriches the lead profile instead of creating duplicates.

Capture Point 2: Exit-Intent Popups (Done Right)

I know. Popups. Everyone hates them. But the data is clear: exit-intent popups on product pages convert at 4-8% when the offer is relevant and the timing is right.

The rules for non-annoying popups:

  • Only trigger on exit intent (cursor moves toward browser chrome on desktop, scroll-up on mobile)
  • Only on product pages, never on the homepage or blog
  • Only once per session. If they dismiss it, it does not come back
  • The offer must be specific to the product they were viewing
  • No countdown timers. No fake urgency. No "Wait! Before you go!"

Implementation with a React hook:

// hooks/useExitIntent.js
import { useState, useEffect, useCallback } from 'react';

export function useExitIntent(productHandle) {
  const [showPopup, setShowPopup] = useState(false);

  const handleMouseLeave = useCallback((e) => {
    // Only trigger when cursor moves to top of viewport
    if (e.clientY > 50) return;

    // Check if already shown this session
    const key = `exit_shown_${productHandle}`;
    if (sessionStorage.getItem(key)) return;

    sessionStorage.setItem(key, 'true');
    setShowPopup(true);
  }, [productHandle]);

  useEffect(() => {
    document.addEventListener('mouseleave', handleMouseLeave);
    return () => {
      document.removeEventListener('mouseleave', handleMouseLeave);
    };
  }, [handleMouseLeave]);

  return { showPopup, dismiss: () => setShowPopup(false) };
}

The popup content: "Want the full spec sheet for the [Product Name]? We'll email it to you." One field: email. One button: "Send Specs." That is it. It is helpful, not desperate.

Capture Point 3: Pre-Checkout Email

This is the most valuable capture point and the one most stores miss entirely. When a buyer clicks "Add to Cart" but has not checked out, they have expressed high purchase intent. They are 10x more likely to convert than a casual browser.

Before redirecting to Shopify checkout, show a single-field email capture:

// components/CartDrawer.jsx
function CartDrawer({ items, checkoutUrl }) {
  const [email, setEmail] = useState('');
  const [captured, setCaptured] = useState(false);

  async function handleCheckout() {
    // Capture email before redirecting to Shopify
    if (email && !captured) {
      await fetch('/api/lead-capture', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          email,
          source: 'pre_checkout',
          productInterest: items.map(i => i.handle).join(', ')
        })
      });
      setCaptured(true);
    }

    // Redirect to Shopify checkout
    window.location.href = checkoutUrl;
  }

  return (
    <div className="cart-drawer">
      {/* Cart items rendering */}
      {items.map(item => (
        <CartItem key={item.variantId} item={item} />
      ))}

      <div className="cart-email">
        <label htmlFor="checkout-email">
          Email for order updates
        </label>
        <input
          id="checkout-email"
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
          placeholder="your@email.com"
        />
      </div>

      <button onClick={handleCheckout}>
        Continue to Checkout
      </button>
    </div>
  );
}

The positioning matters: "Email for order updates." Not "Sign up for our mailing list." The buyer thinks they are entering their email for the order. And they are. But they are also entering HubSpot's pipeline as a high-intent lead. If they abandon the Shopify checkout, you have their email and know exactly which products they wanted.

Capture Point 4: AI Chat Assistant

This one is newer and more powerful than the others. An AI assistant on your product pages that can answer questions about the catalog, compare products, and naturally capture email addresses during the conversation.

I wrote a full post about this (AI Commerce: What Happens When Your Store Can Talk Back), but the CRM integration works like this: when the AI resolves a question that indicates purchase intent ("What's the lead time on the X900?"), it offers to email the answer along with a detailed quote. The email capture happens as a natural part of the conversation, not as a popup or a form.

Capture rates from AI chat: 20-35%. The highest of any capture point. Because the buyer asked for it.

The HubSpot Pipeline

Now that leads are flowing in from four touchpoints, you need a pipeline to manage them. Here is the lifecycle we use:

LIFECYCLE STAGES:

1. SUBSCRIBER
   Trigger: Newsletter signup (low intent)
   Action: Add to weekly email digest
   HubSpot: lifecyclestage = subscriber

2. LEAD
   Trigger: Form submission, spec download, popup capture
   Action: 3-email nurture sequence
   HubSpot: lifecyclestage = lead, hs_lead_status = NEW

3. MARKETING QUALIFIED (MQL)
   Trigger: 3+ page views + 1 form submission
            OR pre-checkout email capture
            OR AI chat email capture
   Action: Move to sales sequence
   HubSpot: lifecyclestage = marketingqualifiedlead

4. SALES QUALIFIED (SQL)
   Trigger: Manual (sales team reviews MQL)
            OR "Request a Quote" form
   Action: Direct sales outreach
   HubSpot: lifecyclestage = salesqualifiedlead

5. OPPORTUNITY
   Trigger: Sales confirms buying intent
   Action: Custom quote, follow-up cadence
   HubSpot: lifecyclestage = opportunity

6. CUSTOMER
   Trigger: Shopify order webhook
   Action: Post-purchase sequence
   HubSpot: lifecyclestage = customer

Lead Scoring

HubSpot has built-in lead scoring, but for e-commerce you need custom scoring rules. Here is what we configure:

SCORING MODEL:

Page views:
  +1  per product page view
  +3  for specification page view
  +5  for pricing/quote page view

Form interactions:
  +5  spec sheet download
  +10 "request a quote" submission
  +15 pre-checkout email capture
  +20 AI chat email capture (with purchase intent)

Email engagement:
  +2  email open
  +5  email click
  +10 reply to sales email

Decay:
  -2  per week of inactivity

Thresholds:
  15+ points = MQL (auto-promote)
  30+ points = prioritized for sales review
  50+ points = hot lead alert to sales team

The scoring triggers automated workflows. When a lead crosses 15 points, they automatically move from LEAD to MQL, and HubSpot enrolls them in the sales sequence. When they cross 50 points, the sales team gets a Slack notification with the lead's full activity history.

Tracking Anonymous Visitors

The hardest part of the pipeline is the anonymous phase. Before a visitor submits a form, you do not know who they are. But you can still track their behavior and connect it retroactively once they identify themselves.

The approach:

// lib/tracking.js

// Generate or retrieve anonymous visitor ID
function getVisitorId() {
  let id = localStorage.getItem('cdm_visitor_id');
  if (!id) {
    id = crypto.randomUUID();
    localStorage.setItem('cdm_visitor_id', id);
  }
  return id;
}

// Track page view
async function trackPageView(page, productHandle = null) {
  const visitorId = getVisitorId();

  await fetch('/api/track', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      visitorId,
      event: 'page_view',
      page,
      productHandle,
      timestamp: new Date().toISOString()
    })
  });
}

// When visitor identifies (form submit), merge history
async function identifyVisitor(email) {
  const visitorId = getVisitorId();

  await fetch('/api/identify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      visitorId,
      email
    })
  });
}

On the server side, the /api/identify endpoint takes the anonymous visitor ID, looks up all their tracked events, and associates them with the HubSpot contact. The sales team sees: "This lead viewed the X900 product page 4 times over 2 weeks, downloaded the spec sheet, then submitted a quote request."

That context changes the sales conversation entirely. Instead of "Hi, you requested a quote, which product were you interested in?" it becomes "Hi, I see you've been looking at the X900. I noticed you downloaded the spec sheet. Do you have questions about the installation requirements?"

The Shopify Order Webhook

When a lead finally purchases through Shopify checkout, you need to close the loop. A Shopify webhook fires on order creation and updates the HubSpot contact to CUSTOMER status:

// app/api/webhooks/shopify-order/route.js
import { NextResponse } from 'next/server';
import crypto from 'crypto';

const SHOPIFY_WEBHOOK_SECRET = process.env.SHOPIFY_WEBHOOK_SECRET;
const HUBSPOT_TOKEN = process.env.HUBSPOT_ACCESS_TOKEN;

export async function POST(request) {
  // Verify Shopify webhook signature
  const body = await request.text();
  const hmac = request.headers.get('x-shopify-hmac-sha256');
  const hash = crypto
    .createHmac('sha256', SHOPIFY_WEBHOOK_SECRET)
    .update(body, 'utf8')
    .digest('base64');

  if (hash !== hmac) {
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 401 }
    );
  }

  const order = JSON.parse(body);
  const email = order.email;

  if (!email) {
    return NextResponse.json({ ok: true });
  }

  // Search for existing contact
  const searchRes = await fetch(
    'https://api.hubapi.com/crm/v3/objects/contacts/search',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        filterGroups: [{
          filters: [{
            propertyName: 'email',
            operator: 'EQ',
            value: email
          }]
        }]
      })
    }
  );

  const searchData = await searchRes.json();

  if (searchData.total > 0) {
    const contactId = searchData.results[0].id;

    // Update to customer
    await fetch(
      `https://api.hubapi.com/crm/v3/objects/contacts/${contactId}`,
      {
        method: 'PATCH',
        headers: {
          'Authorization': `Bearer ${HUBSPOT_TOKEN}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          properties: {
            lifecyclestage: 'customer',
            shopify_order_id: order.id.toString(),
            shopify_order_total: order.total_price,
            shopify_order_date: order.created_at
          }
        })
      }
    );
  }

  return NextResponse.json({ ok: true });
}

The Full Architecture

┌──────────────────────────────────────────────┐
│           Next.js Frontend (Vercel)           │
│                                               │
│  Capture Point 1: Value-exchange forms        │
│  Capture Point 2: Exit-intent popup           │
│  Capture Point 3: Pre-checkout email          │
│  Capture Point 4: AI chat assistant           │
│  Anonymous tracking: localStorage + API       │
│                                               │
│  /api/lead-capture    → HubSpot              │
│  /api/track           → Event store          │
│  /api/identify        → Merge anonymous      │
│  /api/webhooks/order  → Close the loop       │
└───────┬──────────┬──────────┬────────────────┘
        │          │          │
        ▼          ▼          ▼
   ┌────────┐ ┌────────┐ ┌────────┐
   │Shopify │ │HubSpot │ │ Vercel │
   │Products│ │  CRM   │ │  KV    │
   │Checkout│ │Pipeline│ │ Events │
   │Orders  │ │Scoring │ │Tracking│
   └────────┘ └────────┘ └────────┘

Shopify stays in its lane: products, checkout, orders. HubSpot handles the pipeline: leads, scoring, sequences, workflows. Next.js API routes are the glue between them. Vercel KV stores anonymous tracking events until the visitor identifies themselves.

Results

Here are the numbers from a client store that implemented this full pipeline. Selling high-end outdoor equipment, average order value $2,800.

BEFORE (Shopify template, no CRM):
  Monthly visitors:    11,200
  Known leads:         0 (no capture mechanism)
  Conversion rate:     0.5%
  Monthly revenue:     $156,800

AFTER (Headless + HubSpot pipeline):
  Monthly visitors:    11,200 (same traffic)
  Leads captured:      1,680/month (15% capture rate)
  MQLs generated:      340/month
  SQLs generated:      85/month
  Conversion rate:     1.3%
  Monthly revenue:     $407,680

Pipeline value visible in CRM:  $238,000/month
(leads in nurture who have not yet purchased)

The conversion rate increase alone added $250K/month. But the real value is the pipeline visibility. Before, 99.5% of visitors were invisible. Now, 15% enter the CRM. The sales team has a queue of qualified leads to work instead of waiting for orders to appear in Shopify.

The Trade-offs

This is not free. The honest costs:

  • HubSpot: $800-1,600/month for Marketing Hub Professional (you need workflows and lead scoring)
  • Development: $15,000-30,000 for the initial build (headless frontend + API routes + CRM integration)
  • Maintenance: 4-8 hours/month to manage sequences, review scoring, and update content
  • Complexity: More moving parts. Shopify + HubSpot + Vercel + API routes. When something breaks, debugging spans multiple systems

For a store doing $50K/month in revenue with a $200 AOV, this is overkill. The math does not justify the investment.

For a store doing $150K+/month with a $1,000+ AOV, this is the single highest-ROI investment you can make. The pipeline pays for itself in the first month.

If your store has traffic but no pipeline, you are watching money walk out the door. The visitors are there. You just cannot see them. Let's fix that.