---
# === IDENTITY ===
id: software/mvp-development/payment-integration-setup/2026
canonical_question: "How do I integrate payments — Stripe subscriptions, one-time, usage-based, LemonSqueezy, PayPal with webhooks?"
aliases:
  - "Stripe payment integration for SaaS MVP"
  - "How to add subscriptions to my Next.js app with Stripe"
  - "LemonSqueezy vs Stripe for indie founders"
  - "Usage-based billing implementation with Stripe meters"
  - "Payment webhook setup for SaaS startups"
entity_type: execution_recipe
domain: software > mvp-development > payment integration setup
region: global
jurisdiction: global
temporal_scope: 2025-2026

# === VERIFICATION ===
last_verified: 2026-03-12
confidence: 0.92
version: 1.0
first_published: 2026-03-12

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: evolving
  last_breaking_change: "Stripe pushed Embedded Checkout as default in 2025; Stripe acquired Metronome for usage-based billing; LemonSqueezy acquired by Stripe in late 2025"
  next_review: 2026-09-08
  change_sensitivity: high

# === CONSTRAINTS ===
constraints:
  - "Use Stripe test mode (sk_test_*) until go-live checklist is complete — test mode transactions never charge real cards"
  - "Webhook signature verification is mandatory — never process unverified webhook events in production"
  - "Stripe fees: 2.9% + $0.30 per transaction; LemonSqueezy: 5% + $0.50 (includes tax compliance as MoR)"
  - "PCI compliance: use Stripe Checkout or Elements — never handle raw card numbers in your application"
  - "Subscription state changes (upgrades, downgrades, cancellations) must be handled via webhooks, not client-side"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "User needs to choose a payment provider, not implement one"
    use_instead: "Search knowledgelib.io for MVP tech stack selection — no dedicated unit yet"
  - condition: "User needs subscription pricing strategy, not technical implementation"
    use_instead: "business/pricing/saas-pricing-models-comparison/2026"
  - condition: "User needs a complete project scaffold, not just payments"
    use_instead: "software/mvp-development/coded-mvp-architecture-templates/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: payment_provider
    question: "Which payment provider should be used?"
    type: choice
    options: ["Stripe", "LemonSqueezy", "PayPal", "no preference — auto-select"]
  - key: billing_model
    question: "What billing model is needed?"
    type: choice
    options: ["one-time payments", "recurring subscriptions", "usage-based (metered)", "hybrid (subscription + usage)", "marketplace (split payments)"]
  - key: tax_handling
    question: "How should sales tax be handled?"
    type: choice
    options: ["self-managed (Stripe Tax)", "merchant of record (LemonSqueezy handles everything)", "not needed yet"]
  - key: framework
    question: "Which frontend framework?"
    type: choice
    options: ["Next.js (App Router)", "Next.js (Pages Router)", "React (Vite)", "other"]

# === EXECUTION METADATA ===
execution:
  required_inputs:
    - name: "Authenticated user system"
      source: "software/mvp-development/authentication-authorization-setup/2026"
      format: "working auth flow with user IDs"
    - name: "Pricing plan definition"
      source: "product/founder decision"
      format: "plan names, prices, features, billing intervals"
  outputs:
    - name: "Working payment system"
      format: "code + configured provider"
      description: "Checkout flow, webhook handling, subscription management, and customer portal"
    - name: "Payment database schema"
      format: "SQL migration"
      description: "Customers, subscriptions, and payment history tables synced via webhooks"
  tools_required:
    - name: "Stripe"
      purpose: "Payment processing, subscription management, invoicing"
      tier: "paid"
      cost: "2.9% + $0.30 per transaction (no monthly fee)"
      alternatives: ["LemonSqueezy", "Paddle", "PayPal"]
    - name: "LemonSqueezy"
      purpose: "Merchant of record — handles payments, tax, and compliance"
      tier: "paid"
      cost: "5% + $0.50 per transaction (no monthly fee)"
      alternatives: ["Stripe + Stripe Tax", "Paddle"]
    - name: "Stripe CLI"
      purpose: "Local webhook testing and event forwarding"
      tier: "free"
      cost: "$0"
      alternatives: ["ngrok"]
  credentials_needed:
    - service: "Stripe"
      type: "API keys (publishable + secret) + webhook signing secret"
      where_to_get: "https://dashboard.stripe.com/apikeys"
      free_tier_limits: "No monthly fee; test mode is free"
    - service: "LemonSqueezy"
      type: "API key + webhook signing secret"
      where_to_get: "https://app.lemonsqueezy.com/settings/api"
      free_tier_limits: "No monthly fee; test mode available"
  estimated_duration: "45-90 minutes for complete payment integration"
  estimated_cost: "$0 setup + transaction fees on live payments"

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/mvp-development/payment-integration-setup/2026"
suggested_citation: "Source: knowledgelib.io — AI Knowledge Library (verified 2026-03-12)"

