Skip to main content
This endpoint creates a Stripe Checkout session, redirecting the authenticated user to Stripe’s hosted checkout page to complete their subscription payment.
  • Endpoint: POST /api/checkout_sessions
  • File Location: src/app/api/checkout_sessions/route.ts
  • Authentication: Required (uses Supabase session)

Parameters

price_id
string
required
The Stripe Price ID for the subscription plan. Get this from your Stripe Dashboard or environment variables.Format: price_1234567890abcdefWhere to find:
  • Stripe Dashboard → Products → Select product → Copy Price ID
  • Or use environment variables: NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY

Request/Response

Example Request (cURL)

curl -X POST 'http://localhost:3000/api/checkout_sessions' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -H 'Cookie: your-auth-cookie' \
  -d 'price_id=price_1234567890abcdef'

Example Request (Fetch API)

const response = await fetch('/api/checkout_sessions', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
  },
  body: new URLSearchParams({
    price_id: 'price_1234567890abcdef'
  })
});

// Stripe Checkout redirects automatically (303)
// No JSON response to handle

Example Response (303 Redirect)

HTTP/1.1 303 See Other
Location: https://checkout.stripe.com/c/pay/cs_test_abc123...
The endpoint redirects to Stripe Checkout. Users complete payment there and return to your success URL.

Example Response (401 Unauthorized)

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

Example Response (400 Bad Request)

{
  "error": "Valid price ID is required"
}
Missing or invalid price_id parameter.

Example Response (500 Server Error)

{
  "error": "Error message from Stripe"
}
Stripe API error (invalid price ID, network issue, etc.)

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

Validate price ID

Extracts price_id from form data and validates it’s not empty or “undefined”.Invalid price IDs return 400 Bad Request.
3

Create Checkout session

Calls Stripe API to create a Checkout session with:
  • Line items: Subscription with selected price
  • Mode: "subscription" (recurring payment)
  • Success URL: {SITE_URL}/success?session_id={CHECKOUT_SESSION_ID}
  • Cancel URL: {SITE_URL}/pricing?canceled=true
  • Customer email: Pre-filled with user’s email
  • Metadata: Includes user_id and user_email for webhook processing
  • Features:
    • Automatic tax calculation
    • Promotion code support
    • Customer email pre-filled
4

Redirect to Stripe

Returns a 303 See Other redirect to the Stripe Checkout URL.User completes payment on Stripe’s secure hosted page.
5

Handle return

After payment:
  • Success: User redirected to /success?session_id=cs_...
  • Cancel: User redirected to /pricing?canceled=true
The success page verifies payment and shows confirmation.

Implementation

The endpoint is implemented in src/app/api/checkout_sessions/route.ts:
src/app/api/checkout_sessions/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/payments/stripe";
import { createClient } from "@/lib/supabase/server";

