BetterStarter logoBetterStarter
Features

Stripe

Payment processing with Stripe subscriptions and one-time payments.

Overview

BetterStarter integrates Stripe for:

  1. One-time purchases - Products with one-time checkout
  2. Subscriptions - Recurring billing
  3. Entitlements - Track what users have access to
  4. Webhooks - Handle payment events

Setup

1. Get Stripe Keys

  1. Go to stripe.com
  2. Create account and verify identity
  3. Copy Publishable Key and Secret Key from Dashboard

2. Environment Variables

# .env.local
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...

3. Webhook Secret

For production webhook handling:

# From Stripe CLI
stripe listen --forward-to localhost:3000/api/stripe/webhook

# Get signing secret
STRIPE_WEBHOOK_SECRET=whsec_...

One-Time Payments

Product Creation

Create a product with pricing in Stripe Dashboard or via API.

Checkout Flow

import { loadStripe } from '@stripe/js'
import { createServerFn } from '@tanstack/react-start'

export const createCheckoutSession = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])
  .handler(async ({ context, data }) => {
    const user = context.user
    
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price: 'price_...',
          quantity: 1,
        },
      ],
      mode: 'payment',
      customer_email: user.email,
      success_url: 'https://yourdomain.com/purchase/success',
      cancel_url: 'https://yourdomain.com/purchase/cancel',
    })
    
    return { sessionId: session.id }
  })

Client-Side Checkout

function CheckoutButton() {
  const { mutate: createSession } = useMutation({
    mutationFn: createCheckoutSession,
  })
  
  const handleCheckout = async () => {
    const { sessionId } = await createSession()
    const stripe = await loadStripe(env.STRIPE_PUBLIC_KEY)
    await stripe.redirectToCheckout({ sessionId })
  }
  
  return <Button onClick={handleCheckout}>Purchase Now</Button>
}

Subscriptions

Creating Subscriptions

const subscription = await stripe.subscriptions.create({
  customer: customerId,
  items: [
    {
      price: 'price_monthly_...', // Recurring price
    },
  ],
  payment_behavior: 'default_incomplete',
  expansion: ['latest_invoice.payment_intent'],
})

Handling Subscription Events

Webhooks notify your app of subscription changes:

// Route: /api/stripe/webhook

export const handleStripeWebhook = async (event) => {
  switch (event.type) {
    case 'customer.subscription.created':
      // Grant access to premium features
      break
    
    case 'customer.subscription.updated':
      // Handle plan changes
      break
    
    case 'customer.subscription.deleted':
      // Revoke access
      break
    
    case 'invoice.payment_succeeded':
      // Confirm recurring payment
      break
    
    case 'invoice.payment_failed':
      // Notify user of retry
      break
  }
}

Entitlements

Track what users have access to via entitlements.

Database Schema

// From Drizzle schema
export const entitlement = pgTable('entitlement', {
  id: serial('id').primaryKey(),
  userId: integer('user_id').references(() => user.id),
  stripeSubscriptionId: text('stripe_subscription_id'),
  productId: text('product_id'),
  expiresAt: timestamp('expires_at'),
  createdAt: timestamp('created_at').defaultNow(),
})

Checking Access

async function hasAccess(userId: string, feature: string): Promise<boolean> {
  const now = new Date()
  
  const entitlement = await db.query.entitlement.findFirst({
    where: (e, { and, eq, gt, isNull }) =>
      and(
        eq(e.userId, userId),
        eq(e.productId, feature),
        or(isNull(e.expiresAt), gt(e.expiresAt, now))
      ),
  })
  
  return !!entitlement
}

Granting Access

// On subscription.created webhook
await db.insert(entitlement).values({
  userId: user.id,
  stripeSubscriptionId: subscription.id,
  productId: subscription.items.data[0].price.product,
  expiresAt: null, // Until cancelled
  createdAt: new Date(),
})

Billing Portal

Let users manage subscriptions in Stripe's hosted portal:

export const createBillingSession = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    const session = await stripe.billingPortal.sessions.create({
      customer: context.user.stripeCustomerId,
      return_url: 'https://yourdomain.com/account/billing',
    })
    
    return { url: session.url }
  })

Testing

Use Stripe test cards:

CardResult
4242 4242 4242 4242Success
4000 0000 0000 0002Declined
4000 0000 0000 0010Requires auth
4000 0000 0000 9995Insufficient funds

Any future expiry and any CVC work.

Production Checklist

  • Switch to live API keys
  • Set production webhook signing secret
  • Test complete checkout flow
  • Verify emails are sent
  • Test subscription cancellation
  • Review pricing in Stripe Dashboard
  • Enable 3D Secure for security
  • Test failed payment handling
  • Document support process for disputes
  • Monitor Stripe Dashboard for failed payments

Webhook Verification

Always verify webhook signatures:

let event

try {
  event = stripe.webhooks.constructEvent(
    body,
    signature,
    STRIPE_WEBHOOK_SECRET
  )
} catch (error) {
  return { error: 'Invalid signature' }
}

// Process trusted event

Error Handling

try {
  const session = await stripe.checkout.sessions.create({...})
} catch (error) {
  if (error.type === 'StripeInvalidRequestError') {
    // Invalid parameters
  }
  if (error.type === 'StripeAuthenticationError') {
    // Invalid API key
  }
  if (error.type === 'StripeRateLimitError') {
    // Rate limit hit
  }
}

More Resources

On this page