> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getsabo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payments with Stripe

> Complete guide to integrating Stripe subscriptions, managing plans, and handling webhooks in Sabo.

<Info>
  Sabo includes a production-ready Stripe integration with subscription management, checkout sessions, customer portal, and webhook handling. This guide walks you through setup, configuration, and testing.
</Info>

## Overview

Sabo's Stripe integration provides:

* **Subscription checkout** with Stripe Checkout hosted pages
* **Plan management** via centralized configuration in `src/lib/payments/plans.ts`
* **Customer portal** for subscription management, payment methods, and invoices
* **Webhook handling** for subscription lifecycle events and payment tracking
* **Database sync** with automatic updates to `user_subscriptions` and `payment_history` tables

## Quick Start

<Steps>
  <Step title="Get Stripe API keys">
    Create a Stripe account (or use test mode) and copy your API keys.

    1. Visit [Stripe Dashboard → API Keys](https://dashboard.stripe.com/test/apikeys)
    2. Ensure you're in **Test Mode** (toggle in top right)
    3. Copy your **Publishable key** (`pk_test_...`) and **Secret key** (`sk_test_...`)

    <Warning>
      Never expose your secret key (`sk_test_...` or `sk_live_...`) to the browser. Only use it in server-side code and environment variables.
    </Warning>
  </Step>

  <Step title="Add Stripe keys to environment">
    Add the following to your `.env.local` file:

    ```bash .env.local theme={null}
    # Site URL (used for Stripe Customer Portal redirects)
    NEXT_PUBLIC_SITE_URL=http://localhost:3000

    # Stripe API Keys
    STRIPE_SECRET_KEY=sk_test_your_secret_key_here
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here

    # Supabase Secret Key (required for webhooks)
    SUPABASE_SECRET_KEY=sb_secret_your_supabase_secret_key

    # Webhook secret (filled after Step 4)
    STRIPE_WEBHOOK_SECRET=

    # Price IDs (filled after Step 3)
    NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=
    NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY=
    ```

    <Tip>
      `NEXT_PUBLIC_SITE_URL` must match the origin of your app (e.g., `https://yourdomain.com` in production). The customer portal route uses this variable to send users back to `/dashboard/settings/billing`.
    </Tip>

    <Tip>
      Get your Supabase secret key from: Supabase Dashboard → Project Settings → API → Generate new secret key. Secret keys (`sb_secret_...`) are preferred over service role keys for better security.
    </Tip>
  </Step>

  <Step title="Create products and prices">
    Create the product and prices directly in the Stripe Dashboard:

    1. Go to [Stripe Dashboard → Products](https://dashboard.stripe.com/test/products) and click **Add product**.
    2. Name the product “Pro” (or any plan name that matches your `plans.ts` configuration).
    3. Under **Pricing**, add two recurring prices:
       * Monthly: `$12.00`, billing period **Monthly**
       * Yearly: `$120.00`, billing period **Yearly**
    4. Save the product and copy the generated price IDs (`price_...`).

    Paste the price IDs into your `.env.local` file:

    ```bash .env.local theme={null}
    NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=price_xxxxx
    NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY=price_xxxxx
    ```

    <Tip>
      If you change plan names or add additional tiers, mirror those changes in `src/lib/payments/plans.ts` and create matching prices in Stripe so the price IDs stay in sync.
    </Tip>

    After updating the values, restart your dev server so the new environment variables are loaded.
  </Step>

  <Step title="Set up webhook forwarding">
    Webhooks notify your app when subscription events occur (created, updated, cancelled, payments). Choose the approach that fits your environment:

    <Tabs>
      <Tab title="Option A · Stripe CLI (local development)">
        1. Install the Stripe CLI (see [docs](https://docs.stripe.com/stripe-cli)) and log in:

        ```bash theme={null}
        stripe login
        ```

        2. Forward events to your local app (keep this terminal running):

        ```bash theme={null}
        stripe listen --forward-to localhost:3000/api/webhooks/stripe
        ```

        3. Copy the webhook secret from the CLI output (`whsec_...`) and add it to `.env.local`:

        ```bash .env.local theme={null}
        STRIPE_WEBHOOK_SECRET=whsec_xxxxx
        ```

        <Check>
          Use two terminals: one for `pnpm dev`, another for `stripe listen`. Restart Stripe CLI whenever you restart the dev server.
        </Check>
      </Tab>

      <Tab title="Option B · Stripe Dashboard (deployed environments)">
        1. Go to [Stripe Dashboard → Webhooks](https://dashboard.stripe.com/test/webhooks) (use **Test mode** for staging, **Live mode** for production).
        2. Add an endpoint: `https://yourdomain.com/api/webhooks/stripe`.
        3. Subscribe to events: all `customer.subscription.*` and `invoice.payment_*` events (or leave set to "send all events" if you prefer).
        4. Copy the signing secret (`whsec_...`) and add it to your hosting provider's env configuration (or `.env.production`):

        ```bash theme={null}
        STRIPE_WEBHOOK_SECRET=whsec_xxxxx
        ```

        <Warning>
          Use separate webhook secrets for test vs. production mode; Stripe generates unique secrets per endpoint.
        </Warning>
      </Tab>
    </Tabs>
  </Step>

  <Step title="Test the integration">
    Start your development server and test the complete flow:

    ```bash theme={null}
    pnpm dev
    ```

    1. Visit `http://localhost:3000/pricing`
    2. Click "Upgrade to Pro"
    3. If not logged in, you'll be redirected to sign in
    4. After authentication, you'll be redirected to Stripe Checkout
    5. Use test card `4242 4242 4242 4242` (any future date, any CVC)
    6. Complete checkout and return to the success page 🎉
    7. Visit `/dashboard/settings/billing` to see your subscription

    <Check>
      Verify webhook events are received in the Stripe CLI terminal with "200 OK" responses.
    </Check>
  </Step>
</Steps>

***

## Configuration

### Plans Configuration

Sabo centralizes plan configuration in `src/lib/payments/plans.ts`. This file defines all subscription plans, pricing, features, and Stripe price IDs.

```ts src/lib/payments/plans.ts (excerpt) theme={null}
export interface Plan {
  id: string;
  name: string;
  description: string;
  monthlyPrice: number | null;
  yearlyPrice: number | null;
  stripePriceIds: { monthly: string | null; yearly: string | null };
  features: string[];
  isPopular: boolean;
  buttonText: string;
  isContactUs?: boolean;
  isFree?: boolean;
}

export const plans: Plan[] = [
  // ...free plan omitted...
  {
    id: "pro",
    name: "Pro",
    description: "Ideal for professionals and small teams",
    monthlyPrice: 12,
    yearlyPrice: 10, // $120/year = $10/month
    stripePriceIds: {
      monthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY || null,
      yearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY || null,
    },
    features: [
      "Custom domain",
      "SEO-optimizations",
      "Auto-generated API docs",
      "Built-in components library",
      "E-commerce integration",
      "User authentication system",
      "Multi-language support",
      "Real-time collaboration tools",
    ],
    isPopular: true,
    buttonText: "Upgrade to Pro",
  },
  // ...enterprise plan omitted...
];
```

The pricing component (`src/components/marketing/pricing.tsx`) automatically reads from this configuration.

<Tip>
  To add or modify plans, edit `plans.ts`, create corresponding products/prices in Stripe, and update the environment variables with new price IDs.
</Tip>

### Helper Functions

The same file also exports helper utilities used by pricing UI, API routes, and webhooks:

```ts src/lib/payments/plans.ts (excerpt) theme={null}
export function getPlanById(planId: string): Plan | undefined {
  return plans.find((plan) => plan.id === planId);
}

export function getPlanByPriceId(priceId: string): Plan | undefined {
  return plans.find(
    (plan) =>
      plan.stripePriceIds.monthly === priceId ||
      plan.stripePriceIds.yearly === priceId
  );
}

export function getBillingCycle(priceId: string): "month" | "year" {
  return plans.some((plan) => plan.stripePriceIds.yearly === priceId)
    ? "year"
    : "month";
}

export function formatPrice(amount: number): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 0,
  }).format(amount);
}
```

These helpers are imported by components such as `pricing.tsx` and the Stripe webhook handler to keep plan lookups consistent across the app.

***

## API Endpoints

Sabo provides three Stripe API endpoints for subscription management. This section offers a quick overview.

### Quick Reference

| Endpoint                      | Purpose                                                 | Authentication    | Documentation                                                |
| ----------------------------- | ------------------------------------------------------- | ----------------- | ------------------------------------------------------------ |
| `POST /api/checkout_sessions` | Create Stripe Checkout session for subscriptions        | Required          | [Full API docs →](/api-reference/endpoint/checkout-sessions) |
| `POST /api/customer_portal`   | Open Stripe Customer Portal for subscription management | Required          | [Full API docs →](/api-reference/endpoint/customer-portal)   |
| `POST /api/webhooks/stripe`   | Handle Stripe webhook events                            | Webhook signature | [Details below](#webhook-handler)                            |

### Common Usage Pattern

Here's how the pricing page initiates checkout:

```tsx src/components/marketing/pricing.tsx (excerpt) theme={null}
const handleSubscribe = async (priceId: string) => {
  // Create form and submit to checkout endpoint
  const form = document.createElement('form');
  form.method = 'POST';
  form.action = '/api/checkout_sessions';
  
  const input = document.createElement('input');
  input.type = 'hidden';
  input.name = 'price_id';
  input.value = priceId;
  
  form.appendChild(input);
  document.body.appendChild(form);
  form.submit(); // Redirects to Stripe Checkout
};
```

And accessing the Customer Portal:

```tsx src/app/(dashboard)/dashboard/settings/billing/page.tsx (excerpt) theme={null}
const handleManageSubscription = async () => {
  const response = await fetch('/api/customer_portal', {
    method: 'POST',
    credentials: 'include',
  });
  
  const { url } = await response.json();
  window.location.href = url; // Redirects to Stripe portal
};
```

<Tip>
  For complete endpoint documentation including request/response examples, implementation details, frontend integration, and troubleshooting, see the [API Reference](/api-reference/introduction).
</Tip>

<Info>
  Need to surface subscription status, cancel buttons, or invoices inside Sabo? Customize the billing settings UI described in <a href="/customization-guides/dashboard/settings">Dashboard → Settings</a>; it consumes the same subscription data that Stripe webhooks write.
</Info>

***

## Webhook Handler

The webhook endpoint (`POST /api/webhooks/stripe`) is critical for keeping your database in sync with Stripe subscription events.

### Supported Events

| Event                           | Trigger                                           | Action                               |
| ------------------------------- | ------------------------------------------------- | ------------------------------------ |
| `customer.subscription.created` | New subscription started                          | Creates `user_subscriptions` record  |
| `customer.subscription.updated` | Subscription changed (plan, status, cancellation) | Updates `user_subscriptions`         |
| `customer.subscription.deleted` | Subscription ended                                | Marks subscription as deleted        |
| `invoice.payment_succeeded`     | Payment successful                                | Records payment in `payment_history` |
| `invoice.payment_failed`        | Payment failed                                    | Records failed payment               |

### How It Works

```ts src/app/api/webhooks/stripe/route.ts (simplified) theme={null}
export async function POST(request: Request) {
  // 1. Verify webhook signature
  const signature = request.headers.get("stripe-signature");
  const event = stripe.webhooks.constructEvent(
    body, 
    signature, 
    process.env.STRIPE_WEBHOOK_SECRET
  );
  
  // 2. Process event based on type
  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated":
      // Update user_subscriptions table
      const subscription = event.data.object;
      await supabase.from("user_subscriptions").upsert({
        user_id: subscription.metadata.user_id,
        stripe_customer_id: subscription.customer,
        stripe_subscription_id: subscription.id,
        plan_name: getPlanByPriceId(subscription.items.data[0].price.id)?.name,
        status: subscription.status,
        billing_cycle: getBillingCycle(subscription.items.data[0].price.id),
        // ... more fields
      });
      break;
      
    case "invoice.payment_succeeded":
      // Record payment (requires fetching subscription for metadata)
      const invoice = event.data.object;
      const subscriptionId = invoice.subscription;
      const subscription = await stripe.subscriptions.retrieve(subscriptionId);
      const userId = subscription.metadata.user_id;

      await supabase.from("payment_history").insert({
        user_id: userId,
        stripe_subscription_id: subscriptionId,
        amount: invoice.amount_paid,
        currency: invoice.currency,
        status: "succeeded",
        invoice_url: invoice.hosted_invoice_url,
      });
      break;
  }
  
  return new Response(null, { status: 200 });
}
```

<Note>
  Stripe invoices don’t always include customer metadata directly. The production handler retrieves the subscription (and its metadata) when processing payment events to reliably recover `user_id`, even for complex invoices (e.g., those generated off-cycle or retried).
</Note>

### Key Implementation Details

<AccordionGroup>
  <Accordion title="Signature Verification">
    Webhooks verify requests are from Stripe using the `STRIPE_WEBHOOK_SECRET`:

    ```ts theme={null}
    const event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
    ```

    **Without verification**, anyone could send fake events to your endpoint. Always verify signatures in production.
  </Accordion>

  <Accordion title="User ID from Metadata">
    When creating checkout sessions, we store `user_id` in metadata:

    ```ts theme={null}
    // In /api/checkout_sessions
    subscription_data: {
      metadata: { user_id: user.id }
    }
    ```

    Webhooks use this metadata to link Stripe subscriptions to your Supabase users.
  </Accordion>

  <Accordion title="Plan Name Lookup">
    Webhooks use `getPlanByPriceId()` helper to determine plan names:

    ```ts theme={null}
    plan_name: getPlanByPriceId(priceId)?.name
    ```

    This ensures plan names stay consistent between your UI and database.
  </Accordion>

  <Accordion title="RLS Bypass with Service Client">
    Webhooks need `SUPABASE_SECRET_KEY` to bypass Row Level Security (RLS):

    ```ts theme={null}
    const supabase = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL,
      process.env.SUPABASE_SECRET_KEY // Service client
    );
    ```

    **Without this**, webhooks can't write to `user_subscriptions` or `payment_history` tables.
  </Accordion>
</AccordionGroup>

<Warning>
  **Required environment variable:** `SUPABASE_SECRET_KEY`

  Webhooks use the Supabase service client (not the regular auth client) to bypass Row Level Security when writing subscription data. Without this key, all webhook database operations will fail silently.
</Warning>

***

## Database Schema

Stripe webhooks automatically sync subscription data to two Supabase tables:

### user\_subscriptions

Stores current subscription state for each user.

| Column                      | Type        | Description                                        |
| --------------------------- | ----------- | -------------------------------------------------- |
| `id`                        | uuid        | Primary key                                        |
| `user_id`                   | uuid        | Foreign key to `auth.users`                        |
| `stripe_customer_id`        | text        | Stripe customer ID (`cus_...`)                     |
| `stripe_subscription_id`    | text        | Stripe subscription ID (`sub_...`)                 |
| `stripe_price_id`           | text        | Current price ID                                   |
| `plan_name`                 | text        | Human-readable plan name (from `plans.ts`)         |
| `status`                    | text        | `active`, `trialing`, `canceled`, `past_due`, etc. |
| `billing_cycle`             | text        | `month` or `year`                                  |
| `current_period_start`      | timestamptz | Start of current billing period                    |
| `current_period_end`        | timestamptz | End of current billing period                      |
| `cancel_at_period_end`      | boolean     | Will cancel at end of period                       |
| `cancel_at`                 | timestamptz | Scheduled cancellation date                        |
| `canceled_at`               | timestamptz | When user cancelled                                |
| `cancellation_reason`       | text        | Why cancelled                                      |
| `trial_start` / `trial_end` | timestamptz | Trial period dates                                 |
| `created_at` / `updated_at` | timestamptz | Record timestamps                                  |

### payment\_history

Stores all payment transactions for audit and billing history.

| Column                     | Type        | Description                              |
| -------------------------- | ----------- | ---------------------------------------- |
| `id`                       | uuid        | Primary key                              |
| `user_id`                  | uuid        | Foreign key to `auth.users`              |
| `stripe_subscription_id`   | text        | Related subscription                     |
| `stripe_payment_intent_id` | text        | Stripe payment intent ID (`pi_...`)      |
| `amount`                   | integer     | Amount in cents (e.g., `1200` = \$12.00) |
| `currency`                 | text        | Currency code (`usd`)                    |
| `status`                   | text        | `succeeded` or `failed`                  |
| `description`              | text        | Payment description                      |
| `invoice_url`              | text        | Stripe hosted invoice URL                |
| `created_at`               | timestamptz | Payment timestamp                        |

<Check>
  Both tables are automatically created by the Supabase migration in `supabase/migrations/20240101000000_create_user_profiles.sql`.
</Check>

<Tip>
  For a full tour of every column, Row Level Security policy, and TypeScript type, see the dedicated <a href="/core-features/database-supabase">Database with Supabase</a> guide.
</Tip>

***

## Testing

### Test Cards

Stripe provides test cards for various scenarios:

| Card Number           | Scenario                                 |
| --------------------- | ---------------------------------------- |
| `4242 4242 4242 4242` | Successful payment                       |
| `4000 0000 0000 0002` | Declined payment                         |
| `4000 0025 0000 3155` | Requires authentication (3D Secure)      |
| `4000 0000 0000 0341` | Attaches but fails on subsequent charges |

Use any future expiration date and any 3-digit CVC.

### Testing Checklist

<AccordionGroup>
  <Accordion title="1. Complete Checkout Flow">
    Test the full user journey from pricing to dashboard:

    1. Visit `/pricing` while logged out
    2. Click "Upgrade to Pro" → redirected to `/sign-in`
    3. Sign in and return to pricing
    4. Click "Upgrade to Pro" → redirected to Stripe Checkout
    5. Complete payment with test card
    6. Return to success page
    7. Check `/dashboard/settings/billing` for subscription details

    **Expected:**

    * Checkout session opens with correct price
    * Email is pre-filled
    * Success page shows after payment
    * Subscription appears in billing dashboard
  </Accordion>

  <Accordion title="2. Webhook Processing">
    Verify webhooks are received and processed correctly:

    1. Check Stripe CLI terminal for webhook events:
       ```
       --> customer.subscription.created [200 OK]
       --> invoice.payment_succeeded [200 OK]
       ```
    2. Check Supabase tables:
       * `user_subscriptions`: New row with subscription data
       * `payment_history`: Payment record with `status: "succeeded"`
    3. Verify plan name matches `plans.ts` configuration

    **Common issues:**

    * 400 errors: Wrong `STRIPE_WEBHOOK_SECRET`
    * 500 errors: Missing `SUPABASE_SECRET_KEY`
    * Empty plan\_name: Price ID not in `plans.ts`
  </Accordion>

  <Accordion title="3. Customer Portal">
    Test subscription management via Stripe Customer Portal:

    1. Go to `/dashboard/settings/billing`
    2. Click "Manage Subscription"
    3. Verify redirect to Stripe Customer Portal
    4. Test updating payment method
    5. Test cancelling subscription
    6. Check webhook events fire (`customer.subscription.updated`)
    7. Verify cancellation reflected in dashboard

    **Expected:**

    * Portal loads with correct subscription
    * Changes sync back to Supabase within seconds
    * `cancel_at_period_end` updates to `true`
  </Accordion>

  <Accordion title="4. Yearly vs Monthly Toggle">
    Test billing cycle switching:

    1. On `/pricing`, toggle between Monthly and Yearly
    2. Verify prices update (yearly shows discount)
    3. Click "Upgrade to Pro" for yearly
    4. Verify Stripe Checkout shows yearly price (\$120/year)
    5. Complete checkout
    6. Check `user_subscriptions.billing_cycle` is `"year"`

    **Expected:**

    * Price toggle animates smoothly
    * Checkout URL includes yearly price ID
    * Webhook records correct billing cycle
  </Accordion>
</AccordionGroup>

<Tip>
  For end-to-end automation of these scenarios (checkout, portal, cancellation), follow the <a href="/core-features/testing-playwright">Playwright testing guide</a>. It includes helpers for logging in with Supabase and examples of asserting billing UI.
</Tip>

***

## Production Deployment

<Steps>
  <Step title="Switch to live mode">
    In Stripe Dashboard, toggle from Test Mode to Live Mode (top right). Create new products and prices in live mode, or copy test mode products.

    Update `.env` (production environment):

    ```bash theme={null}
    STRIPE_SECRET_KEY=sk_live_...  # Live secret key
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...  # Live publishable key
    NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=price_live_...
    NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY=price_live_...
    ```
  </Step>

  <Step title="Create production webhook">
    In Stripe Dashboard → Webhooks, add a new endpoint:

    1. **Endpoint URL**: `https://yourdomain.com/api/webhooks/stripe`
    2. **Events to send**: Select all:
       * `customer.subscription.created`
       * `customer.subscription.updated`
       * `customer.subscription.deleted`
       * `invoice.payment_succeeded`
       * `invoice.payment_failed`
    3. **Copy signing secret** and add to production environment as `STRIPE_WEBHOOK_SECRET`

    <Warning>
      Production and test mode have separate webhook secrets. Make sure to use the correct secret for each environment.
    </Warning>
  </Step>

  <Step title="Test with real payment">
    Before going live, test with a real small-amount payment:

    1. Create a test plan with a low price (\$1 monthly)
    2. Complete checkout with a real card
    3. Verify webhook processing works
    4. Cancel the test subscription
    5. Switch back to your production pricing
  </Step>

  <Step title="Configure Customer Portal">
    Customize the Stripe Customer Portal for your brand:

    1. Go to Stripe Dashboard → Settings → Customer portal
    2. Configure:
       * **Branding**: Logo, colors, fonts
       * **Features**: Which actions customers can take (cancel, update payment, view invoices)
       * **Business information**: Support email, terms, privacy policy
    3. (Optional) Create a custom portal configuration and set `STRIPE_CUSTOMER_PORTAL_CONFIG_ID` environment variable

    <Check>
      Test the portal at `/dashboard/settings/billing` after configuration.
    </Check>
  </Step>
</Steps>

***

## Advanced Topics

### Handling One-Time Payments

By default, Sabo processes recurring subscriptions. To accept one-time payments (lifetime access, credits, digital products):

<Steps>
  <Step title="Create one-time price in Stripe">
    In Stripe Dashboard, create a new price with "One time" pricing model instead of "Recurring".
  </Step>

  <Step title="Add to plans.ts">
    ```ts src/lib/payments/plans.ts theme={null}
    {
      id: "lifetime",
      name: "Lifetime",
      monthlyPrice: 299,  // Display price
      yearlyPrice: null,
      stripePriceIds: {
        monthly: "price_your_onetime_id",  // Reuse monthly field
        yearly: null,
      },
      // ...
    }
    ```
  </Step>

  <Step title="Modify checkout API">
    Update `src/app/api/checkout_sessions/route.ts` to detect one-time vs subscription:

    ```ts src/app/api/checkout_sessions/route.ts theme={null}
    // Fetch price to check if it's recurring
    const price = await stripe.prices.retrieve(price_id);
    const mode = price.recurring ? "subscription" : "payment";

    const session = await stripe.checkout.sessions.create({
      mode: mode,  // "payment" for one-time, "subscription" for recurring
      line_items: [{ price: price_id, quantity: 1 }],
      // For payment mode, omit subscription_data
      ...(mode === "subscription" && {
        subscription_data: {
          metadata: { user_id: user.id },
        },
      }),
    });
    ```
  </Step>
</Steps>

### Custom Trial Periods

To add trial periods to subscriptions:

```ts src/app/api/checkout_sessions/route.ts theme={null}
const session = await stripe.checkout.sessions.create({
  // ... other parameters
  subscription_data: {
    trial_period_days: 14,  // 14-day free trial
    metadata: { user_id: user.id },
  },
});
```

Stripe webhooks automatically track trial start/end dates in `user_subscriptions` table.

### Promotional Codes

Sabo enables promotional codes by default:

```ts src/app/api/checkout_sessions/route.ts theme={null}
const session = await stripe.checkout.sessions.create({
  allow_promotion_codes: true,  // Customers can enter promo codes
// ...
});
```

Create promo codes in Stripe Dashboard → Products → Coupons.

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Unauthorized on checkout">
    **Cause**: User not authenticated.

    **Fix**:

    * Ensure user is logged in before clicking "Upgrade"
    * Pricing component automatically redirects to `/sign-in` if needed
    * Check `sessionStorage.getItem("signin-redirect-to")` is working
  </Accordion>

  <Accordion title="400 Bad Request - Invalid price ID">
    **Cause**: Price ID is `null`, `undefined`, or doesn't exist in Stripe.

    **Fix**:

    * Verify price IDs are set in `.env.local`
    * Run `npx tsx scripts/setup-stripe-products.ts` to create products
    * Restart dev server after changing env vars
    * Check price IDs match those in Stripe Dashboard
  </Accordion>

  <Accordion title="Webhook signature verification failed">
    **Cause**: Wrong `STRIPE_WEBHOOK_SECRET` or request not from Stripe.

    **Fix**:

    * For local: Copy webhook secret from Stripe CLI output
    * For production: Copy signing secret from Stripe Dashboard webhook settings
    * Ensure webhook URL is exactly `/api/webhooks/stripe`
    * Check Stripe CLI is running: `stripe listen --forward-to localhost:3000/api/webhooks/stripe`
  </Accordion>

  <Accordion title="Subscription created but not in database">
    **Cause**: Missing `SUPABASE_SECRET_KEY` or webhook processing error.

    **Fix**:

    * Add `SUPABASE_SECRET_KEY` to environment (get from Supabase Dashboard → Settings → API)
    * Check webhook handler logs in terminal for errors
    * Verify `user_id` is in subscription metadata
    * Check Supabase table permissions (RLS policies)
  </Accordion>

  <Accordion title="Plan name shows as null or 'Unknown Plan'">
    **Cause**: Price ID not found in `plans.ts`.

    **Fix**:

    * Add price ID to `stripePriceIds` in `plans.ts`
    * Ensure environment variables match plan configuration
    * Webhook uses `getPlanByPriceId()` to look up plan names
  </Accordion>

  <Accordion title="Customer portal not working">
    **Cause**: No subscription found or missing customer ID.

    **Fix**:

    * User must have an active subscription first
    * Check `user_subscriptions` table has `stripe_customer_id`
    * Verify subscription status is not `canceled` or `incomplete`
    * Test creating a new subscription first
  </Accordion>
</AccordionGroup>
