Skip to main content
ship before you scale

The Freemium Funnel: Conversion Tracking, Usage Limits, and the Psychology of Upgrading Without a Sales Team

6 min read Chapter 37 of 42

The Freemium Funnel

Marketflow’s pricing is simple. Free for markets with fewer than 20 vendors. $29/month for markets with 20 or more vendors. The free tier is not a trial with an expiration date. It is a fully functional product with a capacity limit. An organizer running a small neighborhood market with 12 vendors uses Marketflow indefinitely without paying. An organizer running a downtown market with 40 vendors needs the paid tier.

This is not altruism. It is distribution. Every free market is a reference. Every vendor who uses Marketflow at one market encounters it at another. The free tier builds the network. The paid tier captures value from organizers who have already validated the product with real usage.

The Feature

When an organizer’s market reaches 18 vendors (90% of the free tier limit), the dashboard shows a gentle notification: “Your market is growing. When you reach 20 vendors, you will need the Growth plan to add more.” At 20 vendors, the “Add Vendor” button is replaced with an “Upgrade to Growth” button. The organizer cannot add more vendors without upgrading. Existing vendors are not affected, the market continues to function for the current vendors.

The Decision

Hard limits with graceful handling. Not soft limits with warning emails. Not honor-system limits. The application enforces the vendor count at the API level. A free-tier organizer cannot add a 21st vendor regardless of which client (web, mobile, API) they use. The limit is checked on every “add vendor” operation, not on a daily batch job.

The Implementation

Tier Configuration

# backend/app/config/tiers.py
from dataclasses import dataclass


@dataclass(frozen=True)
class TierLimits:
    max_vendors: int
    max_markets: int
    document_storage_mb: int
    custom_branding: bool
    priority_support: bool


TIERS = {
    "free": TierLimits(
        max_vendors=20,
        max_markets=2,
        document_storage_mb=500,
        custom_branding=False,
        priority_support=False,
    ),
    "growth": TierLimits(
        max_vendors=200,
        max_markets=10,
        document_storage_mb=5000,
        custom_branding=True,
        priority_support=True,
    ),
}


def get_tier_limits(tier: str) -> TierLimits:
    return TIERS.get(tier, TIERS["free"])

Usage Limit Enforcement

# backend/app/services/usage.py
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession

from app.config.tiers import get_tier_limits
from app.models.market import Market
from app.models.vendor import Vendor


class UsageLimitError(Exception):
    def __init__(self, limit_type: str, current: int, maximum: int):
        self.limit_type = limit_type
        self.current = current
        self.maximum = maximum
        super().__init__(
            f"{limit_type} limit reached: {current}/{maximum}"
        )


async def check_vendor_limit(
    db: AsyncSession,
    market: Market,
) -> None:
    """Check if the market can add another vendor.
    Raises UsageLimitError if the limit is reached."""
    limits = get_tier_limits(market.organizer.tier)

    vendor_count = await db.scalar(
        select(func.count(Vendor.id))
        .where(Vendor.market_id == market.id)
        .where(Vendor.status == "active")
    )

    if vendor_count >= limits.max_vendors:
        raise UsageLimitError("vendors", vendor_count, limits.max_vendors)


async def check_market_limit(
    db: AsyncSession,
    organizer_id: str,
    tier: str,
) -> None:
    """Check if the organizer can create another market."""
    limits = get_tier_limits(tier)

    market_count = await db.scalar(
        select(func.count(Market.id))
        .where(Market.organizer_id == organizer_id)
        .where(Market.status != "archived")
    )

    if market_count >= limits.max_markets:
        raise UsageLimitError("markets", market_count, limits.max_markets)

API Integration

# backend/app/routers/vendors.py
from fastapi import APIRouter, Depends, HTTPException

from app.services.usage import check_vendor_limit, UsageLimitError


@router.post("/markets/{market_id}/vendors")
async def add_vendor(
    market_id: str,
    vendor_data: VendorCreate,
    market: Market = Depends(get_market_for_organizer),
    db: AsyncSession = Depends(get_db),
):
    try:
        await check_vendor_limit(db, market)
    except UsageLimitError as e:
        raise HTTPException(
            status_code=403,
            detail={
                "error": "usage_limit_reached",
                "limit_type": e.limit_type,
                "current": e.current,
                "maximum": e.maximum,
                "upgrade_url": "/settings/billing",
            },
        )

    # ... create vendor ...

