| name | stripe-integration |
| description | Implements Stripe payments correctly the first time. Handles checkout sessions, webhooks, subscriptions, and customer management following Stripe best practices. |
Stripe Integration Skill
Implements Stripe payments following official best practices. Covers checkout, webhooks, subscriptions, and error handling.
When I Activate
- User mentions "Stripe", "payments", "checkout", "subscription", "billing"
- Building payment flows, upgrading plans, or handling purchases
- Setting up webhooks or handling payment events
Core Principles
- Never trust client-side data for amounts - Always compute prices server-side
- Use webhooks for fulfillment - Don't rely on redirect success alone
- Test with test keys first - Never use live keys in development
- Handle all webhook events - Especially
checkout.session.completed - Store Stripe customer IDs - Link users to their Stripe customers
Implementation Checklist
Environment Variables Required
STRIPE_SECRET_KEY=sk_test_... # Server-side only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # Client-side OK
STRIPE_WEBHOOK_SECRET=whsec_... # For webhook verification
Getting STRIPE_WEBHOOK_SECRET
- Go to https://dashboard.stripe.com/webhooks
- Click "Add endpoint"
- Enter your webhook URL:
https://yourdomain.com/api/webhooks/stripe - Select events:
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted - Click "Add endpoint"
- Click "Reveal" under "Signing secret" - this is your
STRIPE_WEBHOOK_SECRET
For local development:
# Install Stripe CLI: brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Copy the webhook signing secret it displays
Standard Implementation Pattern
1. Install Dependencies
npm install stripe @stripe/stripe-js
2. Create Stripe Client (Server)
// src/lib/stripe.ts
import Stripe from 'stripe'
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not set')
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-12-18.acacia',
typescript: true,
})
3. Create Checkout Session API Route
// src/app/api/checkout/route.ts
import { NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { stripe } from '@/lib/stripe'
import { getUserByClerkId } from '@/lib/supabase/queries'
const PRICES = {
starter: process.env.STRIPE_STARTER_PRICE_ID,
pro: process.env.STRIPE_PRO_PRICE_ID,
business: process.env.STRIPE_BUSINESS_PRICE_ID,
}
export async function POST(req: Request) {
try {
const { userId } = await auth()
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { priceId, planName } = await req.json()
// Validate price ID exists
if (!priceId || !Object.values(PRICES).includes(priceId)) {
return NextResponse.json({ error: 'Invalid price' }, { status: 400 })
}
// Get user from database
const { data: user } = await getUserByClerkId(userId)
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
// Create or retrieve Stripe customer
let stripeCustomerId = user.stripe_customer_id
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id, clerkId: userId },
})
stripeCustomerId = customer.id
// TODO: Save stripeCustomerId to user record
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?checkout=cancelled`,
metadata: {
userId: user.id,
planName: planName,
},
subscription_data: {
metadata: { userId: user.id, planName: planName },
},
})
return NextResponse.json({ url: session.url })
} catch (error) {
console.error('[CHECKOUT] Error:', error)
return NextResponse.json({ error: 'Checkout failed' }, { status: 500 })
}
}
4. Create Webhook Handler (CRITICAL)
// src/app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import Stripe from 'stripe'
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: Request) {
const body = await req.text()
const headersList = await headers()
const signature = headersList.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error('[STRIPE_WEBHOOK] Signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
await handleCheckoutCompleted(session)
break
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
await handleSubscriptionUpdated(subscription)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await handleSubscriptionDeleted(subscription)
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
await handlePaymentFailed(invoice)
break
}
}
return NextResponse.json({ received: true })
} catch (error) {
console.error('[STRIPE_WEBHOOK] Handler error:', error)
return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 })
}
}
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const userId = session.metadata?.userId
const planName = session.metadata?.planName
if (!userId || !planName) {
console.error('[STRIPE_WEBHOOK] Missing metadata:', session.id)
return
}
// Update user's plan and credits in database
// This is where you fulfill the purchase!
console.log(`[STRIPE_WEBHOOK] Upgrading user ${userId} to ${planName}`)
// TODO: Call your database update function
// await updateUserPlan(userId, planName)
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
const userId = subscription.metadata?.userId
// Handle plan changes, status changes
console.log(`[STRIPE_WEBHOOK] Subscription updated for user ${userId}`)
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
const userId = subscription.metadata?.userId
// Downgrade user to free plan
console.log(`[STRIPE_WEBHOOK] Subscription cancelled for user ${userId}`)
// TODO: await downgradeUserToFree(userId)
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
// Notify user of failed payment
console.log(`[STRIPE_WEBHOOK] Payment failed for invoice ${invoice.id}`)
}
5. Create Checkout Button Component
// src/components/CheckoutButton.tsx
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
interface CheckoutButtonProps {
priceId: string
planName: string
children: React.ReactNode
}
export function CheckoutButton({ priceId, planName, children }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false)
const handleCheckout = async () => {
setLoading(true)
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId, planName }),
})
const { url, error } = await response.json()
if (error) {
alert(error)
return
}
// Redirect to Stripe Checkout
window.location.href = url
} catch (error) {
console.error('Checkout error:', error)
alert('Failed to start checkout')
} finally {
setLoading(false)
}
}
return (
<Button onClick={handleCheckout} disabled={loading}>
{loading ? 'Loading...' : children}
</Button>
)
}
Stripe Dashboard Setup
Create Products and Prices
- Go to https://dashboard.stripe.com/products
- Click "Add product"
- For each plan (Starter, Pro, Business):
- Name: "RankEasy Starter" etc.
- Pricing: Recurring, Monthly
- Price: $29, $79, $199 etc.
- Copy the Price ID (starts with
price_) - Add to
.env.local:STRIPE_STARTER_PRICE_ID=price_... STRIPE_PRO_PRICE_ID=price_... STRIPE_BUSINESS_PRICE_ID=price_...
Testing Checklist
Test Cards
- Success:
4242 4242 4242 4242 - Declined:
4000 0000 0000 0002 - Requires auth:
4000 0025 0000 3155
Test Flow
- Click checkout button
- Should redirect to Stripe Checkout
- Use test card
4242 4242 4242 4242 - Any future expiry, any CVC
- Complete payment
- Should redirect back with
?checkout=success - Webhook should fire and update user's plan
Webhook Testing (Local)
# Terminal 1: Run your app
npm run dev
# Terminal 2: Forward webhooks
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Terminal 3: Trigger test event
stripe trigger checkout.session.completed
Common Errors
| Error | Cause | Fix |
|---|---|---|
| "No such price" | Invalid price ID | Check STRIPE_*_PRICE_ID env vars |
| "Invalid signature" | Wrong webhook secret | Update STRIPE_WEBHOOK_SECRET |
| Webhook not firing | Endpoint not registered | Add endpoint in Stripe Dashboard |
| Customer not found | No stripe_customer_id | Create customer first |
Security Best Practices
- Never expose STRIPE_SECRET_KEY to client - Only use in server components/API routes
- Always verify webhook signatures - Prevents spoofed events
- Use metadata for user linking - Store userId in checkout session metadata
- Validate prices server-side - Don't trust client-provided amounts
- Handle all webhook events - Don't just rely on success redirect