# === RELATED UNITS ===
related_kos:
  depends_on:
    - id: "software/mvp-development/authentication-authorization-setup/2026"
      label: "Auth required before payment integration"
    - id: "software/mvp-development/database-schema-design-for-mvps/2026"
      label: "Database schema for customer/subscription tables"
  related_to:
    - id: "software/mvp-development/coded-mvp-architecture-templates/2026"
      label: "Project scaffold with payment patterns"
  alternative_to: []

# === SOURCES ===
sources:
  - id: src1
    title: "Build a Stripe-hosted Checkout Page — Stripe Docs"
    author: Stripe
    url: https://docs.stripe.com/checkout/quickstart?client=next
    type: official_docs
    published: 2025-01-01
    reliability: authoritative
  - id: src2
    title: "Usage-based Billing — Stripe Documentation"
    author: Stripe
    url: https://docs.stripe.com/billing/subscriptions/usage-based
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src3
    title: "Stripe vs LemonSqueezy for SaaS: Which Payment Provider in 2025?"
    author: GetNextKit
    url: https://getnextkit.com/blog/stripe-vs-lemonsqueezy-for-saas-which-payment-provider-should-you-choose-in-2025
    type: technical_blog
    published: 2025-03-01
    reliability: high
  - id: src4
    title: "Stripe Payment Integration: Complete Dev Guide 2026"
    author: Digital Applied
    url: https://www.digitalapplied.com/blog/stripe-payment-integration-developer-guide-2026
    type: technical_blog
    published: 2026-01-15
    reliability: high
  - id: src5
    title: "The Ultimate Guide to Stripe + Next.js (2026 Edition)"
    author: DEV Community
    url: https://dev.to/sameer_saleem/the-ultimate-guide-to-stripe-nextjs-2026-edition-2f33
    type: technical_blog
    published: 2026-02-01
    reliability: high
  - id: src6
    title: "Choosing the Right Payment Provider: Stripe vs LemonSqueezy vs Polar vs Creem"
    author: Supastarter
    url: https://supastarter.dev/blog/saas-payment-providers-stripe-lemonsqueezy-polar-creem-comparison
    type: technical_blog
    published: 2025-09-01
    reliability: high
  - id: src7
    title: "Next.js Subscription Payments Starter"
    author: Vercel
    url: https://github.com/vercel/nextjs-subscription-payments
    type: open_source
    published: 2025-01-01
    reliability: high
---

# Payment Integration Setup

## Purpose

This recipe integrates a complete payment system into an MVP — covering Stripe (subscriptions, one-time, usage-based), LemonSqueezy (merchant of record), and PayPal, with webhook handling, customer portal, and subscription lifecycle management. The output is a working checkout flow that accepts payments, manages subscriptions, syncs state via webhooks, and provides a customer self-service portal. [src1]

## Prerequisites

