Skip to main content
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.
  • 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)

curl -X POST 'http://localhost:3000/api/customer_portal' \
  -H 'Cookie: your-auth-cookie'

Example Request (Fetch API)

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)

{
  "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)

{
  "error": "Unauthorized"
}
User is not authenticated. They must sign in first.

Example Response (404 Not Found)

{
  "error": "No subscription found"
}
User doesn’t have an active subscription or their user_subscriptions record is missing.

Example Response (500 Server Error)

{
  "error": "Internal server error"
}
Stripe API error or database query failure.

How It Works

1

Authenticate user

The endpoint checks for an active Supabase session using supabase.auth.getUser().If no session exists, returns 401 Unauthorized.
2

Fetch subscription data

Queries user_subscriptions table to get the user’s Stripe Customer ID and Subscription ID:
SELECT stripe_customer_id, stripe_subscription_id
FROM user_subscriptions
WHERE user_id = $1
If no record found, returns 404 Not Found.
3

Create Customer Portal session

Calls Stripe API to create a Customer Portal session:
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)
4

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.

Implementation

The endpoint is implemented in src/app/api/customer_portal/route.ts:
src/app/api/customer_portal/route.ts
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:
src/app/(dashboard)/dashboard/settings/billing/page.tsx
"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)
The exact features available depend on your Stripe Customer Portal configuration. Configure the portal in Stripe Dashboard → Settings → Billing → Customer portal.

Portal Configuration

Using Custom Configuration

Set STRIPE_CUSTOMER_PORTAL_CONFIG_ID to customize portal behavior:
.env.local
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
Multiple configurations are useful for different user tiers. For example, free users might have limited options compared to enterprise customers.

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:
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
The return URL must be on the same domain as your site. Stripe validates this for security. Cross-domain redirects will fail.

Testing

Test Workflow

1

Create a test subscription

  1. Use checkout sessions endpoint to create a subscription
  2. Complete checkout with test card 4242 4242 4242 4242
  3. Verify subscription created in Stripe Dashboard
2

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)
3

Test portal features

Try these actions in the portal:
  • Update payment method (use different test card)
  • View invoice history
  • Cancel subscription
  • Reactivate subscription
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
4

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:
SELECT status, cancel_at_period_end, canceled_at
FROM user_subscriptions
WHERE user_id = 'your-user-id';

Troubleshooting

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()
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
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)
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
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)

Security Considerations

Endpoint verifies Supabase session before proceeding. Unauthenticated requests return 401.Why: Prevents unauthorized portal access and ensures users can only manage their own subscriptions.
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.
user_subscriptions table has Row Level Security enabled:
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.
Customer Portal sessions expire after 30 minutes of inactivity.Best practice: Always generate fresh sessions on user request. Never store or cache portal URLs.

Common Use Cases

// 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>
  );
}
// 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>
  );
}
// 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>
  );
}
// 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>
  );
}