Billing

Starterbase includes full Stripe integration for subscription billing and one-time purchases, including checkout, customer portal, and webhook handling.

Overview

Model Use Case Examples
Subscriptions Recurring revenue, ongoing access Monthly/annual plans, tiered pricing
One-Time Purchases Single payments, permanent access Lifetime plans, add-ons, credits

Key Features:

Setup

1. Create Products in Stripe

  1. Go to Products in your Stripe dashboard
  2. Click Add product and fill in:
    • Name: e.g., "Pro Plan"
    • Pricing model: Recurring (for subscriptions) or One time (for products)
  3. Add prices for each billing cycle (monthly, annual, one-time)
  4. Copy the Product ID (prod_...) and Price ID (price_...)
💡 Monthly + Annual + Lifetime

For maximum flexibility, create three prices per plan: monthly, annual (with discount), and lifetime (one-time payment).

2. Set Up Webhooks

  1. Go to DevelopersWebhooksAdd endpoint
  2. Enter your webhook URL:
    • Local: Use Stripe CLI
    • Production: https://your-api-domain.com/api/v1/billing/webhook
  3. Select events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed, invoice.payment_succeeded, payment_intent.succeeded
  4. Copy the Signing secret (whsec_...)

3. Configure Environment Variables

Backend (backend/.env):

STRIPE_SECRET_KEY=sk_test_your_secret_key
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
Variable Description
STRIPE_SECRET_KEY Backend API key (sk_test_... or sk_live_...)
STRIPE_PUBLISHABLE_KEY Frontend-safe key (pk_test_... or pk_live_...)
STRIPE_WEBHOOK_SECRET Webhook signature verification (whsec_...)
⚠️ Warning

Never expose STRIPE_SECRET_KEY in frontend code or commit it to version control.

Billing Models

Subscriptions

Billing Cycle Description current_period_end
monthly Renews every month Next month
annual Renews every year Next year
lifetime One-time payment, never expires null

Subscription Statuses: active, trialing, past_due, canceled, incomplete

One-Time Products

Standalone items purchased once, tracked via the Purchase model. Use for add-ons, digital goods, or lifetime access.

Lifetime Plans

When a lifetime price is purchased:

  1. A Purchase record is created
  2. A Subscription is created with billing_cycle = 'lifetime'
  3. The subscription never expires (current_period_end = null)

API Endpoints

User-Facing Endpoints

Create Checkout Session

POST /api/v1/billing/checkout
Authorization: Bearer <access_token>

{
  "price_id": "price_1234...",
  "success_url": "https://yourapp.com/billing?success=true",
  "cancel_url": "https://yourapp.com/billing?canceled=true",
  "mode": "subscription",
  "product_id": null,
  "plan_id": null,
  "is_lifetime": false
}
Field Type Description
price_id string Stripe price ID (required)
success_url string Redirect URL after successful payment
cancel_url string Redirect URL if payment canceled
mode string "subscription" or "payment" (one-time)
product_id string Product ID for one-time product purchases
plan_id string Plan ID for lifetime plan purchases
is_lifetime boolean true for lifetime plan purchases

Response:

{
  "session_id": "cs_test_...",
  "url": "https://checkout.stripe.com/c/pay/..."
}

Create Portal Session

Redirect to Stripe Customer Portal for self-service management:

POST /api/v1/billing/portal
Authorization: Bearer <access_token>

Response:

{
  "url": "https://billing.stripe.com/p/session/..."
}

Get Subscription

GET /api/v1/billing/subscription
Authorization: Bearer <access_token>

Response:

{
  "id": "uuid",
  "organization_id": "uuid",
  "plan_id": "uuid",
  "status": "active",
  "billing_cycle": "monthly",
  "current_period_start": "2024-01-01T00:00:00Z",
  "current_period_end": "2024-02-01T00:00:00Z",
  "cancel_at_period_end": false,
  "created_at": "2024-01-01T00:00:00Z",
  "plan_name": "Pro"
}

List Plans

GET /api/v1/billing/plans

Response:

[
  {
    "id": "uuid",
    "name": "Pro",
    "slug": "pro",
    "monthly_price": 29.00,
    "annual_price": 290.00,
    "one_time_price": 499.00,
    "stripe_product_id": "prod_...",
    "stripe_monthly_price_id": "price_...",
    "stripe_annual_price_id": "price_...",
    "stripe_one_time_price_id": "price_...",
    "features": {"api_calls": 10000}
  }
]

Admin Endpoints

Plans Management

# List plans (with pagination)
GET /api/v1/back-office/billing/plans

# Get billing overview statistics
GET /api/v1/back-office/billing/overview

# Create plan
POST /api/v1/back-office/billing/plans

# Update plan
PATCH /api/v1/back-office/billing/plans/{plan_id}

# Delete plan (only if no active subscribers)
DELETE /api/v1/back-office/billing/plans/{plan_id}

Products Management

# List products
GET /api/v1/back-office/products

# Get single product
GET /api/v1/back-office/products/{product_id}

# Create product
POST /api/v1/back-office/products

# Update product
PATCH /api/v1/back-office/products/{product_id}

# Soft delete product
DELETE /api/v1/back-office/products/{product_id}

Subscriptions Management

# List subscriptions (with filtering)
GET /api/v1/back-office/subscriptions

# Get subscription details
GET /api/v1/back-office/subscriptions/{subscription_id}

# Change plan
PATCH /api/v1/back-office/subscriptions/{subscription_id}/plan

