Skip to main content

Overview

The Billing API integrates with Stripe to manage team subscriptions, storage quotas, and pricing plans. Lawn offers two plans: Basic (5/monthwith100GBstorage)andPro(5/month with 100GB storage) and Pro (25/month with 1TB storage). All billing functions are defined in convex/billing.ts.

Plans & Pricing

PlanMonthly PriceStorage Limit
Basic$5100 GB
Pro$251 TB (1024 GB)

Functions

createSubscriptionCheckout

Creates a Stripe Checkout session for subscribing to a plan.
import { useAction } from 'convex/react';
import { api } from '../convex/_generated/api';

function UpgradeButton({ teamId, plan }) {
  const createCheckout = useAction(api.billing.createSubscriptionCheckout);
  
  const handleUpgrade = async () => {
    const { url } = await createCheckout({
      teamId,
      plan, // 'basic' or 'pro'
      successUrl: window.location.origin + '/team/billing/success',
      cancelUrl: window.location.origin + '/team/billing',
    });
    
    if (url) {
      window.location.href = url;
    }
  };
  
  return <button onClick={handleUpgrade}>Upgrade to {plan}</button>;
}
teamId
Id<'teams'>
required
The team to create subscription for
plan
'basic' | 'pro'
required
The plan to subscribe to
successUrl
string
required
URL to redirect to after successful payment
cancelUrl
string
required
URL to redirect to if checkout is canceled
sessionId
string
Stripe Checkout session ID
url
string | null
Stripe Checkout URL to redirect user to
Permissions: Requires owner role in the team Implementation: convex/billing.ts:15
If the team already has an active subscription, this will throw an error. Use createCustomerPortalSession to manage existing subscriptions.

createCustomerPortalSession

Creates a Stripe Customer Portal session for managing an existing subscription.
import { useAction } from 'convex/react';
import { api } from '../convex/_generated/api';

function ManageBillingButton({ teamId }) {
  const createPortal = useAction(api.billing.createCustomerPortalSession);
  
  const openBillingPortal = async () => {
    const { url } = await createPortal({
      teamId,
      returnUrl: window.location.origin + '/team/billing',
    });
    
    window.location.href = url;
  };
  
  return <button onClick={openBillingPortal}>Manage Subscription</button>;
}
teamId
Id<'teams'>
required
The team to manage billing for
returnUrl
string
required
URL to return to after managing billing
url
string
Stripe Customer Portal URL
Permissions: Requires owner role in the team Implementation: convex/billing.ts:68
The Customer Portal allows team owners to update payment methods, view invoices, cancel subscriptions, and change plans.

getTeamBilling

Retrieves complete billing information for a team.
import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

function BillingDashboard({ teamId }) {
  const billing = useQuery(api.billing.getTeamBilling, { teamId });
  
  if (!billing) return <div>Loading...</div>;
  
  const storagePercent = (billing.storageUsedBytes / billing.storageLimitBytes) * 100;
  
  return (
    <div>
      <h2>Billing & Usage</h2>
      
      <div className="plan-info">
        <strong>{billing.plan}</strong> Plan
        <span>${billing.monthlyPriceUsd}/month</span>
      </div>
      
      <div className="storage">
        <h3>Storage</h3>
        <div className="progress-bar">
          <div style={{ width: `${storagePercent}%` }} />
        </div>
        <p>
          {formatBytes(billing.storageUsedBytes)} of {formatBytes(billing.storageLimitBytes)} used
        </p>
      </div>
      
      {billing.hasActiveSubscription && (
        <div className="subscription-status">
          Status: {billing.subscriptionStatus}
          {billing.currentPeriodEnd && (
            <span>Renews {new Date(billing.currentPeriodEnd).toLocaleDateString()}</span>
          )}
        </div>
      )}
    </div>
  );
}

function formatBytes(bytes: number): string {
  const gb = bytes / (1024 ** 3);
  return `${gb.toFixed(1)} GB`;
}
teamId
Id<'teams'>
required
The team to get billing info for
billing
object
Complete billing information
plan
'basic' | 'pro'
Current plan tier
monthlyPriceUsd
number
Monthly price in USD
storageLimitBytes
number
Storage quota in bytes
storageUsedBytes
number
Current storage usage in bytes
hasActiveSubscription
boolean
Whether team has an active Stripe subscription
subscriptionStatus
string | null
Stripe subscription status (active, trialing, past_due, etc.)
stripeCustomerId
string | null
Stripe customer ID
stripeSubscriptionId
string | null
Stripe subscription ID
stripePriceId
string | null
Stripe price ID
currentPeriodEnd
number | null
Subscription period end timestamp (milliseconds)
role
string
Current user’s role in the team
Permissions: Requires viewer role in the team Implementation: convex/billing.ts:108

Storage Quota Enforcement

Video uploads automatically check storage quotas before accepting files:
// From convex/billingHelpers.ts
export const TEAM_PLAN_STORAGE_LIMIT_BYTES: Record<TeamPlan, number> = {
  basic: 100 * GIBIBYTE,  // 100 GB
  pro: 1024 * GIBIBYTE,   // 1 TB
};
When a video upload would exceed the team’s storage limit, the upload is rejected with an error message prompting the user to upgrade.

Checking Available Storage

import { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

function StorageWarning({ teamId }) {
  const billing = useQuery(api.billing.getTeamBilling, { teamId });
  
  if (!billing) return null;
  
  const percentUsed = (billing.storageUsedBytes / billing.storageLimitBytes) * 100;
  
  if (percentUsed < 80) return null;
  
  return (
    <div className="warning">
      <strong>Storage Warning</strong>
      <p>
        You've used {percentUsed.toFixed(0)}% of your storage.
        {percentUsed >= 95 && ' Uploads may fail soon.'}
      </p>
      {billing.plan === 'basic' && (
        <button>Upgrade to Pro for 1TB</button>
      )}
    </div>
  );
}

Usage Examples

Complete Billing Flow

import { useQuery, useAction } from 'convex/react';
import { api } from '../convex/_generated/api';

function TeamBillingPage({ teamId }) {
  const billing = useQuery(api.billing.getTeamBilling, { teamId });
  const createCheckout = useAction(api.billing.createSubscriptionCheckout);
  const createPortal = useAction(api.billing.createCustomerPortalSession);
  
  if (!billing) return <div>Loading...</div>;
  
  const canManageBilling = billing.role === 'owner';
  const storagePercent = (billing.storageUsedBytes / billing.storageLimitBytes) * 100;
  
  const handleUpgrade = async (plan: 'basic' | 'pro') => {
    const { url } = await createCheckout({
      teamId,
      plan,
      successUrl: `${window.location.origin}/team/${teamId}/billing/success`,
      cancelUrl: `${window.location.origin}/team/${teamId}/billing`,
    });
    
    if (url) window.location.href = url;
  };
  
  const handleManage = async () => {
    const { url } = await createPortal({
      teamId,
      returnUrl: `${window.location.origin}/team/${teamId}/billing`,
    });
    
    window.location.href = url;
  };
  
  return (
    <div className="billing-page">
      <section className="current-plan">
        <h2>Current Plan</h2>
        <div className="plan-card">
          <h3>{billing.plan} Plan</h3>
          <div className="price">${billing.monthlyPriceUsd}/month</div>
          
          {billing.hasActiveSubscription && (
            <div className="status">
              <span className="badge">{billing.subscriptionStatus}</span>
              {billing.currentPeriodEnd && (
                <p>Renews on {new Date(billing.currentPeriodEnd).toLocaleDateString()}</p>
              )}
            </div>
          )}
        </div>
      </section>
      
      <section className="storage">
        <h2>Storage Usage</h2>
        <div className="storage-bar">
          <div 
            className="storage-fill" 
            style={{ 
              width: `${storagePercent}%`,
              backgroundColor: storagePercent > 90 ? 'red' : 'green'
            }} 
          />
        </div>
        <p>
          {formatBytes(billing.storageUsedBytes)} of {formatBytes(billing.storageLimitBytes)} used
          ({storagePercent.toFixed(1)}%)
        </p>
      </section>
      
      {canManageBilling && (
        <section className="actions">
          <h2>Manage Billing</h2>
          
          {!billing.hasActiveSubscription ? (
            <div className="upgrade-options">
              {billing.plan === 'basic' && (
                <button onClick={() => handleUpgrade('pro')} className="primary">
                  Upgrade to Pro - $25/month
                </button>
              )}
              {!billing.plan && (
                <>
                  <button onClick={() => handleUpgrade('basic')}>
                    Get Basic - $5/month
                  </button>
                  <button onClick={() => handleUpgrade('pro')} className="primary">
                    Get Pro - $25/month
                  </button>
                </>
              )}
            </div>
          ) : (
            <button onClick={handleManage}>
              Manage Subscription in Stripe
            </button>
          )}
        </section>
      )}
      
      {!canManageBilling && (
        <p className="permission-note">
          Only team owners can manage billing.
        </p>
      )}
    </div>
  );
}

function formatBytes(bytes: number): string {
  const gb = bytes / (1024 ** 3);
  return `${gb.toFixed(1)} GB`;
}

Plan Comparison Component

function PricingTable({ currentPlan, onSelectPlan }) {
  const plans = [
    {
      name: 'basic',
      price: 5,
      storage: '100 GB',
      features: [
        'Up to 100GB video storage',
        'Unlimited team members',
        'Timestamped comments',
        'Password-protected sharing',
        'Workflow management',
      ],
    },
    {
      name: 'pro',
      price: 25,
      storage: '1 TB',
      features: [
        'Up to 1TB video storage',
        'Unlimited team members',
        'Timestamped comments',
        'Password-protected sharing',
        'Workflow management',
        'Priority support',
      ],
    },
  ];
  
  return (
    <div className="pricing-grid">
      {plans.map(plan => (
        <div 
          key={plan.name} 
          className={`plan-card ${currentPlan === plan.name ? 'current' : ''}`}
        >
          <h3>{plan.name}</h3>
          <div className="price">${plan.price}<span>/month</span></div>
          <div className="storage">{plan.storage} storage</div>
          
          <ul className="features">
            {plan.features.map(feature => (
              <li key={feature}>{feature}</li>
            ))}
          </ul>
          
          <button 
            onClick={() => onSelectPlan(plan.name)}
            disabled={currentPlan === plan.name}
          >
            {currentPlan === plan.name ? 'Current Plan' : `Choose ${plan.name}`}
          </button>
        </div>
      ))}
    </div>
  );
}

Subscription Status Values

StatusDescription
activeSubscription is active and paid
trialingIn trial period (if configured)
past_duePayment failed, retrying
canceledSubscription canceled, ends at period end
unpaidPayment failed, subscription paused
incompleteInitial payment not completed
Lawn considers active, trialing, and past_due as valid states for accessing the platform. Other states may restrict functionality.

Build docs developers (and LLMs) love