> ## 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.

# Stripe Customer Portal

> Create a Stripe Customer Portal session for subscription management

<Info>
  This endpoint creates a Stripe Customer Portal session, allowing authenticated users to manage their subscription, update payment methods, and view invoices through Stripe's hosted interface.
</Info>

* **Endpoint**: `POST /api/customer_portal`
* **File Location**: `src/app/api/customer_portal/route.ts`
* **Authentication**: Required (uses Supabase session)
* **Requires**: Active Stripe subscription

## Request/Response

#### Example Request (cURL)

```bash theme={null}
curl -X POST 'http://localhost:3000/api/customer_portal' \
  -H 'Cookie: your-auth-cookie'
```

#### Example Request (Fetch API)

```javascript theme={null}
const response = await fetch('/api/customer_portal', {
  method: 'POST',
  credentials: 'include', // Include cookies
});

const { url } = await response.json();

// Redirect to Customer Portal
window.location.href = url;
```

#### Example Response (200 Success)

```json theme={null}
{
  "url": "https://billing.stripe.com/p/session/test_abc123..."
}
```

The response contains the Customer Portal URL. Redirect the user to this URL to access their subscription management interface.

#### Example Response (401 Unauthorized)

```json theme={null}
{
  "error": "Unauthorized"
}
```

User is not authenticated. They must sign in first.

#### Example Response (404 Not Found)

```json theme={null}
{
  "error": "No subscription found"
}
```

User doesn't have an active subscription or their `user_subscriptions` record is missing.

#### Example Response (500 Server Error)

```json theme={null}
{
  "error": "Internal server error"
}
```

Stripe API error or database query failure.

## How It Works

<Steps>
  <Step title="Authenticate user">
    The endpoint checks for an active Supabase session using `supabase.auth.getUser()`.

    If no session exists, returns `401 Unauthorized`.
  </Step>

  <Step title="Fetch subscription data">
    Queries `user_subscriptions` table to get the user's Stripe Customer ID and Subscription ID:

    ```sql theme={null}
    SELECT stripe_customer_id, stripe_subscription_id
    FROM user_subscriptions
    WHERE user_id = $1
    ```

    If no record found, returns `404 Not Found`.
  </Step>

  <Step title="Create Customer Portal session">
    Calls Stripe API to create a Customer Portal session:

    ```ts theme={null}
    await stripe.billingPortal.sessions.create({
      customer: subscription.stripe_customer_id,
      return_url: `${SITE_URL}/dashboard/settings/billing`,
      configuration: process.env.STRIPE_CUSTOMER_PORTAL_CONFIG_ID,
    });
    ```

    The portal is configured with:

    * **Customer**: User's Stripe Customer ID
    * **Return URL**: Where to redirect after portal actions
    * **Configuration**: Custom portal settings (optional)
  </Step>

  <Step title="Return portal URL">
    Returns JSON with the portal URL. The frontend redirects the user to this URL.

    User can now manage their subscription on Stripe's secure hosted page.
  </Step>
</Steps>

## Implementation

The endpoint is implemented in `src/app/api/customer_portal/route.ts`:

