Payment Webhook Patterns

Handle payment events reliably with Stripe webhooks.

Overview#

Webhooks are essential for reliable payment processing. This pattern covers:

  • Webhook signature verification
  • Event handling
  • Idempotency
  • Error handling and retries
  • Testing webhooks locally

Basic Webhook Handler#

Verify and process Stripe webhook events.

1// app/api/webhooks/stripe/route.ts 2import { stripe } from '@/lib/stripe' 3import { headers } from 'next/headers' 4import Stripe from 'stripe' 5 6const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET! 7 8export async function POST(request: Request) { 9 const body = await request.text() 10 const signature = headers().get('stripe-signature')! 11 12 let event: Stripe.Event 13 14 try { 15 event = stripe.webhooks.constructEvent(body, signature, webhookSecret) 16 } catch (err) { 17 console.error('Webhook signature verification failed:', err) 18 return Response.json({ error: 'Invalid signature' }, { status: 400 }) 19 } 20 21 try { 22 await handleEvent(event) 23 return Response.json({ received: true }) 24 } catch (err) { 25 console.error('Webhook handler error:', err) 26 return Response.json({ error: 'Handler failed' }, { status: 500 }) 27 } 28} 29 30async function handleEvent(event: Stripe.Event) { 31 switch (event.type) { 32 case 'checkout.session.completed': 33 await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session) 34 break 35 36 case 'customer.subscription.created': 37 case 'customer.subscription.updated': 38 await handleSubscriptionUpdate(event.data.object as Stripe.Subscription) 39 break 40 41 case 'customer.subscription.deleted': 42 await handleSubscriptionDeleted(event.data.object as Stripe.Subscription) 43 break 44 45 case 'invoice.paid': 46 await handleInvoicePaid(event.data.object as Stripe.Invoice) 47 break 48 49 case 'invoice.payment_failed': 50 await handlePaymentFailed(event.data.object as Stripe.Invoice) 51 break 52 53 default: 54 console.log(`Unhandled event type: ${event.type}`) 55 } 56}

Idempotent Processing#

Prevent duplicate processing of the same event.

1// lib/webhooks.ts 2import { prisma } from '@/lib/db' 3 4export async function processWebhookOnce( 5 eventId: string, 6 handler: () => Promise<void> 7) { 8 // Check if already processed 9 const existing = await prisma.webhookEvent.findUnique({ 10 where: { eventId } 11 }) 12 13 if (existing?.processedAt) { 14 console.log(`Event ${eventId} already processed, skipping`) 15 return { skipped: true } 16 } 17 18 // Create or update event record 19 await prisma.webhookEvent.upsert({ 20 where: { eventId }, 21 create: { eventId, receivedAt: new Date() }, 22 update: {} 23 }) 24 25 try { 26 await handler() 27 28 // Mark as processed 29 await prisma.webhookEvent.update({ 30 where: { eventId }, 31 data: { processedAt: new Date() } 32 }) 33 34 return { success: true } 35 } catch (error) { 36 // Record failure 37 await prisma.webhookEvent.update({ 38 where: { eventId }, 39 data: { 40 error: error instanceof Error ? error.message : 'Unknown error', 41 failedAt: new Date() 42 } 43 }) 44 45 throw error 46 } 47} 48 49// Usage in webhook handler 50async function handleEvent(event: Stripe.Event) { 51 await processWebhookOnce(event.id, async () => { 52 switch (event.type) { 53 case 'checkout.session.completed': 54 await handleCheckoutComplete(event.data.object) 55 break 56 // ... other handlers 57 } 58 }) 59}

Event Handlers#

Handle specific webhook events.