Frontend Upgrade Prompts

// frontend/src/components/VendorLimitBanner.tsx
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";

interface VendorLimitBannerProps {
  vendorCount: number;
  maxVendors: number;
  tier: string;
}

export function VendorLimitBanner({
  vendorCount,
  maxVendors,
  tier,
}: VendorLimitBannerProps) {
  if (tier !== "free") return null;

  const percentUsed = (vendorCount / maxVendors) * 100;

  // Show nothing below 80%
  if (percentUsed < 80) return null;

  // Warning at 80-99%
  if (vendorCount < maxVendors) {
    return (
      <Alert className="mb-4 border-amber-500 bg-amber-50 dark:bg-amber-950">
        <AlertDescription>
          Your market has {vendorCount} of {maxVendors} vendors.
          When you reach {maxVendors}, you will need the Growth plan to add more.
          <Button variant="link" className="ml-2 text-amber-700 dark:text-amber-300" asChild>
            <a href="/settings/billing">View plans</a>
          </Button>
        </AlertDescription>
      </Alert>
    );
  }

  // Hard limit reached
  return (
    <Alert className="mb-4 border-red-500 bg-red-50 dark:bg-red-950">
      <AlertDescription>
        Your market has reached the {maxVendors}-vendor limit on the Free plan.
        Upgrade to Growth to add more vendors.
        <Button variant="default" className="ml-2" asChild>
          <a href="/settings/billing">Upgrade to Growth - $29/month</a>
        </Button>
      </AlertDescription>
    </Alert>
  );
}

Usage Dashboard for the Organizer

// frontend/src/components/UsageDashboard.tsx
interface UsageBarProps {
  label: string;
  current: number;
  max: number;
}

function UsageBar({ label, current, max }: UsageBarProps) {
  const percent = Math.min((current / max) * 100, 100);
  const color =
    percent >= 100
      ? "bg-red-500"
      : percent >= 80
        ? "bg-amber-500"
        : "bg-blue-500";

  return (
    <div className="space-y-1">
      <div className="flex justify-between text-sm">
        <span>{label}</span>
        <span>
          {current} / {max}
        </span>
      </div>
      <div className="h-2 rounded-full bg-gray-200 dark:bg-gray-700">
        <div
          className={`h-2 rounded-full ${color}`}
          style={{ width: `${percent}%` }}
        />
      </div>
    </div>
  );
}

export function UsageDashboard({
  vendorCount,
  marketCount,
  storageMb,
  limits,
}: {
  vendorCount: number;
  marketCount: number;
  storageMb: number;
  limits: { max_vendors: number; max_markets: number; document_storage_mb: number };
}) {
  return (
    <div className="space-y-4 rounded-lg border p-4">
      <h3 className="font-semibold">Usage</h3>
      <UsageBar label="Vendors" current={vendorCount} max={limits.max_vendors} />
      <UsageBar label="Markets" current={marketCount} max={limits.max_markets} />
      <UsageBar
        label="Document Storage"
        current={storageMb}
        max={limits.document_storage_mb}
      />
    </div>
  );
}

The Trap

# TRAP: Checking usage limits only in the frontend
# A determined user opens DevTools, modifies the JavaScript,
# and bypasses the vendor limit check entirely.

# SAFE: Enforce limits at the API level
# The frontend shows warnings and disables buttons for UX.
# The backend enforces the limit on every write operation.
# Even if the frontend is bypassed, the API rejects the request.

Usage limits must be enforced at the API level. Frontend checks are cosmetic. They improve the user experience by showing limits before the user attempts an action. But a user who opens the browser console or calls the API directly bypasses frontend checks entirely. The API is the enforcement boundary.

The Cost

ComponentCost
Tier configuration$0 (application code)
Usage tracking$0 (database queries)
Stripe Checkout link$0 (already integrated)

The freemium model adds no infrastructure cost. Usage limits are checked via database queries that execute in under 1 millisecond. Conversion tracking uses Stripe’s existing metadata. The only cost is the developer time to implement the limits and upgrade prompts.