Skip to main content
Sabo includes a production-ready Stripe integration with subscription management, checkout sessions, customer portal, and webhook handling. This guide walks you through setup, configuration, and testing.

Overview

Sabo’s Stripe integration provides:
  • Subscription checkout with Stripe Checkout hosted pages
  • Plan management via centralized configuration in src/lib/payments/plans.ts
  • Customer portal for subscription management, payment methods, and invoices
  • Webhook handling for subscription lifecycle events and payment tracking
  • Database sync with automatic updates to user_subscriptions and payment_history tables

Quick Start

1

Get Stripe API keys

Create a Stripe account (or use test mode) and copy your API keys.
  1. Visit Stripe Dashboard → API Keys
  2. Ensure you’re in Test Mode (toggle in top right)
  3. Copy your Publishable key (pk_test_...) and Secret key (sk_test_...)
Never expose your secret key (sk_test_... or sk_live_...) to the browser. Only use it in server-side code and environment variables.
2

Add Stripe keys to environment

Add the following to your .env.local file:
.env.local
# Site URL (used for Stripe Customer Portal redirects)
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# Stripe API Keys
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here

# Supabase Secret Key (required for webhooks)
SUPABASE_SECRET_KEY=sb_secret_your_supabase_secret_key

# Webhook secret (filled after Step 4)
STRIPE_WEBHOOK_SECRET=

# Price IDs (filled after Step 3)
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY=
NEXT_PUBLIC_SITE_URL must match the origin of your app (e.g., https://yourdomain.com in production). The customer portal route uses this variable to send users back to /dashboard/settings/billing.
Get your Supabase secret key from: Supabase Dashboard → Project Settings → API → Generate new secret key. Secret keys (sb_secret_...) are preferred over service role keys for better security.
3

Create products and prices

Create the product and prices directly in the Stripe Dashboard:
  1. Go to Stripe Dashboard → Products and click Add product.
  2. Name the product “Pro” (or any plan name that matches your plans.ts configuration).
  3. Under Pricing, add two recurring prices:
    • Monthly: $12.00, billing period Monthly
    • Yearly: $120.00, billing period Yearly
  4. Save the product and copy the generated price IDs (price_...).
Paste the price IDs into your .env.local file:
.env.local
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=price_xxxxx
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY=price_xxxxx
If you change plan names or add additional tiers, mirror those changes in src/lib/payments/plans.ts and create matching prices in Stripe so the price IDs stay in sync.
After updating the values, restart your dev server so the new environment variables are loaded.
4

Set up webhook forwarding

Webhooks notify your app when subscription events occur (created, updated, cancelled, payments). Choose the approach that fits your environment:
  • Option A · Stripe CLI (local development)
  • Option B · Stripe Dashboard (deployed environments)
  1. Install the Stripe CLI (see docs) and log in:
stripe login
  1. Forward events to your local app (keep this terminal running):
stripe listen --forward-to localhost:3000/api/webhooks/stripe
  1. Copy the webhook secret from the CLI output (whsec_...) and add it to .env.local:
.env.local
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
Use two terminals: one for pnpm dev, another for stripe listen. Restart Stripe CLI whenever you restart the dev server.
5

Test the integration

Start your development server and test the complete flow:
pnpm dev
  1. Visit http://localhost:3000/pricing
  2. Click “Upgrade to Pro”
  3. If not logged in, you’ll be redirected to sign in
  4. After authentication, you’ll be redirected to Stripe Checkout
  5. Use test card 4242 4242 4242 4242 (any future date, any CVC)
  6. Complete checkout and return to the success page 🎉
  7. Visit /dashboard/settings/billing to see your subscription
Verify webhook events are received in the Stripe CLI terminal with “200 OK” responses.

Configuration

Plans Configuration

Sabo centralizes plan configuration in src/lib/payments/plans.ts. This file defines all subscription plans, pricing, features, and Stripe price IDs.
src/lib/payments/plans.ts (excerpt)
export interface Plan {
  id: string;
  name: string;
  description: string;
  monthlyPrice: number | null;
  yearlyPrice: number | null;
  stripePriceIds: { monthly: string | null; yearly: string | null };
  features: string[];
  isPopular: boolean;
  buttonText: string;
  isContactUs?: boolean;
  isFree?: boolean;
}

export const plans: Plan[] = [
  // ...free plan omitted...
  {
    id: "pro",
    name: "Pro",
    description: "Ideal for professionals and small teams",
    monthlyPrice: 12,
    yearlyPrice: 10, // $120/year = $10/month
    stripePriceIds: {
      monthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY || null,
      yearly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY || null,
    },
    features: [
      "Custom domain",
      "SEO-optimizations",
      "Auto-generated API docs",
      "Built-in components library",
      "E-commerce integration",
      "User authentication system",
      "Multi-language support",
      "Real-time collaboration tools",
    ],
    isPopular: true,
    buttonText: "Upgrade to Pro",
  },
  // ...enterprise plan omitted...
];
The pricing component (src/components/marketing/pricing.tsx) automatically reads from this configuration.
To add or modify plans, edit plans.ts, create corresponding products/prices in Stripe, and update the environment variables with new price IDs.

Helper Functions

The same file also exports helper utilities used by pricing UI, API routes, and webhooks:
src/lib/payments/plans.ts (excerpt)
export function getPlanById(planId: string): Plan | undefined {
  return plans.find((plan) => plan.id === planId);
}

export function getPlanByPriceId(priceId: string): Plan | undefined {
  return plans.find(
    (plan) =>
      plan.stripePriceIds.monthly === priceId ||
      plan.stripePriceIds.yearly === priceId
  );
}

export function getBillingCycle(priceId: string): "month" | "year" {
  return plans.some((plan) => plan.stripePriceIds.yearly === priceId)
    ? "year"
    : "month";
}

export function formatPrice(amount: number): string {
  return new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: "USD",
    minimumFractionDigits: 0,
  }).format(amount);
}
These helpers are imported by components such as pricing.tsx and the Stripe webhook handler to keep plan lookups consistent across the app.

