Skip to main content

Payments

KwikSaaS uses Stripe for all payment processing. This guide covers setup, configuration, and testing.
Payments are pre-configured. This guide helps you customize plans and connect your Stripe account.

What’s Included

  • Stripe Checkout — Hosted payment page for secure transactions
  • Customer Portal — Self-service subscription management
  • Webhook sync — Automatic database updates on payment events
  • Plan gating — Feature access based on subscription status
  • Multiple pricing — Subscriptions (monthly/yearly) and lifetime one-time purchases

Payment Flow


Prerequisites

Stripe Account

Create at stripe.com. Use test mode for development.

Supabase Database

Migrations applied with billing tables.

Stripe Setup

1

Get API keys

Go to Stripe Dashboard → Developers → API keys:
KeyEnvironment VariableUsage
Publishable keyNEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYClient-side (safe to expose)
Secret keySTRIPE_SECRET_KEYServer-side (keep secret)
Use test mode keys during development. Toggle “Test mode” in the Stripe Dashboard header.
2

Create products

Go to Products in Stripe Dashboard:
  1. Click Add product
  2. Set name (e.g., “KwikSaaS Standard”)
  3. Add description
  4. Set pricing:
    • One-time for lifetime access
    • Recurring for subscriptions
  5. Save and copy the Price ID (starts with price_)
3

Add price IDs to environment

# .env.local
NEXT_PUBLIC_STRIPE_PRICE_ID_STANDARD_LIFETIME=price_...
NEXT_PUBLIC_STRIPE_PRICE_ID_ULTIMATE_LIFETIME=price_...

# For subscriptions (optional)

NEXT*PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=price*...
NEXT*PUBLIC_STRIPE_PRICE_ID_PRO_YEARLY=price*...

4

Set up webhooks

For local development:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copy the webhook signing secret (whsec_...) to:
STRIPE_WEBHOOK_SECRET=whsec_...
For production:
  1. Go to Developers → Webhooks in Stripe Dashboard
  2. Add endpoint: https://yourdomain.com/api/webhooks/stripe
  3. Select events:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_succeeded
    • invoice.payment_failed
    • payment_intent.succeeded
  4. Copy the signing secret to production environment

Plan Configuration

Plans are defined in src/lib/payments/plans.ts:
export const plans: Plan[] = [
  {
    id: "free",
    name: "Free",
    description: "Get started with the basics",
    monthlyPrice: 0,
    yearlyPrice: 0,
    stripePriceIds: { monthly: null, yearly: null },
    features: ["Starter features", "Community support"],
    featureAccess: ["basic"],
    isPopular: false,
    buttonText: "Get started",
    isFree: true,
  },
  {
    id: "standard",
    name: "Standard",
    description: "Lifetime access to core features",
    monthlyPrice: 39,
    yearlyPrice: null,
    stripePriceIds: {
      monthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_STANDARD_LIFETIME,
      yearly: null,
    },
    features: [
      "Lifetime access",
      "Core SaaS boilerplate",
      "Email & Auth setup",
    ],
    featureAccess: ["basic", "templates", "support"],
    isPopular: false,
    buttonText: "Get Standard",
    allowPromotionCodes: true,
  },
  {
    id: "ultimate",
    name: "Ultimate",
    description: "Everything plus GitHub access",
    monthlyPrice: 69,
    yearlyPrice: null,
    stripePriceIds: {
      monthly: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_ULTIMATE_LIFETIME,
      yearly: null,
    },
    features: [
      "Everything in Standard",
      "GitHub repo access",
      "Future updates",
    ],
    featureAccess: ["basic", "templates", "support", "api", "advanced"],
    isPopular: true,
    buttonText: "Get Ultimate",
    allowPromotionCodes: true,
  },
];

Plan Properties

PropertyDescription
idUnique identifier (used in database)
nameDisplay name
monthlyPricePrice shown on pricing page
stripePriceIdsStripe Price IDs for checkout
featuresDisplay features (marketing)
featureAccessFeature keys for access control
allowPromotionCodesEnable promo codes in checkout

API Endpoints

Create Checkout Session

POST /api/checkout_sessions
curl -X POST https://yourdomain.com/api/checkout_sessions \
  -H "Content-Type: application/json" \
  -d '{"priceId": "price_..."}'
