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_123Webhook 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#
- Always verify signatures - Never process unverified webhooks
- Handle idempotently - Events may be sent multiple times
- Return 200 quickly - Process asynchronously if needed
- Log all events - Maintain audit trail for debugging
- Monitor failures - Set up alerts for failed webhook processing
Related Patterns#
- Stripe - Stripe setup
- Subscriptions - Subscription events
- Checkout - Checkout completion events