API Endpoints

Sabo provides three Stripe API endpoints for subscription management. This section offers a quick overview.

Quick Reference

EndpointPurposeAuthenticationDocumentation
POST /api/checkout_sessionsCreate Stripe Checkout session for subscriptionsRequiredFull API docs →
POST /api/customer_portalOpen Stripe Customer Portal for subscription managementRequiredFull API docs →
POST /api/webhooks/stripeHandle Stripe webhook eventsWebhook signatureDetails below

Common Usage Pattern

Here’s how the pricing page initiates checkout:
src/components/marketing/pricing.tsx (excerpt)
const handleSubscribe = async (priceId: string) => {
  // Create form and submit to checkout endpoint
  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(); // Redirects to Stripe Checkout
};
And accessing the Customer Portal:
src/app/(dashboard)/dashboard/settings/billing/page.tsx (excerpt)
const handleManageSubscription = async () => {
  const response = await fetch('/api/customer_portal', {
    method: 'POST',
    credentials: 'include',
  });
  
  const { url } = await response.json();
  window.location.href = url; // Redirects to Stripe portal
};
For complete endpoint documentation including request/response examples, implementation details, frontend integration, and troubleshooting, see the API Reference.
Need to surface subscription status, cancel buttons, or invoices inside Sabo? Customize the billing settings UI described in Dashboard → Settings; it consumes the same subscription data that Stripe webhooks write.

Webhook Handler

The webhook endpoint (POST /api/webhooks/stripe) is critical for keeping your database in sync with Stripe subscription events.

Supported Events

EventTriggerAction
customer.subscription.createdNew subscription startedCreates user_subscriptions record
customer.subscription.updatedSubscription changed (plan, status, cancellation)Updates user_subscriptions
customer.subscription.deletedSubscription endedMarks subscription as deleted
invoice.payment_succeededPayment successfulRecords payment in payment_history
invoice.payment_failedPayment failedRecords failed payment

How It Works