Response:
{ "url": "https://checkout.stripe.com/c/pay/cs_..." }

Customer Portal

POST /api/customer_portal Opens Stripe’s billing portal for subscription management.
// Client-side usage
const response = await fetch("/api/customer_portal", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ return_url: "/dashboard" }),
});
const { url } = await response.json();
window.location.href = url;

Webhooks

POST /api/webhooks/stripe Handles Stripe events and updates database:
EventAction
checkout.session.completedCreate subscription or one-time purchase
customer.subscription.createdInsert subscription record
customer.subscription.updatedUpdate status, period dates
customer.subscription.deletedMark as canceled
invoice.payment_succeededLog payment history
invoice.payment_failedLog failure, update status
payment_intent.succeededRecord one-time purchase

Database Tables

user_subscriptions

Stores active subscription state:
ColumnDescription
user_idSupabase Auth user ID
stripe_customer_idStripe Customer ID
stripe_subscription_idStripe Subscription ID
stripe_price_idCurrent price ID
statusactive, trialing, canceled, past_due
billing_cyclemonthly or yearly
current_period_endWhen current period ends
cancel_at_period_endIf scheduled for cancellation

one_time_purchases

Stores lifetime/one-time purchases:
ColumnDescription
user_idSupabase Auth user ID
stripe_payment_intent_idStripe Payment Intent ID
statussucceeded, failed
granted_atWhen access was granted
revoked_atIf access was revoked

payment_history

Audit log of all payments:
ColumnDescription
user_idSupabase Auth user ID
amountPayment amount (cents)
currencyCurrency code
payment_typerecurring or one_time
invoice_urlLink to Stripe invoice

Access Control

Check user access with helpers in src/lib/access.ts:
import { hasAccess, canAccessFeature, requireFeature } from "@/lib/access";

// Check if user has any paid access
const hasPaidAccess = await hasAccess(userId);

// Check specific feature
const canUseApi = await canAccessFeature(userId, "api");

// Throw error if no access (for protected routes)
await requireFeature(userId, "advanced");

Feature Keys

KeyDescription
basicBasic app access (all plans)
templatesTemplate library
supportPriority support
apiAPI access
analyticsAdvanced analytics
custom_domainCustom domain support
advancedSubscriber-only features

Testing

Test Cards

Use these card numbers in Stripe test mode:
ScenarioCard Number
Successful payment4242 4242 4242 4242
Declined card4000 0000 0000 0002
Requires 3D Secure4000 0027 6000 3184
Insufficient funds4000 0000 0000 9995
Use any future expiry date and any 3-digit CVC.

Testing Webhooks Locally

  1. Start your dev server: npm run dev
  2. Start Stripe listener:
    stripe listen --forward-to localhost:3000/api/webhooks/stripe
    
  3. Complete a test checkout
  4. Check database for new records

Verify Webhook Events

# Trigger a test event
stripe trigger checkout.session.completed

Promo Codes & Coupons

Create in Stripe

  1. Go to Products → Coupons in Stripe Dashboard
  2. Create coupon with:
    • Percentage or fixed amount off
    • Duration (once, repeating, forever)
    • Redemption limits

Enable in Plans

{
  id: "standard",
  // ...
  allowPromotionCodes: true, // Enable promo codes in checkout
}

Troubleshooting

Check:
  • Price ID is correct and matches Stripe
  • Plan exists in plans.ts with matching price ID
  • Stripe is in correct mode (test vs live)
Check: - STRIPE_WEBHOOK_SECRET matches the endpoint - For local: restart stripe listen and update secret - For production: verify webhook URL is correct
Check: 1. Webhook listener is running 2. Check Stripe Dashboard → Developers → Webhooks for failures 3. Verify database migrations have been applied 4. Check server logs for errors
Check:
  • User has a stripe_customer_id in user_subscriptions
  • User is authenticated
  • Portal is enabled in Stripe Dashboard settings

Going to Production

1

Switch to live mode

Replace test API keys with live keys in production environment.
2

Create production products

Create new products in live mode (test products don’t transfer).
3

Add production webhook

Add endpoint in Stripe Dashboard pointing to production URL.
4

Update price IDs

Set production price IDs in environment variables.
5

Test with real card

Do a small real transaction to verify the flow.
Keep test and production environments completely separate. Never mix test and live API keys.

Next Steps