1// lib/webhook-handlers.ts 2import { prisma } from '@/lib/db' 3import { sendEmail } from '@/lib/email' 4import Stripe from 'stripe' 5 6export async function handleCheckoutComplete(session: Stripe.Checkout.Session) { 7 const userId = session.metadata?.userId 8 if (!userId) return 9 10 if (session.mode === 'subscription') { 11 // Subscription handled by subscription webhooks 12 return 13 } 14 15 // One-time payment 16 if (session.mode === 'payment') { 17 const lineItems = await stripe.checkout.sessions.listLineItems(session.id) 18 19 // Create order record 20 await prisma.order.create({ 21 data: { 22 userId, 23 stripeSessionId: session.id, 24 amount: session.amount_total!, 25 currency: session.currency!, 26 status: 'completed', 27 items: { 28 create: lineItems.data.map(item => ({ 29 name: item.description!, 30 quantity: item.quantity!, 31 price: item.price!.unit_amount! 32 })) 33 } 34 } 35 }) 36 37 // Send confirmation email 38 const user = await prisma.user.findUnique({ where: { id: userId } }) 39 if (user?.email) { 40 await sendEmail({ 41 to: user.email, 42 subject: 'Order Confirmation', 43 template: 'order-confirmation', 44 data: { sessionId: session.id } 45 }) 46 } 47 } 48} 49 50export async function handleSubscriptionUpdate(subscription: Stripe.Subscription) { 51 const customerId = subscription.customer as string 52 53 const user = await prisma.user.findFirst({ 54 where: { stripeCustomerId: customerId } 55 }) 56 57 if (!user) { 58 console.error('User not found for customer:', customerId) 59 return 60 } 61 62 await prisma.subscription.upsert({ 63 where: { userId: user.id }, 64 create: { 65 userId: user.id, 66 stripeSubscriptionId: subscription.id, 67 stripePriceId: subscription.items.data[0].price.id, 68 status: subscription.status, 69 currentPeriodStart: new Date(subscription.current_period_start * 1000), 70 currentPeriodEnd: new Date(subscription.current_period_end * 1000), 71 cancelAtPeriodEnd: subscription.cancel_at_period_end 72 }, 73 update: { 74 stripePriceId: subscription.items.data[0].price.id, 75 status: subscription.status, 76 currentPeriodStart: new Date(subscription.current_period_start * 1000), 77 currentPeriodEnd: new Date(subscription.current_period_end * 1000), 78 cancelAtPeriodEnd: subscription.cancel_at_period_end 79 } 80 }) 81} 82 83export async function handlePaymentFailed(invoice: Stripe.Invoice) { 84 const customerId = invoice.customer as string 85 86 const user = await prisma.user.findFirst({ 87 where: { stripeCustomerId: customerId } 88 }) 89 90 if (!user) return 91 92 // Record failed payment 93 await prisma.paymentAttempt.create({ 94 data: { 95 userId: user.id, 96 stripeInvoiceId: invoice.id, 97 amount: invoice.amount_due, 98 status: 'failed', 99 failedAt: new Date() 100 } 101 }) 102 103 // Send notification email 104 await sendEmail({ 105 to: user.email, 106 subject: 'Payment Failed', 107 template: 'payment-failed', 108 data: { 109 amount: invoice.amount_due, 110 currency: invoice.currency, 111 updateUrl: `${process.env.NEXT_PUBLIC_URL}/settings/billing` 112 } 113 }) 114}

Webhook Event Schema#

Prisma schema for tracking webhook events.

1// prisma/schema.prisma 2model WebhookEvent { 3 id String @id @default(cuid()) 4 eventId String @unique 5 receivedAt DateTime 6 processedAt DateTime? 7 error String? 8 failedAt DateTime? 9 createdAt DateTime @default(now()) 10} 11 12model PaymentAttempt { 13 id String @id @default(cuid()) 14 userId String 15 stripeInvoiceId String 16 amount Int 17 status String 18 failedAt DateTime? 19 createdAt DateTime @default(now()) 20 21 user User @relation(fields: [userId], references: [id]) 22}

Testing Webhooks Locally#

Use Stripe CLI to test webhooks during development.

1# Install Stripe CLI 2brew install stripe/stripe-cli/stripe 3 4# Login to Stripe 5stripe login 6 7# Forward webhooks to local server 8stripe listen --forward-to localhost:3000/api/webhooks/stripe 9 10# In another terminal, trigger test events 11stripe trigger checkout.session.completed 12stripe trigger customer.subscription.updated 13stripe trigger invoice.payment_failed 14 15# Trigger with specific data 16stripe trigger checkout.session.completed \ 17 --override checkout_session:metadata.userId=user_123

Webhook Configuration#

Configure webhooks in Stripe Dashboard or via API.

1// scripts/setup-webhooks.ts 2import { stripe } from '@/lib/stripe' 3 4async function setupWebhooks() { 5 const webhookEndpoint = await stripe.webhookEndpoints.create({ 6 url: 'https://your-domain.com/api/webhooks/stripe', 7 enabled_events: [ 8 'checkout.session.completed', 9 'customer.subscription.created', 10 'customer.subscription.updated', 11 'customer.subscription.deleted', 12 'invoice.paid', 13 'invoice.payment_failed', 14 'customer.updated' 15 ] 16 }) 17 18 console.log('Webhook endpoint created:', webhookEndpoint.id) 19 console.log('Webhook secret:', webhookEndpoint.secret) 20} 21 22setupWebhooks()

Best Practices#

  1. Always verify signatures - Never process unverified webhooks
  2. Handle idempotently - Events may be sent multiple times
  3. Return 200 quickly - Process asynchronously if needed
  4. Log all events - Maintain audit trail for debugging
  5. Monitor failures - Set up alerts for failed webhook processing