Skip to main content
8Space includes optional Stripe integration for payment processing. Configure Stripe to enable billing features in the landing site.
Billing is optional. If Stripe is not configured, payment features will be disabled.

Overview

The Stripe integration supports:
  • One-time payments (Stripe Checkout in payment mode)
  • Recurring subscriptions (Stripe Checkout in subscription mode)
  • Customer portal for subscription management
  • Webhook handling for payment events
  • Tax ID collection
  • Promotional codes

Environment Variables

Add Stripe credentials to packages/landing/.env.local:
STRIPE_SECRET_KEY
string
required
Stripe secret key for API authentication.Test mode: sk_test_...Live mode: sk_live_...Get from: Stripe Dashboard → Developers → API keys
STRIPE_WEBHOOK_SECRET
string
required
Webhook signing secret for verifying Stripe webhook events.Format: whsec_...Get from: Stripe Dashboard → Developers → Webhooks

Example Configuration

packages/landing/.env.local
STRIPE_SECRET_KEY=sk_test_51Abc123...
STRIPE_WEBHOOK_SECRET=whsec_xyz789...
Never commit Stripe secrets to version control. Add .env.local to .gitignore.

Stripe Setup

1

Create Stripe Account

Sign up at stripe.com if you don’t have an account.
2

Get API Keys

  1. Navigate to Developers → API keys
  2. Copy the Secret key (starts with sk_test_ or sk_live_)
  3. Add to .env.local as STRIPE_SECRET_KEY
3

Create Products & Prices

  1. Navigate to Products
  2. Click “Add product”
  3. Set name, description, and pricing
  4. Copy the Price ID (starts with price_)
Use this Price ID in your checkout calls.
4

Configure Webhooks

  1. Navigate to Developers → Webhooks
  2. Click “Add endpoint”
  3. Set endpoint URL:
    • Local: Use Stripe CLI
    • Production: https://yourdomain.com/api/stripe/webhook
  4. Select events to listen for
  5. Copy the Signing secret (starts with whsec_)
  6. Add to .env.local as STRIPE_WEBHOOK_SECRET

API Implementation

The Stripe integration is implemented in packages/landing/libs/stripe.ts and used by Next.js API routes.

Stripe Client

packages/landing/libs/stripe.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2023-08-16',
  typescript: true,
});

Checkout Session

Create a checkout session for payment or subscription:
packages/landing/libs/stripe.ts
export const createCheckout = async ({
  priceId,
  mode,
  successUrl,
  cancelUrl,
  clientReferenceId,
  user,
  couponId,
}: CreateCheckoutParams): Promise<string> => {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
    apiVersion: '2023-08-16',
    typescript: true,
  });

  const extraParams: any = {};

  if (user?.customerId) {
    extraParams.customer = user.customerId;
  } else {
    if (mode === 'payment') {
      extraParams.customer_creation = 'always';
      extraParams.payment_intent_data = {
        setup_future_usage: 'on_session'
      };
    }
    if (user?.email) {
      extraParams.customer_email = user.email;
    }
    extraParams.tax_id_collection = { enabled: true };
  }

  const stripeSession = await stripe.checkout.sessions.create({
    mode,
    allow_promotion_codes: true,
    client_reference_id: clientReferenceId,
    line_items: [{ price: priceId, quantity: 1 }],
    discounts: couponId ? [{ coupon: couponId }] : [],
    success_url: successUrl,
    cancel_url: cancelUrl,
    ...extraParams,
  });

  return stripeSession.url;
};

Customer Portal

Create a customer portal session for subscription management:
packages/landing/libs/stripe.ts
export const createCustomerPortal = async ({
  customerId,
  returnUrl,
}: CreateCustomerPortalParams): Promise<string> => {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
    apiVersion: '2023-08-16',
    typescript: true,
  });

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: returnUrl,
  });

  return portalSession.url;
};

API Routes

The landing package includes API routes for Stripe operations.

Create Checkout Route

Endpoint: POST /api/stripe/create-checkout Location: packages/landing/app/api/stripe/create-checkout/route.ts Request body:
{
  "priceId": "price_1Abc123",
  "mode": "subscription",
  "successUrl": "https://example.com/success",
  "cancelUrl": "https://example.com/cancel"
}
Response:
{
  "url": "https://checkout.stripe.com/c/pay/cs_..."
}
Implementation:
packages/landing/app/api/stripe/create-checkout/route.ts
export async function POST(req: NextRequest) {
  const body = await req.json();

  if (!body.priceId || !body.successUrl || !body.cancelUrl || !body.mode) {
    return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
  }

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

    const stripeSessionURL = await createCheckout({
      priceId: body.priceId,
      mode: body.mode,
      successUrl: body.successUrl,
      cancelUrl: body.cancelUrl,
      clientReferenceId: user?.id,
      user: user ? { email: user.email, customerId: null } : null,
    });

    return NextResponse.json({ url: stripeSessionURL });
  } catch (e: any) {
    return NextResponse.json({ error: e?.message }, { status: 500 });
  }
}

Create Portal Route

