Overview
Production-grade payment processing with Stripe Payment Intents, 3D Secure authentication, webhook verification, and automated receipt generation. Handles both one-time payments and checkout flows with full error handling.
Use Cases
- E-commerce checkouts: Accept payments for physical/digital products
- SaaS one-time fees: Setup fees, credits, add-ons
- Service bookings: Coaching sessions, consultations, workshops
- Digital downloads: Courses, templates, assets
When to Use This Pattern
Use this pattern when you need to:
- Accept one-time payments (not subscriptions - see stripe-subscriptions spec)
- Support multiple payment methods (cards, Apple Pay, Google Pay)
- Handle Strong Customer Authentication (SCA) / 3D Secure
- Generate receipts and confirmations automatically
- Track payment status in your database
Pro Tips
Before you start implementing, read these carefully:
- Use Payment Intents, not Charges API - Charges API is legacy, Payment Intents handle SCA automatically
- Never trust client-side payment status - Always verify via webhook before fulfilling orders
- Test with Stripe test cards - Use 4242424242424242 for success, 4000000000009995 for declined
- Set up webhook signature verification - Prevents spoofed webhook attacks
- Store Stripe customer ID - Enables better fraud detection and customer support
Implementation Phases
Phase 1: Stripe Setup
Configure Stripe account and API keys:
- Create Stripe account (or use existing)
- Get API keys from Dashboard ā Developers ā API keys
- Store in environment variables (never commit to git)
- Install Stripe SDK:
npm install stripe @stripe/stripe-js
Environment variables needed:
STRIPE_SECRET_KEY=sk_test_... # Server-side only
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... # Client-side safe
STRIPE_WEBHOOK_SECRET=whsec_... # From webhook endpoint setup
Phase 2: Database Schema
Track payments in your database:
model Payment {
id String @id @default(uuid())
userId String?
amount Int // Amount in cents
currency String @default("usd")
status String // pending, succeeded, failed, refunded
stripePaymentId String @unique
stripeCustomerId String?
description String?
receiptUrl String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id])
}
Why track in database:
- Audit trail for accounting
- Enable refund workflows
- Support customer service lookups
- Generate reports and analytics
Phase 3: Create Payment Intent
Server-side API route (app/api/create-payment-intent/route.ts):
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
})
export async function POST(request: Request) {
try {
const { amount, currency = 'usd', metadata = {} } = await request.json()
// Validate amount
if (!amount || amount < 50) { // $0.50 minimum
return NextResponse.json(
{ error: 'Invalid amount' },
{ status: 400 }
)
}
// Create Payment Intent
const paymentIntent = await stripe.paymentIntents.create({
amount,
currency,
automatic_payment_methods: {
enabled: true, // Enables cards, Apple Pay, Google Pay
},
metadata, // Store custom data (userId, productId, etc.)
})
// Save to database
await prisma.payment.create({
data: {
amount,
currency,
status: 'pending',
stripePaymentId: paymentIntent.id,
metadata,
},
})
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
})
} catch (error) {
console.error('Payment Intent creation failed:', error)
return NextResponse.json(
{ error: 'Payment failed' },
{ status: 500 }
)
}
}
Phase 4: Client-Side Payment Form
React component with Stripe Elements:
'use client'
import { loadStripe } from '@stripe/stripe-js'
import {
Elements,
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js'
import { useState } from 'react'
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
)
function CheckoutForm({ clientSecret }: { clientSecret: string }) {
const stripe = useStripe()
const elements = useElements()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!stripe || !elements) return
setLoading(true)
setError(null)
const { error: submitError } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment/success`,
},
})
if (submitError) {
setError(submitError.message || 'Payment failed')
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
{error && (
<div className="text-red-600 text-sm mt-2">{error}</div>
)}
<button
type="submit"
disabled={!stripe || loading}
className="mt-4 w-full bg-blue-600 text-white py-2 rounded"
>
{loading ? 'Processing...' : 'Pay Now'}
</button>
</form>
)
}
export default function PaymentPage({ amount }: { amount: number }) {
const [clientSecret, setClientSecret] = useState<string | null>(null)
// Create Payment Intent on mount
useEffect(() => {
fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
})
.then(res => res.json())
.then(data => setClientSecret(data.clientSecret))
}, [amount])
if (!clientSecret) return <div>Loading...</div>
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm clientSecret={clientSecret} />
</Elements>
)
}
Phase 5: Webhook Handler
Verify payments server-side (app/api/webhooks/stripe/route.ts):
import Stripe from 'stripe'
import { NextResponse } from 'next/server'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
})
export async function POST(request: Request) {
const body = await request.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
// Verify webhook signature
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed:', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// Handle payment success
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object as Stripe.PaymentIntent
// Update database
await prisma.payment.update({
where: { stripePaymentId: paymentIntent.id },
data: {
status: 'succeeded',
stripeCustomerId: paymentIntent.customer as string,
receiptUrl: paymentIntent.charges.data[0]?.receipt_url,
},
})
// Send receipt email
await sendReceiptEmail(paymentIntent)
// Fulfill order (grant access, send download link, etc.)
await fulfillOrder(paymentIntent)
}
// Handle payment failure
if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object as Stripe.PaymentIntent
await prisma.payment.update({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'failed' },
})
// Notify user of failure
await sendPaymentFailureEmail(paymentIntent)
}
return NextResponse.json({ received: true })
}
Edge Cases to Handle
Critical Edge Cases
Incomplete payments (user abandons 3D Secure):
- Payment Intent stuck in
requires_actionstatus - Set up webhook for
payment_intent.canceled - Clean up abandoned payments after 24 hours
- Consider reminder emails for incomplete checkouts
Network failures during confirmation:
- User clicks "Pay" but network drops before confirmation
- Payment may succeed on Stripe but user never sees success page
- Solution: Check payment status on return_url page
- Don't rely solely on client-side redirect
Duplicate payment attempts:
- User clicks "Pay" multiple times (button not disabled)
- Creates multiple Payment Intents for same order
- Solution: Use idempotency keys in Payment Intent creation
- Disable submit button after first click
Currency mismatch:
- User's card is USD but you charge in EUR
- Stripe handles conversion but adds fees
- Solution: Detect user location, offer local currency
- Show converted amount before payment
Refunds and disputes:
- Customer requests refund after successful payment
- Create refund endpoint:
stripe.refunds.create() - Update database status to 'refunded'
- Handle partial refunds (restocking fees, etc.)
Webhook delivery failures:
- Stripe sends webhook but your server is down
- Stripe retries for 3 days with exponential backoff
- Solution: Implement webhook event log table
- Manually replay failed events from Stripe dashboard
Test mode vs Live mode confusion:
- Accidentally using test API keys in production
- Solution: Environment-based key selection
- Add admin panel showing current Stripe mode
- Test webhook endpoints in both modes
Tech Stack Recommendations
Minimum Viable Stack
- Framework: Next.js with API routes
- Payment Provider: Stripe (easiest, most flexible)
- Database: Any (PostgreSQL, MySQL, Supabase)
- Email: Resend or SendGrid for receipts
Production-Grade Stack
- Framework: Next.js 15 with App Router
- Payment Processing: Stripe Payment Intents API
- Database: Prisma + PostgreSQL
- Email: Resend for transactional emails
- Queue: Inngest or Trigger.dev for async fulfillment
- Monitoring: Sentry for error tracking
Architecture Diagram
āāāāāāāāāāāāāāā
ā Browser ā
ā (Client) ā
āāāāāāāā¬āāāāāāā
ā
āāāā Step 1: Request Payment Intent
ā POST /api/create-payment-intent
ā { amount: 5000, currency: 'usd' }
ā
ā
āāāāāāāāāāāāāāāāāāāā
ā Your Server ā
ā (Next.js API) ā
āāāāāāāāāā¬āāāāāāāāāā
ā
āāāā Step 2: Create Payment Intent
ā stripe.paymentIntents.create()
ā
ā
āāāāāāāāāāāāāāāāāāāā
ā Stripe API ā
ā (Returns secret)ā
āāāāāāāāāā¬āāāāāāāāāā
ā
āāāā Step 3: Return client secret
ā
āāāāāāāāāāāāāāā
ā Browser ā
ā Stripe.js ā
āāāāāāāā¬āāāāāāā
ā
āāāā Step 4: User enters payment details
ā (Card number, expiry, CVC)
ā
āāāā Step 5: stripe.confirmPayment()
ā ā 3D Secure challenge if required
ā
ā
āāāāāāāāāāāāāāāāāāāā
ā Stripe ā
ā (Processes) ā
āāāāāāāāāā¬āāāāāāāāāā
ā
āāāā Step 6: Webhook Event
ā payment_intent.succeeded
ā POST /api/webhooks/stripe
ā
ā
āāāāāāāāāāāāāāāāāāāā
ā Your Server ā
ā (Fulfillment) ā
ā ā
ā - Update DB ā
ā - Send receipt ā
ā - Grant access ā
āāāāāāāāāāāāāāāāāāāā
Full Implementation Prompt
Copy this prompt to use with Claude Code:
I need to implement a production-grade payment processing flow with Stripe. Before we start coding, help me review my setup.
First, let me understand my current state:
- Do I have a Stripe account? If so, show me where to find my API keys.
- What does my User/Order data model look like? (Show me schema)
- How am I handling emails currently? (For receipts)
- What's my hosting setup? (For webhook URL configuration)
Then let's discuss these critical decisions:
- Should I create Payment Intent on page load or on form submit? (UX vs security trade-off)
- How should I handle failed payments? (Retry? Email user? How many attempts?)
- What metadata should I store in Payment Intent? (userId, productId, etc.)
- Should I use Stripe Checkout (hosted) or Payment Element (embedded)? (Faster vs customizable)
- How do I want to handle refunds? (Manual via Stripe dashboard or programmatic?)
Then we'll implement in phases: Phase 1: Stripe account setup + environment variables Phase 2: Database schema for tracking payments Phase 3: Create Payment Intent API endpoint Phase 4: Client-side payment form with Stripe Elements Phase 5: Webhook handler for payment confirmation Phase 6: Receipt email template and sending logic
After implementation, let's test:
- Successful payment (4242 4242 4242 4242)
- Declined payment (4000 0000 0000 9995)
- Requires 3D Secure (4000 0025 0000 3155)
- Webhook delivery and signature verification
- Refund workflow
Sound good? Let's start by reviewing your current setup.
Related Feature Specs
- Stripe Subscriptions - Recurring billing, plan management, prorations
- Payment Webhooks - Advanced webhook patterns, retry logic
- Invoice Generation - PDF invoices, tax compliance, receipts
- Shopping Cart - Guest checkout, cart migration, payment integration
Success Criteria
You've successfully implemented this when:
ā Users can complete payments with cards, Apple Pay, Google Pay ā 3D Secure authentication works automatically when required ā Payments are tracked in your database with correct status ā Webhook signature verification is working ā Receipt emails are sent automatically on success ā Failed payments are handled gracefully with clear error messages ā Refunds can be processed (manually or programmatically) ā All edge cases have error handling
Common Mistakes to Avoid
ā Using legacy Charges API instead of Payment Intents ā Not verifying webhook signatures (security risk) ā Fulfilling orders before webhook confirmation ā Storing API keys in client-side code ā Not handling 3D Secure redirects properly ā Missing error handling for declined cards ā Not testing with Stripe test cards ā Forgetting to set up webhook endpoint in Stripe dashboard
Implementation Checklist
Setup:
- Create Stripe account (or use existing)
- Get API keys (secret + publishable)
- Store keys in environment variables
- Install Stripe SDK (
npm install stripe @stripe/stripe-js) - Install Stripe React (
npm install @stripe/react-stripe-js)
Database:
- Create Payment model with required fields
- Add indexes on stripePaymentId, userId
- Test database connection
Backend:
- Create Payment Intent API endpoint
- Add validation (amount, currency)
- Store Payment Intent in database
- Handle errors and return proper status codes
Frontend:
- Load Stripe.js with publishable key
- Create checkout form component
- Add Stripe Elements (PaymentElement)
- Handle form submission with confirmPayment
- Add loading states and error handling
- Create success/failure pages
Webhooks:
- Create webhook endpoint
- Verify webhook signatures
- Handle payment_intent.succeeded event
- Handle payment_intent.payment_failed event
- Update database on webhook events
- Add webhook URL to Stripe dashboard
- Test webhook with Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Email:
- Create receipt email template
- Send email on payment success
- Include payment details, receipt URL
- Test email delivery
Testing:
- Test successful payment (4242 4242 4242 4242)
- Test declined payment (4000 0000 0000 9995)
- Test 3D Secure required (4000 0025 0000 3155)
- Test webhook delivery
- Test refund flow
- Test error handling (network failures, invalid amounts)
Deployment:
- Set production environment variables
- Switch to live Stripe API keys
- Update webhook endpoint in Stripe dashboard
- Monitor first production transactions
- Set up error tracking (Sentry)
Last Updated: 2025-12-04 Difficulty: Intermediate Estimated Time: 3-5 hours Prerequisites: Next.js API routes, database basics, async/await patterns
Built with this spec? Share your implementation and we'll feature it in our showcase!