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:
- Subscription Management (monthly, annual, lifetime billing cycles)
- One-Time Products (standalone purchasable items)
- Stripe Checkout (secure, hosted payment pages)
- Customer Portal (self-service subscription management)
- Webhook Handling (automatic subscription and purchase sync)
- Organization-Based Billing (subscriptions tied to organizations)
Setup
1. Create Products in Stripe
- Go to Products in your Stripe dashboard
- Click Add product and fill in:
- Name: e.g., "Pro Plan"
- Pricing model: Recurring (for subscriptions) or One time (for products)
- Add prices for each billing cycle (monthly, annual, one-time)
- Copy the Product ID (
prod_...) and Price ID (price_...)
For maximum flexibility, create three prices per plan: monthly, annual (with discount), and lifetime (one-time payment).
2. Set Up Webhooks
- Go to Developers → Webhooks → Add endpoint
- Enter your webhook URL:
- Local: Use Stripe CLI
- Production:
https://your-api-domain.com/api/v1/billing/webhook
- Select events:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed,invoice.payment_succeeded,payment_intent.succeeded - 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_...) |
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:
- A
Purchaserecord is created - A
Subscriptionis created withbilling_cycle = 'lifetime' - 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
- Subscriptions are tied to organizations, not individual users
- All members share the same subscription
- Only one subscription per organization
- Billing management may require specific permissions
Going Live Checklist
- Switch to live API keys (
sk_live_...,pk_live_...) - Create production webhook endpoint
- Update
STRIPE_WEBHOOK_SECRETwith production signing secret - Test complete checkout flow with a real card
- Test one-time purchase and lifetime plan flows
- Verify webhook events are received
- Configure Customer Portal settings
- Set up email receipts
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
- Authentication - Understand user auth flow
- Email - Set up transactional emails
- Backend Architecture - Learn the module pattern