- [ ] **Authenticated user system** — working auth from [Authentication & Authorization Setup](/software/mvp-development/authentication-authorization-setup/2026)
- [ ] **Database with user profiles** — from [Database Schema Design for MVPs](/software/mvp-development/database-schema-design-for-mvps/2026)
- [ ] **Stripe account** — [dashboard.stripe.com](https://dashboard.stripe.com) (no monthly fee, instant approval)
- [ ] **Stripe CLI** — `brew install stripe/stripe-cli/stripe` or [download](https://stripe.com/docs/stripe-cli)
- [ ] **Pricing plan defined** — plan names, prices, billing intervals (monthly/yearly), feature gates

## Constraints

- Always use Stripe test mode (`sk_test_*`) until the go-live checklist is complete — test transactions never charge real cards. [src1]
- Webhook signature verification is mandatory — anyone can POST to your webhook URL, but `stripe.webhooks.constructEvent()` ensures the request came from Stripe. [src4]
- Never handle raw card numbers — use Stripe Checkout (hosted or embedded) or Stripe Elements. This is a PCI compliance requirement. [src1]
- Subscription state changes (upgrades, downgrades, cancellations, payment failures) must be processed via webhooks — client-side state is unreliable. [src5]
- LemonSqueezy takes 5% + $0.50 per transaction but handles all tax compliance as merchant of record — Stripe takes 2.9% + $0.30 but requires you to handle tax yourself (or add Stripe Tax at 0.5%). [src3]

## Tool Selection Decision

```
Which payment approach?
├── SaaS with subscriptions AND want maximum control
│   └── PROVIDER A: Stripe Checkout + Billing — lowest fees, full API
├── SaaS AND want zero tax/compliance burden
│   └── PROVIDER B: LemonSqueezy — merchant of record, higher fees
├── AI/API product with usage-based pricing
│   └── PROVIDER C: Stripe Meters + Billing — metered usage tracking
├── Marketplace with split payments
│   └── PROVIDER D: Stripe Connect — platform payments, split fees
└── Unsure
    └── DEFAULT: Provider A (Stripe Checkout + Billing)
```

| Provider | Fee | Tax Handling | Setup Time | Payout Speed | Best For |
|----------|-----|-------------|------------|-------------|----------|
| A: Stripe | 2.9% + $0.30 | Self-managed (+ Stripe Tax 0.5%) | 45-60 min | 2 days | SaaS, full control |
| B: LemonSqueezy | 5% + $0.50 | Included (MoR) | 30 min | Net 15 | Solo founders, global sales |
| C: Stripe Meters | 2.9% + $0.30 | Self-managed | 60-90 min | 2 days | AI/API products, metered |
| D: Stripe Connect | 2.9% + $0.30 + platform fee | Per-account | 2-4 hours | Varies | Marketplaces |

## Execution Flow

### Step 1: Configure Stripe and Create Products

**Duration**: 10 minutes
**Tool**: Stripe Dashboard + CLI

```bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe  # macOS
# Or: scoop install stripe                # Windows

# Login and verify
stripe login

# Create products and prices via CLI (or Dashboard)
stripe products create --name="Starter" --description="For small teams"
# Note the product ID: prod_xxx

stripe prices create \
  --product=prod_xxx \
  --unit-amount=2900 \
  --currency=usd \
  --recurring[interval]=month
# Note the price ID: price_xxx

stripe prices create \
  --product=prod_xxx \
  --unit-amount=29000 \
  --currency=usd \
  --recurring[interval]=year
# Annual price with ~17% discount
```

Add Stripe keys to environment:

```bash
# .env.local
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...  # Set in Step 4

# Price IDs for plans
STRIPE_STARTER_MONTHLY_PRICE_ID=price_xxx
STRIPE_STARTER_YEARLY_PRICE_ID=price_xxx
STRIPE_PRO_MONTHLY_PRICE_ID=price_yyy
STRIPE_PRO_YEARLY_PRICE_ID=price_yyy
```

**Verify**: Products and prices visible in Stripe Dashboard under Product Catalog.
**If failed**: Check API key permissions. Ensure you're in test mode (toggle in top-right of Stripe Dashboard).

### Step 2: Create Checkout Session API Route

**Duration**: 10-15 minutes
**Tool**: Code editor

**Provider A — Stripe Checkout (Next.js App Router):** [src1] [src5]

```typescript
// app/api/checkout/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const { priceId, userId, userEmail } = await request.json()

  // Check if customer exists in Stripe
  const customers = await stripe.customers.list({ email: userEmail, limit: 1 })
  let customerId = customers.data[0]?.id

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: userEmail,
      metadata: { userId },
    })
    customerId = customer.id
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${request.headers.get('origin')}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${request.headers.get('origin')}/pricing`,
    subscription_data: {
      metadata: { userId },
    },
  })

  return NextResponse.json({ url: session.url })
}
```

**Provider B — LemonSqueezy:** [src3]

```typescript
// app/api/checkout/route.ts
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const { variantId, userId, userEmail } = await request.json()

  const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
      'Content-Type': 'application/vnd.api+json',
    },
    body: JSON.stringify({
      data: {
        type: 'checkouts',
        attributes: {
          checkout_data: {
            email: userEmail,
            custom: { user_id: userId },
          },
        },
        relationships: {
          store: { data: { type: 'stores', id: process.env.LEMONSQUEEZY_STORE_ID } },
          variant: { data: { type: 'variants', id: variantId } },
        },
      },
    }),
  })

  const data = await response.json()
  return NextResponse.json({ url: data.data.attributes.url })
}
```

**Verify**: Clicking "Subscribe" redirects to Stripe/LemonSqueezy checkout page. Use test card 4242 4242 4242 4242.
**If failed**: Check API keys. Verify price/variant IDs match your dashboard. Check the success/cancel URLs are absolute.

### Step 3: Add Payment Database Schema

**Duration**: 5-10 minutes
**Tool**: SQL Editor

```sql
-- supabase/migrations/004_payments.sql