src/app/api/webhooks/stripe/route.ts (simplified)
export async function POST(request: Request) {
  // 1. Verify webhook signature
  const signature = request.headers.get("stripe-signature");
  const event = stripe.webhooks.constructEvent(
    body, 
    signature, 
    process.env.STRIPE_WEBHOOK_SECRET
  );
  
  // 2. Process event based on type
  switch (event.type) {
    case "customer.subscription.created":
    case "customer.subscription.updated":
      // Update user_subscriptions table
      const subscription = event.data.object;
      await supabase.from("user_subscriptions").upsert({
        user_id: subscription.metadata.user_id,
        stripe_customer_id: subscription.customer,
        stripe_subscription_id: subscription.id,
        plan_name: getPlanByPriceId(subscription.items.data[0].price.id)?.name,
        status: subscription.status,
        billing_cycle: getBillingCycle(subscription.items.data[0].price.id),
        // ... more fields
      });
      break;
      
    case "invoice.payment_succeeded":
      // Record payment (requires fetching subscription for metadata)
      const invoice = event.data.object;
      const subscriptionId = invoice.subscription;
      const subscription = await stripe.subscriptions.retrieve(subscriptionId);
      const userId = subscription.metadata.user_id;

      await supabase.from("payment_history").insert({
        user_id: userId,
        stripe_subscription_id: subscriptionId,
        amount: invoice.amount_paid,
        currency: invoice.currency,
        status: "succeeded",
        invoice_url: invoice.hosted_invoice_url,
      });
      break;
  }
  
  return new Response(null, { status: 200 });
}
Stripe invoices don’t always include customer metadata directly. The production handler retrieves the subscription (and its metadata) when processing payment events to reliably recover user_id, even for complex invoices (e.g., those generated off-cycle or retried).

Key Implementation Details

Webhooks verify requests are from Stripe using the STRIPE_WEBHOOK_SECRET:
const event = stripe.webhooks.constructEvent(
  body,
  signature,
  process.env.STRIPE_WEBHOOK_SECRET
);
Without verification, anyone could send fake events to your endpoint. Always verify signatures in production.
When creating checkout sessions, we store user_id in metadata:
// In /api/checkout_sessions
subscription_data: {
  metadata: { user_id: user.id }
}
Webhooks use this metadata to link Stripe subscriptions to your Supabase users.
Webhooks use getPlanByPriceId() helper to determine plan names:
plan_name: getPlanByPriceId(priceId)?.name
This ensures plan names stay consistent between your UI and database.
Webhooks need SUPABASE_SECRET_KEY to bypass Row Level Security (RLS):
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.SUPABASE_SECRET_KEY // Service client
);
Without this, webhooks can’t write to user_subscriptions or payment_history tables.
Required environment variable: SUPABASE_SECRET_KEYWebhooks use the Supabase service client (not the regular auth client) to bypass Row Level Security when writing subscription data. Without this key, all webhook database operations will fail silently.

Database Schema

Stripe webhooks automatically sync subscription data to two Supabase tables:

user_subscriptions

Stores current subscription state for each user.
ColumnTypeDescription
iduuidPrimary key
user_iduuidForeign key to auth.users
stripe_customer_idtextStripe customer ID (cus_...)
stripe_subscription_idtextStripe subscription ID (sub_...)
stripe_price_idtextCurrent price ID
plan_nametextHuman-readable plan name (from plans.ts)
statustextactive, trialing, canceled, past_due, etc.
billing_cycletextmonth or year
current_period_starttimestamptzStart of current billing period
current_period_endtimestamptzEnd of current billing period
cancel_at_period_endbooleanWill cancel at end of period
cancel_attimestamptzScheduled cancellation date
canceled_attimestamptzWhen user cancelled
cancellation_reasontextWhy cancelled
trial_start / trial_endtimestamptzTrial period dates
created_at / updated_attimestamptzRecord timestamps

payment_history

