Features
Stripe
Payment processing with Stripe subscriptions and one-time payments.
Overview
BetterStarter integrates Stripe for:
- One-time purchases - Products with one-time checkout
- Subscriptions - Recurring billing
- Entitlements - Track what users have access to
- Webhooks - Handle payment events
Setup
1. Get Stripe Keys
- Go to stripe.com
- Create account and verify identity
- 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:
| Card | Result |
|---|---|
| 4242 4242 4242 4242 | Success |
| 4000 0000 0000 0002 | Declined |
| 4000 0000 0000 0010 | Requires auth |
| 4000 0000 0000 9995 | Insufficient 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 eventError 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
}
}