# Change billing cycle
PATCH /api/v1/back-office/subscriptions/{subscription_id}/billing-cycle

# Cancel subscription
POST /api/v1/back-office/subscriptions/{subscription_id}/cancel

# Reactivate subscription
POST /api/v1/back-office/subscriptions/{subscription_id}/reactivate

Webhook Handler

POST /api/v1/billing/webhook
Stripe-Signature: t=...,v1=...

Events handled:

Event Action
checkout.session.completed Creates subscription or purchase record
customer.subscription.updated Updates subscription status and period
customer.subscription.deleted Marks subscription as canceled
invoice.payment_failed Marks subscription as past_due
invoice.payment_succeeded Reactivates past_due subscriptions
payment_intent.succeeded Logs one-time payment success

Database Models

Plan

class Plan(Base):
    id: UUID
    name: str                      # "Pro Plan"
    slug: str                      # "pro" (unique)
    monthly_price: float           # 29.00
    annual_price: float            # 290.00
    one_time_price: float | None   # 499.00 (optional lifetime)
    stripe_product_id: str
    stripe_monthly_price_id: str
    stripe_annual_price_id: str
    stripe_one_time_price_id: str | None
    features: dict                 # {"api_calls": 10000}

Product

class Product(Base):
    id: UUID
    name: str                  # "Premium Add-on"
    slug: str                  # "premium-addon" (unique)
    description: str | None
    price: float               # 99.00
    stripe_product_id: str
    stripe_price_id: str
    is_active: bool            # True (soft delete = False)
    features: dict
    created_at: datetime

Subscription

class Subscription(Base):
    id: UUID
    organization_id: UUID      # Links to organization
    plan_id: UUID | None
    stripe_subscription_id: str | None
    stripe_customer_id: str | None
    status: SubscriptionStatus # active, canceled, past_due, etc.
    billing_cycle: BillingCycle  # monthly, annual, lifetime
    current_period_start: datetime | None
    current_period_end: datetime | None  # null for lifetime
    cancel_at_period_end: bool
    purchase_id: UUID | None   # Links to purchase (lifetime only)
    created_at: datetime

Purchase

class Purchase(Base):
    id: UUID
    organization_id: UUID      # Links to organization
    product_id: UUID | None    # For product purchases
    plan_id: UUID | None       # For lifetime plan purchases
    stripe_payment_intent_id: str
    stripe_customer_id: str | None
    amount: float              # 99.00
    currency: str              # "usd"
    status: str                # "succeeded"
    created_at: datetime

Frontend Integration

Billing Hooks

The billing system provides three separate hooks (recommended):

import { useSubscription, usePlans, useBillingMutations } from '@/hooks';

function BillingPage() {
  // Get current subscription
  const { data: subscription, isLoading } = useSubscription();

  // Get available plans
  const { data: plans = [] } = usePlans();

  // Get mutation functions
  const {
    createCheckout,
    createPortalSession,
    isCreatingCheckout,
    isCreatingPortal,
  } = useBillingMutations();

  // Start checkout (redirects to Stripe)
  const handleSubscribe = (priceId: string) => {
    createCheckout(priceId);
  };

  // Open customer portal (redirects to Stripe)
  const handleManage = () => {
    createPortalSession();
  };
}

Or use the combined hook:

import { useBilling } from '@/hooks';

const {
  subscription,      // Current subscription or null
  plans,             // Available plans
  isLoading,
  createCheckout,    // (priceId, successUrl?, cancelUrl?) => void
  createPortalSession,
  isCreatingCheckout,
  isCreatingPortal,
} = useBilling();

Handling Checkout Results

import { useSearchParams } from 'next/navigation';

function BillingPage() {
  const searchParams = useSearchParams();

  useEffect(() => {
    if (searchParams.get('success')) {
      toast.success('Payment successful!');
    } else if (searchParams.get('canceled')) {
      toast.info('Checkout was canceled');
    }
  }, [searchParams]);
}

Local Development

Forward Webhooks with Stripe CLI

# Install
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks
stripe listen --forward-to localhost:8000/api/v1/billing/webhook

Use the output signing secret as STRIPE_WEBHOOK_SECRET.

Test Events

stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed

How It Works

Subscription Flow

Frontend                    Backend                     Stripe
   │                          │                           │
   │ POST /billing/checkout   │                           │
   ├─────────────────────────>│                           │
   │                          │ Create Checkout Session   │
   │                          ├──────────────────────────>│
   │                          │<──────────────────────────┤
   │<─────────────────────────┤                           │
   │                          │                           │
   │ Redirect to Stripe       │                           │
   ├─────────────────────────────────────────────────────>│
   │                          │                           │
   │                          │ Webhook: checkout.completed
   │                          │<──────────────────────────┤
   │                          │ Create subscription       │
   │                          │                           │
   │ Redirect to success_url  │                           │
   │<─────────────────────────────────────────────────────┤

Organization-Based Billing

Going Live Checklist

Troubleshooting

Issue Solution
Webhooks not received Check endpoint URL (must be HTTPS), verify signing secret, check Stripe logs
Checkout session fails Verify price ID and API key, check mode matches price type
Subscription not updating Verify webhook secret, check database connectivity, review webhook logs
Lifetime subscription not created Ensure is_lifetime: true and plan_id in request, check webhook metadata
One-time purchase not recorded Verify mode: "payment" and product_id, check purchases table

Next Steps