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:
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
Create Stripe Account
Sign up at stripe.com if you don’t have an account.
Get API Keys
Navigate to Developers → API keys
Copy the Secret key (starts with sk_test_ or sk_live_)
Add to .env.local as STRIPE_SECRET_KEY
Create Products & Prices
Navigate to Products
Click “Add product”
Set name, description, and pricing
Copy the Price ID (starts with price_)
Use this Price ID in your checkout calls.
Configure Webhooks
Navigate to Developers → Webhooks
Click “Add endpoint”
Set endpoint URL:
Local: Use Stripe CLI
Production: https://yourdomain.com/api/stripe/webhook
Select events to listen for
Copy the Signing secret (starts with whsec_)
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.
Recommended Schema
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
Event Description 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.
Install Stripe CLI
Download from stripe.com/docs/stripe-cli Or 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
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_...
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 Number Scenario 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
One-time Payment
Subscription
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
const response = await fetch ( '/api/stripe/create-checkout' , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ({
priceId: 'price_1Xyz789' ,
mode: 'subscription' ,
successUrl: window . location . origin + '/welcome' ,
cancelUrl: window . location . origin + '/pricing' ,
}),
});
const { url } = await response . json ();
window . location . href = url ;
Production Checklist
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
Configure Webhook Endpoint
Add production URL to Stripe Dashboard
Select events to listen for
Test webhook delivery
Test Payment Flow
Use real credit cards in test mode first
Verify webhooks are received
Check customer data is saved
Security Review
Verify webhook signature validation
Ensure secrets are not committed
Enable HTTPS only
Review Stripe security best practices
Troubleshooting
Webhook signature verification failed
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
Customer portal returns 'No billing account'
Implement customer ID storage:
Create stripe_customers table
Save customer ID on checkout completion
Update portal route to look up customer ID
Checkout session creation fails
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