Stores all payment transactions for audit and billing history.
ColumnTypeDescription
iduuidPrimary key
user_iduuidForeign key to auth.users
stripe_subscription_idtextRelated subscription
stripe_payment_intent_idtextStripe payment intent ID (pi_...)
amountintegerAmount in cents (e.g., 1200 = $12.00)
currencytextCurrency code (usd)
statustextsucceeded or failed
descriptiontextPayment description
invoice_urltextStripe hosted invoice URL
created_attimestamptzPayment timestamp
Both tables are automatically created by the Supabase migration in supabase/migrations/20240101000000_create_user_profiles.sql.
For a full tour of every column, Row Level Security policy, and TypeScript type, see the dedicated Database with Supabase guide.

Testing

Test Cards

Stripe provides test cards for various scenarios:
Card NumberScenario
4242 4242 4242 4242Successful payment
4000 0000 0000 0002Declined payment
4000 0025 0000 3155Requires authentication (3D Secure)
4000 0000 0000 0341Attaches but fails on subsequent charges
Use any future expiration date and any 3-digit CVC.

Testing Checklist

Test the full user journey from pricing to dashboard:
  1. Visit /pricing while logged out
  2. Click “Upgrade to Pro” → redirected to /sign-in
  3. Sign in and return to pricing
  4. Click “Upgrade to Pro” → redirected to Stripe Checkout
  5. Complete payment with test card
  6. Return to success page
  7. Check /dashboard/settings/billing for subscription details
Expected:
  • Checkout session opens with correct price
  • Email is pre-filled
  • Success page shows after payment
  • Subscription appears in billing dashboard
Verify webhooks are received and processed correctly:
  1. Check Stripe CLI terminal for webhook events:
    --> customer.subscription.created [200 OK]
    --> invoice.payment_succeeded [200 OK]
    
  2. Check Supabase tables:
    • user_subscriptions: New row with subscription data
    • payment_history: Payment record with status: "succeeded"
  3. Verify plan name matches plans.ts configuration
Common issues:
  • 400 errors: Wrong STRIPE_WEBHOOK_SECRET
  • 500 errors: Missing SUPABASE_SECRET_KEY
  • Empty plan_name: Price ID not in plans.ts
Test subscription management via Stripe Customer Portal:
  1. Go to /dashboard/settings/billing
  2. Click “Manage Subscription”
  3. Verify redirect to Stripe Customer Portal
  4. Test updating payment method
  5. Test cancelling subscription
  6. Check webhook events fire (customer.subscription.updated)
  7. Verify cancellation reflected in dashboard
Expected:
  • Portal loads with correct subscription
  • Changes sync back to Supabase within seconds
  • cancel_at_period_end updates to true
Test billing cycle switching:
  1. On /pricing, toggle between Monthly and Yearly
  2. Verify prices update (yearly shows discount)
  3. Click “Upgrade to Pro” for yearly
  4. Verify Stripe Checkout shows yearly price ($120/year)
  5. Complete checkout
  6. Check user_subscriptions.billing_cycle is "year"
Expected:
  • Price toggle animates smoothly
  • Checkout URL includes yearly price ID
  • Webhook records correct billing cycle
For end-to-end automation of these scenarios (checkout, portal, cancellation), follow the Playwright testing guide. It includes helpers for logging in with Supabase and examples of asserting billing UI.

Production Deployment

1

Switch to live mode

In Stripe Dashboard, toggle from Test Mode to Live Mode (top right). Create new products and prices in live mode, or copy test mode products.Update .env (production environment):
STRIPE_SECRET_KEY=sk_live_...  # Live secret key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...  # Live publishable key
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=price_live_...
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY=price_live_...
2

Create production webhook

In Stripe Dashboard → Webhooks, add a new endpoint:
  1. Endpoint URL: https://yourdomain.com/api/webhooks/stripe
  2. Events to send: Select all:
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_succeeded
    • invoice.payment_failed
  3. Copy signing secret and add to production environment as STRIPE_WEBHOOK_SECRET
Production and test mode have separate webhook secrets. Make sure to use the correct secret for each environment.
3

Test with real payment

Before going live, test with a real small-amount payment:
  1. Create a test plan with a low price ($1 monthly)
  2. Complete checkout with a real card
  3. Verify webhook processing works
  4. Cancel the test subscription
  5. Switch back to your production pricing
4

Configure Customer Portal