export async function POST(request: Request) {
  try {
    const headersList = await headers();
    const origin = headersList.get("origin");

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

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

    const formData = await request.formData();
    const price_id = formData.get("price_id") as string;

    if (!price_id || price_id === "undefined") {
      return NextResponse.json(
        { error: "Valid price ID is required" },
        { status: 400 }
      );
    }

    const session = await stripe.checkout.sessions.create({
      line_items: [
        {
          price: price_id,
          quantity: 1,
        },
      ],
      mode: "subscription",
      success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${origin}/pricing?canceled=true`,
      automatic_tax: { enabled: true },
      allow_promotion_codes: true,
      customer_email: user.email,
      metadata: {
        user_id: user.id,
        user_email: user.email || "",
      },
      subscription_data: {
        metadata: {
          user_id: user.id,
          user_email: user.email || "",
        },
      },
    });

    return NextResponse.redirect(session.url!, 303);
  } catch (err) {
    const error = err as Error;
    console.error("Checkout session creation error:", error);
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

Frontend Integration

Typically called from a pricing page or “Upgrade” button:
src/components/marketing/pricing.tsx
"use client";

export function PricingCard({ priceId, planName }) {
  const handleSubscribe = async () => {
    // Create form and submit to trigger checkout
    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();
  };

  return (
    <button onClick={handleSubscribe}>
      Subscribe to {planName}
    </button>
  );
}

Success Page Verification

After Stripe redirects to /success, verify the session:
src/app/success/page.tsx
import { stripe } from "@/lib/payments/stripe";
import { createClient } from "@/lib/supabase/server";

export default async function SuccessPage({ searchParams }) {
  const sessionId = searchParams.session_id;
  
  if (!sessionId) {
    return <div>No session ID provided</div>;
  }

  // Verify session with Stripe
  const session = await stripe.checkout.sessions.retrieve(sessionId);
  
  if (session.status === "complete") {
    // Payment successful!
    return <div>Thank you for subscribing!</div>;
  }
  
  if (session.status === "open") {
    // Payment still pending
    return <div>Payment in progress...</div>;
  }
  
  return <div>Payment failed</div>;
}

Webhook Integration

This endpoint only starts the checkout flow—subscription activation and billing history are synced by /api/webhooks/stripe. The webhook handler listens to these events:
EventPurpose
customer.subscription.createdUpserts the initial record in user_subscriptions using metadata from the Checkout session
customer.subscription.updatedHandles plan changes, cancellations, renewals, and billing-cycle changes
customer.subscription.deletedMarks subscriptions as canceled in the database
invoice.payment_succeededInserts a payment record into payment_history
invoice.payment_failedLogs failed payments so you can notify the customer
Because the handler reads the user_id from metadata you set in checkout_sessions, webhooks must run even if the user never returns to /success. See Stripe Webhook Handler for the full implementation details.
Never rely solely on the success page to activate subscriptions. Users can close the browser before returning. Always use webhooks for reliable subscription management.

Testing

Test with Stripe Test Cards

Use Stripe’s test cards to simulate payments:
# Successful payment
Card: 4242 4242 4242 4242
Expiry: Any future date
CVC: Any 3 digits
ZIP: Any 5 digits

Test Workflow

1

Ensure Stripe CLI is running

stripe listen --forward-to localhost:3000/api/webhooks/stripe
This forwards webhook events to your local server.
2

Start dev server

pnpm dev
3

Create checkout session

  1. Sign in to your app
  2. Go to /pricing
  3. Click “Subscribe” on any plan
  4. You’ll be redirected to Stripe Checkout (test mode)
4

Complete test payment

  1. Fill form with test card 4242 4242 4242 4242
  2. Click “Subscribe”
  3. Stripe processes payment and redirects to /success
5

Verify webhook received

Check your terminal running Stripe CLI. You should see one of the subscription or invoice events:
✔ Received event: customer.subscription.created
✔ Forwarded to http://localhost:3000/api/webhooks/stripe
✔ Response: 200
For invoice flows, you can also trigger:
stripe trigger invoice.payment_succeeded
6

Check database

Verify user_subscriptions and payment_history tables updated:
SELECT * FROM user_subscriptions WHERE user_id = 'your-user-id';
SELECT * FROM payment_history 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 with supabase.auth.getUser() in browser console
Cause: Missing or invalid price_id parameter.Fix:
  1. Verify form includes price_id field
  2. Check Price ID format: price_... (not prod_...)
  3. Ensure Price ID exists in Stripe Dashboard
  4. Test with hardcoded Price ID first
Cause: Stripe API error, network issue, or invalid configuration.Fix:
  1. Check STRIPE_SECRET_KEY environment variable
  2. Verify Stripe key matches mode (test vs live)
  3. Check browser console for errors
  4. Inspect server logs for Stripe API errors
Cause: Webhook not received or webhook handler error.Fix:
  1. Verify Stripe CLI running: stripe listen --forward-to localhost:3000/api/webhooks/stripe
  2. Check webhook endpoint: POST /api/webhooks/stripe
  3. Verify STRIPE_WEBHOOK_SECRET environment variable
  4. Check webhook handler logs for errors
  5. Test webhook: stripe trigger checkout.session.completed
Cause: Request made without origin header (e.g., from Postman).Fix: Use browser fetch or add origin header manually:
curl -X POST 'http://localhost:3000/api/checkout_sessions' \
  -H 'Origin: http://localhost:3000' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'price_id=price_...'

Security Considerations

The endpoint checks for an active Supabase session. Unauthenticated requests return 401.Why: Prevents anonymous users from creating checkout sessions and ensures proper user-subscription linking.
User ID and email are stored in Stripe metadata:
metadata: {
  user_id: user.id,
  user_email: user.email || "",
}
Purpose: Webhooks use this to update the correct user_subscriptions record.Privacy: Email stored in Stripe only, not exposed publicly.
The success page should verify the session ID with Stripe:
const session = await stripe.checkout.sessions.retrieve(sessionId);
Why: Prevents users from forging success URLs to fake payments.
The endpoint doesn’t return the Checkout session object (contains sensitive data). It only redirects to Stripe.Best practice: Stripe handles all payment details securely on their PCI-compliant infrastructure.