-- Link users to Stripe customers
CREATE TABLE customers (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  stripe_customer_id TEXT UNIQUE,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Track subscriptions
CREATE TABLE subscriptions (
  id TEXT PRIMARY KEY,  -- Stripe subscription ID (sub_xxx)
  user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  status TEXT NOT NULL CHECK (status IN (
    'active', 'past_due', 'canceled', 'incomplete',
    'incomplete_expired', 'trialing', 'unpaid', 'paused'
  )),
  price_id TEXT NOT NULL,
  plan_name TEXT,
  current_period_start TIMESTAMPTZ,
  current_period_end TIMESTAMPTZ,
  cancel_at_period_end BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

-- Enable RLS
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view own customer record"
  ON customers FOR SELECT USING (id = auth.uid());
CREATE POLICY "Users can view own subscriptions"
  ON subscriptions FOR SELECT USING (user_id = auth.uid());

-- Index for webhook lookups
CREATE INDEX idx_subscriptions_user_id ON subscriptions(user_id);
CREATE INDEX idx_customers_stripe_id ON customers(stripe_customer_id);
```

**Verify**: Tables created. RLS policies active.
**If failed**: Ensure auth.users reference exists (Supabase only).

### Step 4: Implement Webhook Handler

**Duration**: 15-20 minutes
**Tool**: Code editor

**Provider A — Stripe Webhooks:** [src4] [src5]

```typescript
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!  // Service role for webhook writes
)

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const userId = session.subscription
        ? (await stripe.subscriptions.retrieve(session.subscription as string))
            .metadata.userId
        : session.metadata?.userId

      // Save customer mapping
      await supabase.from('customers').upsert({
        id: userId,
        stripe_customer_id: session.customer as string,
      })
      break
    }

    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription
      const userId = subscription.metadata.userId

      await supabase.from('subscriptions').upsert({
        id: subscription.id,
        user_id: userId,
        status: subscription.status,
        price_id: subscription.items.data[0].price.id,
        current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
        current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
        cancel_at_period_end: subscription.cancel_at_period_end,
      })
      break
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      await supabase.from('subscriptions').update({
        status: 'canceled',
      }).eq('id', subscription.id)
      break
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice
      // Update subscription status — will be 'past_due'
      if (invoice.subscription) {
        const sub = await stripe.subscriptions.retrieve(invoice.subscription as string)
        await supabase.from('subscriptions').update({
          status: sub.status,
        }).eq('id', sub.id)
      }
      break
    }
  }

  return NextResponse.json({ received: true })
}
```

Set up local webhook forwarding for development:

```bash
# Forward Stripe webhooks to local development server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# This outputs the webhook signing secret (whsec_...)
# Add it to .env.local as STRIPE_WEBHOOK_SECRET
```

**Verify**: Complete a test checkout. Check that the subscription appears in the database. Trigger `customer.subscription.updated` via Stripe CLI: `stripe trigger customer.subscription.updated`.
**If failed**: Verify webhook signing secret matches. Check Stripe Dashboard > Webhooks > Recent Events for delivery status and errors.

### Step 5: Add Customer Portal and Subscription Checks

**Duration**: 10 minutes
**Tool**: Code editor

```typescript
// app/api/portal/route.ts — Customer self-service portal
import { NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const { customerId } = await request.json()

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${request.headers.get('origin')}/dashboard/settings`,
  })

  return NextResponse.json({ url: session.url })
}

// lib/subscription.ts — Check subscription status
import { createClient } from '@/lib/supabase/server'

export async function getUserSubscription() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return null

  const { data } = await supabase
    .from('subscriptions')
    .select('*')
    .eq('user_id', user.id)
    .in('status', ['active', 'trialing'])
    .single()

  return data
}

export async function requireSubscription() {
  const subscription = await getUserSubscription()
  if (!subscription) {
    throw new Error('Subscription required')
  }
  return subscription
}
```

Enable Customer Portal in Stripe Dashboard: Settings > Billing > Customer Portal > Enable.

**Verify**: Users can open the Stripe Customer Portal to manage subscriptions. Feature gates work — free users cannot access premium features.
**If failed**: Enable the Customer Portal in Stripe Dashboard. Verify the customer ID is correctly stored and passed to the portal session.

### Step 6: Configure Production Webhooks

**Duration**: 5 minutes
**Tool**: Stripe Dashboard

```
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click "Add endpoint"
3. Enter your production URL: https://yourdomain.com/api/webhooks/stripe
4. Select events to listen for:
   - checkout.session.completed
   - customer.subscription.created
   - customer.subscription.updated
   - customer.subscription.deleted
   - invoice.payment_failed
   - invoice.paid
5. Copy the webhook signing secret to your production environment variables
```

**Verify**: Stripe Dashboard shows successful webhook deliveries. Test with `stripe trigger checkout.session.completed`.
**If failed**: Check webhook URL is accessible (not blocked by auth middleware). Verify the signing secret is updated in production environment.

## Output Schema

```json
{
  "output_type": "payment_system",
  "format": "code + configured provider",
  "columns": [
    {"name": "payment_provider", "type": "string", "description": "Provider used (Stripe/LemonSqueezy/PayPal)", "required": true},
    {"name": "billing_model", "type": "string", "description": "Subscription, one-time, or usage-based", "required": true},
    {"name": "plans_configured", "type": "string", "description": "Plan names and price IDs", "required": true},
    {"name": "webhook_endpoint", "type": "string", "description": "Webhook URL for payment events", "required": true},
    {"name": "customer_portal", "type": "boolean", "description": "Self-service portal enabled", "required": true}
  ],
  "expected_row_count": "1",
  "sort_order": "N/A",
  "deduplication_key": "payment_provider"
}
```

## Quality Benchmarks

| Quality Metric | Minimum Acceptable | Good | Excellent |
|---------------|-------------------|------|-----------|
| Checkout completion | Flow works end-to-end | + Error handling on failures | + Retry logic + recovery |
| Webhook reliability | All events processed | + Idempotent handlers | + Dead letter queue |
| Subscription sync | Status synced to DB | + Plan/price synced | + Usage metrics synced |
| Customer portal | Users can cancel | + Upgrade/downgrade | + Invoice history + payment methods |
| Security | Webhook signature verified | + HTTPS only + API rate limiting | + Fraud detection (Stripe Radar) |

**If below minimum**: Verify webhook signature checking is implemented. Test the full flow with Stripe test cards (success, decline, 3D Secure). Check database for subscription status accuracy.

## Error Handling

| Error | Likely Cause | Recovery Action |
|-------|-------------|----------------|
| `No such price: price_xxx` | Wrong price ID or test/live mode mismatch | Verify price ID in Stripe Dashboard; check test vs live mode |
| `Webhook signature verification failed` | Wrong signing secret or request body modified | Use raw body (not parsed JSON) for verification; update signing secret |
| `Customer not found` | Customer created in test mode, queried in live mode | Ensure consistent mode usage; recreate customer in correct mode |
| Checkout page shows $0 | Price has $0 amount or free trial configured | Check price configuration in Stripe Dashboard |
| Subscription active in Stripe but not in DB | Webhook not received or failed to process | Check Stripe Dashboard > Webhooks > Recent Events for errors; replay failed events |
| `The webhook endpoint can't be reached` | Webhook URL blocked by auth middleware or firewall | Exclude webhook route from auth middleware; ensure HTTPS and public accessibility |

## Cost Breakdown

| Component | Stripe | LemonSqueezy | Stripe + Tax |
|-----------|--------|-------------|-------------|
| Transaction fee | 2.9% + $0.30 | 5% + $0.50 | 2.9% + $0.30 |
| Tax compliance | Self-managed | Included | + 0.5% (Stripe Tax) |
| Payout speed | 2 days | Net 15 | 2 days |
| At $1,000 MRR | ~$32/mo in fees | ~$55/mo in fees | ~$37/mo in fees |
| At $10,000 MRR | ~$320/mo | ~$550/mo | ~$370/mo |
| At $100,000 MRR | ~$3,200/mo | ~$5,500/mo | ~$3,700/mo |
| **Break-even for MoR** | N/A | Tax compliance saves ~$200/mo at $10K MRR | N/A |

## Anti-Patterns

### Wrong: Processing webhook events without signature verification
Anyone can POST to your webhook URL with a fake checkout.session.completed event. Without signature verification, attackers can grant themselves premium access for free. [src4]

### Correct: Always verify webhook signatures
Use `stripe.webhooks.constructEvent()` with the raw request body and webhook signing secret. Return 400 for invalid signatures.

### Wrong: Checking subscription status client-side only
Client-side state can be manipulated. A user can modify localStorage or React state to appear subscribed. [src5]

### Correct: Check subscription status server-side on every protected request
Query the subscriptions table (synced via webhooks) in server components, API routes, and middleware. Never trust client-side subscription state.

### Wrong: Using Stripe Checkout in live mode during development
Live mode transactions charge real credit cards and incur real fees. Test cards (4242...) only work in test mode. [src1]

### Correct: Develop and test entirely in Stripe test mode
Use `sk_test_*` keys locally. Only switch to live keys in production environment variables. Use Stripe CLI for local webhook forwarding.

### Wrong: Storing card numbers in your database
Storing raw card data violates PCI-DSS and exposes you to massive liability. Even encrypted card storage is nearly impossible to do correctly. [src1]

### Correct: Use Stripe Checkout or Elements exclusively
Stripe handles all card data in their PCI-compliant infrastructure. Your servers never see card numbers.

## When This Matters

Use this recipe when a developer needs to add payment processing to an MVP with authenticated users. Requires working authentication and a database. This recipe covers checkout, subscriptions, webhooks, customer portal, and usage-based billing — not pricing strategy, revenue analytics, or tax filing.

## Related Units

- [Authentication & Authorization Setup](/software/mvp-development/authentication-authorization-setup/2026) — auth required before payments
- [Database Schema Design for MVPs](/software/mvp-development/database-schema-design-for-mvps/2026) — payment tables schema
- [Coded MVP Architecture Templates](/software/mvp-development/coded-mvp-architecture-templates/2026) — project scaffold with payment patterns
- [Startup Analytics Implementation Guide](/business/startup-metrics/startup-analytics-implementation-guide/2026) — MRR tracking after payments work
- [SaaS Pricing Model Selection](/finance/saas-benchmarks/saas-pricing-model-selection/2026) — choose pricing before implementing