Customize the Stripe Customer Portal for your brand:
  1. Go to Stripe Dashboard → Settings → Customer portal
  2. Configure:
    • Branding: Logo, colors, fonts
    • Features: Which actions customers can take (cancel, update payment, view invoices)
    • Business information: Support email, terms, privacy policy
  3. (Optional) Create a custom portal configuration and set STRIPE_CUSTOMER_PORTAL_CONFIG_ID environment variable
Test the portal at /dashboard/settings/billing after configuration.

Advanced Topics

Handling One-Time Payments

By default, Sabo processes recurring subscriptions. To accept one-time payments (lifetime access, credits, digital products):
1

Create one-time price in Stripe

In Stripe Dashboard, create a new price with “One time” pricing model instead of “Recurring”.
2

Add to plans.ts

src/lib/payments/plans.ts
{
  id: "lifetime",
  name: "Lifetime",
  monthlyPrice: 299,  // Display price
  yearlyPrice: null,
  stripePriceIds: {
    monthly: "price_your_onetime_id",  // Reuse monthly field
    yearly: null,
  },
  // ...
}
3

Modify checkout API

Update src/app/api/checkout_sessions/route.ts to detect one-time vs subscription:
src/app/api/checkout_sessions/route.ts
// Fetch price to check if it's recurring
const price = await stripe.prices.retrieve(price_id);
const mode = price.recurring ? "subscription" : "payment";

const session = await stripe.checkout.sessions.create({
  mode: mode,  // "payment" for one-time, "subscription" for recurring
  line_items: [{ price: price_id, quantity: 1 }],
  // For payment mode, omit subscription_data
  ...(mode === "subscription" && {
    subscription_data: {
      metadata: { user_id: user.id },
    },
  }),
});

Custom Trial Periods

To add trial periods to subscriptions:
src/app/api/checkout_sessions/route.ts
const session = await stripe.checkout.sessions.create({
  // ... other parameters
  subscription_data: {
    trial_period_days: 14,  // 14-day free trial
    metadata: { user_id: user.id },
  },
});
Stripe webhooks automatically track trial start/end dates in user_subscriptions table.

Promotional Codes

Sabo enables promotional codes by default:
src/app/api/checkout_sessions/route.ts
const session = await stripe.checkout.sessions.create({
  allow_promotion_codes: true,  // Customers can enter promo codes
// ...
});
Create promo codes in Stripe Dashboard → Products → Coupons.

Troubleshooting

Cause: User not authenticated.Fix:
  • Ensure user is logged in before clicking “Upgrade”
  • Pricing component automatically redirects to /sign-in if needed
  • Check sessionStorage.getItem("signin-redirect-to") is working
Cause: Price ID is null, undefined, or doesn’t exist in Stripe.Fix:
  • Verify price IDs are set in .env.local
  • Run npx tsx scripts/setup-stripe-products.ts to create products
  • Restart dev server after changing env vars
  • Check price IDs match those in Stripe Dashboard
Cause: Wrong STRIPE_WEBHOOK_SECRET or request not from Stripe.Fix:
  • For local: Copy webhook secret from Stripe CLI output
  • For production: Copy signing secret from Stripe Dashboard webhook settings
  • Ensure webhook URL is exactly /api/webhooks/stripe
  • Check Stripe CLI is running: stripe listen --forward-to localhost:3000/api/webhooks/stripe
Cause: Missing SUPABASE_SECRET_KEY or webhook processing error.Fix:
  • Add SUPABASE_SECRET_KEY to environment (get from Supabase Dashboard → Settings → API)
  • Check webhook handler logs in terminal for errors
  • Verify user_id is in subscription metadata
  • Check Supabase table permissions (RLS policies)
Cause: Price ID not found in plans.ts.Fix:
  • Add price ID to stripePriceIds in plans.ts
  • Ensure environment variables match plan configuration
  • Webhook uses getPlanByPriceId() to look up plan names
Cause: No subscription found or missing customer ID.Fix:
  • User must have an active subscription first
  • Check user_subscriptions table has stripe_customer_id
  • Verify subscription status is not canceled or incomplete
  • Test creating a new subscription first