```ts src/app/api/customer_portal/route.ts theme={null}
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import { stripe } from "@/lib/payments/stripe";

export async function POST() {
  try {
    const supabase = await createClient();

    // Check user authentication
    const {
      data: { user },
      error: authError,
    } = await supabase.auth.getUser();

    if (authError || !user) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    // Get Stripe Customer ID and Subscription ID from subscription info
    const { data: subscription, error: subscriptionError } = await supabase
      .from("user_subscriptions")
      .select("stripe_customer_id, stripe_subscription_id")
      .eq("user_id", user.id)
      .single();

    if (subscriptionError || !subscription?.stripe_customer_id) {
      return NextResponse.json(
        { error: "No subscription found" },
        { status: 404 }
      );
    }

    // Create Stripe Customer Portal session
    const portalSession = await stripe.billingPortal.sessions.create({
      customer: subscription.stripe_customer_id,
      return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/settings/billing`,
      configuration: process.env.STRIPE_CUSTOMER_PORTAL_CONFIG_ID,
    });

    return NextResponse.json({ url: portalSession.url });
  } catch (error) {
    console.error("Error creating portal session:", error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}
```

## Frontend Integration

Typically called from a "Manage Subscription" button on the billing settings page:

```tsx src/app/(dashboard)/dashboard/settings/billing/page.tsx theme={null}
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";

export function ManageSubscriptionButton() {
  const [isLoading, setIsLoading] = useState(false);

  const handleManageSubscription = async () => {
    setIsLoading(true);

    try {
      const response = await fetch('/api/customer_portal', {
        method: 'POST',
        credentials: 'include',
      });

      if (!response.ok) {
        throw new Error('Failed to create portal session');
      }

      const { url } = await response.json();
      
      // Redirect to Customer Portal
      window.location.href = url;
    } catch (error) {
      console.error('Portal error:', error);
      setIsLoading(false);
      // Show error toast to user
    }
  };

  return (
    <Button onClick={handleManageSubscription} disabled={isLoading}>
      {isLoading ? 'Loading...' : 'Manage Subscription'}
    </Button>
  );
}
```

## Customer Portal Features

When users access the Customer Portal, they can:

### Subscription Management

* **View current plan** and pricing
* **Upgrade/downgrade** plans
* **Cancel subscription** (immediate or at period end)
* **Reactivate** canceled subscriptions
* **View renewal date** and billing cycle

### Payment Methods

* **Add** new payment methods
* **Update** existing payment methods
* **Set default** payment method
* **Remove** old payment methods

### Billing History

* **View all invoices** (past and upcoming)
* **Download PDF** invoices
* **View payment status** (paid, failed, pending)
* **See payment details** (amount, date, method)

### Customer Information

* **Update billing address**
* **Update email** for receipts
* **View tax IDs** (if applicable)

<Note>
  The exact features available depend on your Stripe Customer Portal configuration. Configure the portal in Stripe Dashboard → Settings → Billing → Customer portal.
</Note>

## Portal Configuration

### Using Custom Configuration

Set `STRIPE_CUSTOMER_PORTAL_CONFIG_ID` to customize portal behavior:

```bash .env.local theme={null}
STRIPE_CUSTOMER_PORTAL_CONFIG_ID=bpc_1234567890abcdef
```

**To create a configuration:**

1. Go to Stripe Dashboard → Settings → Billing → Customer portal
2. Click "New configuration"
3. Configure allowed features:
   * Subscription cancellation (immediate or at period end)
   * Subscription pause
   * Plan changes (upgrade/downgrade)
   * Payment method management
   * Invoice history
4. Save and copy the configuration ID

<Tip>
  Multiple configurations are useful for different user tiers. For example, free users might have limited options compared to enterprise customers.
</Tip>

### Default Configuration

If `STRIPE_CUSTOMER_PORTAL_CONFIG_ID` is not set, Stripe uses your account's default configuration.

**To set the default:**

1. Stripe Dashboard → Settings → Billing → Customer portal
2. Configure your default portal settings
3. Click "Activate" to make it the default

## Return URL Behavior

After users finish managing their subscription in the portal, they're redirected to the `return_url`:

```ts theme={null}
return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard/settings/billing`
```

**On return:**

* Portal actions (cancellation, plan change) are already processed
* Webhooks have updated your database
* Refresh the billing page to show updated subscription status

<Warning>
  The return URL must be on the same domain as your site. Stripe validates this for security. Cross-domain redirects will fail.
</Warning>

## Testing

### Test Workflow

<Steps>
  <Step title="Create a test subscription">
    1. Use [checkout sessions endpoint](/api-reference/endpoint/checkout-sessions) to create a subscription
    2. Complete checkout with test card `4242 4242 4242 4242`
    3. Verify subscription created in Stripe Dashboard
  </Step>

  <Step title="Access Customer Portal">
    1. Sign in to your app
    2. Go to `/dashboard/settings/billing`
    3. Click "Manage Subscription" button
    4. You'll be redirected to Stripe Customer Portal (test mode)
  </Step>

  <Step title="Test portal features">
    Try these actions in the portal:

    * Update payment method (use different test card)
    * View invoice history
    * Cancel subscription
    * Reactivate subscription

    <Tip>
      Use Stripe test cards to simulate different scenarios:

      * `4242 4242 4242 4242` - Successful payments
      * `4000 0000 0000 0341` - Declined payment
      * `4000 0000 0000 3220` - 3D Secure authentication required
    </Tip>
  </Step>

  <Step title="Verify webhooks">
    After portal actions, check Stripe CLI output:

    ```
    ✔ Received event: customer.subscription.updated
    ✔ Forwarded to http://localhost:3000/api/webhooks/stripe
    ✔ Response: 200
    ```

    Verify database updated:

    ```sql theme={null}
    SELECT status, cancel_at_period_end, canceled_at
    FROM user_subscriptions
    WHERE user_id = 'your-user-id';
    ```
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Unauthorized error">
    **Cause:** User not authenticated or session expired.

    **Fix:**

    1. Verify user is signed in
    2. Check Supabase session cookie exists
    3. Test authentication: `supabase.auth.getUser()`
  </Accordion>

  <Accordion title="404 No subscription found">
    **Cause:** User hasn't subscribed yet or `user_subscriptions` record missing.

    **Fix:**

    1. Check if user has completed checkout
    2. Verify webhook processed successfully
    3. Query database: `SELECT * FROM user_subscriptions WHERE user_id = '...'`
    4. Check webhook logs for errors during subscription creation
  </Accordion>

  <Accordion title="Portal URL expired error">
    **Cause:** Customer Portal sessions expire after 30 minutes.

    **Fix:**

    * Always generate a new portal session when user clicks button
    * Don't cache or reuse portal URLs
    * Current implementation creates fresh session on each request (correct)
  </Accordion>

  <Accordion title="Return URL not working">
    **Cause:** Return URL domain doesn't match your site.

    **Fix:**

    1. Verify `NEXT_PUBLIC_SITE_URL` matches your actual domain
    2. Ensure no trailing slash: `https://yourdomain.com` not `https://yourdomain.com/`
    3. Check Stripe Dashboard → Settings → Billing → Customer portal → Allowed domains
  </Accordion>

  <Accordion title="Configuration not applying">
    **Cause:** Wrong configuration ID or not active.

    **Fix:**

    1. Verify `STRIPE_CUSTOMER_PORTAL_CONFIG_ID` format: `bpc_...`
    2. Check configuration exists: Stripe Dashboard → Settings → Billing → Customer portal
    3. Ensure configuration is activated
    4. Test without config ID (uses default)
  </Accordion>
</AccordionGroup>

## Security Considerations

<AccordionGroup>
  <Accordion title="Authentication Required">
    Endpoint verifies Supabase session before proceeding. Unauthenticated requests return `401`.

    **Why:** Prevents unauthorized portal access and ensures users can only manage their own subscriptions.
  </Accordion>

  <Accordion title="Customer ID Validation">
    The endpoint fetches Customer ID from your database (`user_subscriptions` table), not from user input.

    **Why:** Prevents users from accessing other customers' portals by manipulating request parameters.
  </Accordion>

  <Accordion title="RLS Protection">
    `user_subscriptions` table has Row Level Security enabled:

    ```sql theme={null}
    CREATE POLICY "Users can view their own subscription"
      ON user_subscriptions
      FOR SELECT
      USING (auth.uid() = user_id);
    ```

    **Result:** Users can only query their own subscription data.
  </Accordion>

  <Accordion title="Session Expiration">
    Customer Portal sessions expire after 30 minutes of inactivity.

    **Best practice:** Always generate fresh sessions on user request. Never store or cache portal URLs.
  </Accordion>
</AccordionGroup>

## Common Use Cases

<AccordionGroup>
  <Accordion title="Cancel subscription flow">
    ```tsx theme={null}
    // User clicks "Cancel Subscription" in your app
    // → Redirects to Customer Portal
    // → User confirms cancellation in portal
    // → Webhook updates your database
    // → User returns to billing page with updated status

    export function CancelButton() {
      return (
        <Button onClick={async () => {
          const res = await fetch('/api/customer_portal', { method: 'POST' });
          const { url } = await res.json();
          window.location.href = url;
        }}>
          Cancel Subscription
        </Button>
      );
    }
    ```
  </Accordion>

  <Accordion title="Update payment method">
    ```tsx theme={null}
    // User clicks "Update Payment Method"
    // → Opens Customer Portal
    // → User adds/updates card
    // → Webhook confirms payment method updated
    // → User returns to billing page

    export function UpdatePaymentButton() {
      return (
        <Button onClick={async () => {
          const res = await fetch('/api/customer_portal', { method: 'POST' });
          const { url } = await res.json();
          window.location.href = url;
        }}>
          Update Payment Method
        </Button>
      );
    }
    ```
  </Accordion>

  <Accordion title="View invoice history">
    ```tsx theme={null}
    // User clicks "View Invoices"
    // → Opens Customer Portal
    // → User downloads PDF invoices
    // → User returns to billing page

    export function ViewInvoicesButton() {
      return (
        <Button variant="outline" onClick={async () => {
          const res = await fetch('/api/customer_portal', { method: 'POST' });
          const { url } = await res.json();
          window.location.href = url;
        }}>
          View Invoices
        </Button>
      );
    }
    ```
  </Accordion>

  <Accordion title="Reactivate canceled subscription">
    ```tsx theme={null}
    // User's subscription is canceled but still active until period end
    // → User clicks "Reactivate"
    // → Opens Customer Portal
    // → User confirms reactivation
    // → Webhook updates cancel_at_period_end to false
    // → Subscription continues

    export function ReactivateButton() {
      return (
        <Button onClick={async () => {
          const res = await fetch('/api/customer_portal', { method: 'POST' });
          const { url } = await res.json();
          window.location.href = url;
        }}>
          Reactivate Subscription
        </Button>
      );
    }
    ```
  </Accordion>
</AccordionGroup>

## Related Documentation

<CardGroup cols={2}>
  <Card title="Payments with Stripe" icon="credit-card" href="/core-features/payments-with-stripe">
    Complete Stripe integration guide including webhooks, plans, and Customer Portal configuration.
  </Card>

  <Card title="Create Checkout Session" icon="shopping-cart" href="/api-reference/endpoint/checkout-sessions">
    Learn how users initially subscribe to your product before accessing the Customer Portal.
  </Card>

  <Card title="Stripe Webhooks" icon="webhook" href="/core-features/payments-with-stripe#webhook-handler">
    Understand how portal actions sync to your database via webhooks.
  </Card>

  <Card title="Database Schema" icon="database" href="/core-features/database-supabase">
    See how subscription data is stored in `user_subscriptions` table.
  </Card>
</CardGroup>