Endpoint: POST /api/stripe/create-portal Location: packages/landing/app/api/stripe/create-portal/route.ts Request body:
{
  "returnUrl": "https://example.com/billing"
}
Response:
{
  "url": "https://billing.stripe.com/p/session/..."
}
The portal route currently returns an error if no customerId is found. You’ll need to implement customer lookup from your database.
Implementation:
packages/landing/app/api/stripe/create-portal/route.ts
export async function POST(req: NextRequest) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: 'Not signed in' }, { status: 401 });
  }

  const body = await req.json();
  if (!body.returnUrl) {
    return NextResponse.json({ error: 'Return URL is required' }, { status: 400 });
  }

  // TODO: Look up customerId from database
  const customerId: string | null = null;

  if (!customerId) {
    return NextResponse.json(
      { error: "You don't have a billing account yet. Make a purchase first." },
      { status: 400 }
    );
  }

  const stripePortalUrl = await createCustomerPortal({
    customerId,
    returnUrl: body.returnUrl,
  });

  return NextResponse.json({ url: stripePortalUrl });
}

Customer Data Storage

The current implementation does not store Stripe customer IDs in the database. You’ll need to add this functionality.
Add a table to store Stripe customer data:
create table public.stripe_customers (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references public.profiles (id) on delete cascade,
  stripe_customer_id text not null unique,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create unique index stripe_customers_user_id_idx
  on public.stripe_customers (user_id);

Update Customer Portal

Modify the portal route to look up the customer ID:
// Look up customerId from database
const { data: customer } = await supabase
  .from('stripe_customers')
  .select('stripe_customer_id')
  .eq('user_id', user.id)
  .single();

const customerId = customer?.stripe_customer_id;

Webhook Handling

Webhooks notify your application of Stripe events (payment success, subscription canceled, etc.).

Webhook Route

Create packages/landing/app/api/stripe/webhook/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-08-16',
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature')!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err: any) {
    console.error('Webhook signature verification failed:', err.message);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Handle the event
  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object as Stripe.Checkout.Session;
      // TODO: Save customer ID, grant access, send confirmation email
      console.log('Checkout completed:', session.id);
      break;

    case 'customer.subscription.updated':
      const subscription = event.data.object as Stripe.Subscription;
      // TODO: Update subscription status in database
      console.log('Subscription updated:', subscription.id);
      break;

    case 'customer.subscription.deleted':
      const deletedSubscription = event.data.object as Stripe.Subscription;
      // TODO: Revoke access
      console.log('Subscription canceled:', deletedSubscription.id);
      break;

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

Important Webhook Events

EventDescription
checkout.session.completedPayment successful, grant access
customer.subscription.createdNew subscription started
customer.subscription.updatedSubscription plan changed
customer.subscription.deletedSubscription canceled
invoice.payment_succeededRecurring payment successful
invoice.payment_failedPayment failed, notify user

Local Testing with Stripe CLI

Test webhooks locally using the Stripe CLI.
1

Install Stripe CLI

Download from stripe.com/docs/stripe-cliOr install via package manager:
# macOS
brew install stripe/stripe-cli/stripe

# Linux/WSL
curl -L https://github.com/stripe/stripe-cli/releases/latest/download/stripe_linux_x86_64.tar.gz | tar -xz
2

Authenticate

stripe login
3

Forward Webhooks

stripe listen --forward-to localhost:3000/api/stripe/webhook
Copy the webhook signing secret (starts with whsec_) and add to .env.local:
STRIPE_WEBHOOK_SECRET=whsec_...
4

Trigger Test Events

stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted

Testing Payments

Test Cards

Stripe provides test cards for different scenarios:
Card NumberScenario
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Card declined
4000 0025 0000 3155Requires authentication (3D Secure)
4000 0000 0000 9995Insufficient funds
Use any future expiry date and any 3-digit CVC.

Test Checkout Flow

const response = await fetch('/api/stripe/create-checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    priceId: 'price_1Abc123',
    mode: 'payment',
    successUrl: window.location.origin + '/success',
    cancelUrl: window.location.origin + '/cancel',
  }),
});

const { url } = await response.json();
window.location.href = url; // Redirect to Stripe Checkout

Production Checklist

1

Switch to Live Mode

  • Get live API keys from Stripe Dashboard
  • Update STRIPE_SECRET_KEY with sk_live_...
  • Update webhook secret with live endpoint secret
2

Configure Webhook Endpoint

  • Add production URL to Stripe Dashboard
  • Select events to listen for
  • Test webhook delivery
3

Test Payment Flow

  • Use real credit cards in test mode first
  • Verify webhooks are received
  • Check customer data is saved
4

Security Review

  • Verify webhook signature validation
  • Ensure secrets are not committed
  • Enable HTTPS only
  • Review Stripe security best practices

Troubleshooting

  • Verify STRIPE_WEBHOOK_SECRET matches the endpoint secret
  • For local testing, use Stripe CLI’s secret (starts with whsec_)
  • Check that you’re passing the raw request body to constructEvent
Implement customer ID storage:
  1. Create stripe_customers table
  2. Save customer ID on checkout completion
  3. Update portal route to look up customer ID
  • Verify STRIPE_SECRET_KEY is set correctly
  • Check that Price ID exists in Stripe Dashboard
  • Ensure mode matches price type (payment for one-time, subscription for recurring)

Next Steps

Environment Variables

Configure all required Stripe variables

Database Setup

Add customer table for Stripe IDs

Build docs developers (and LLMs) love