Webhooks
Stripe webhook events and handling guide
Webhooks
Webhooks allow your application to receive real-time notifications when events occur in Stripe. This guide covers all available webhook events and how to handle them securely.
Overview
The application uses Stripe webhooks to handle:
- Subscription lifecycle events
- Payment success and failure
- Invoice generation and payment
- Customer updates
- Refund processing
Webhook Endpoint Configuration
Endpoint URL
Production: https://yourapp.com/api/webhooks/stripe
Staging: https://staging.yourapp.com/api/webhooks/stripe
Local: https://yourapp.ngrok.io/api/webhooks/stripeRequired Events
Configure your Stripe webhook to listen for these events:
| Event | Description |
|---|---|
customer.subscription.created | New subscription created |
customer.subscription.updated | Subscription modified |
customer.subscription.deleted | Subscription canceled |
invoice.created | New invoice generated |
invoice.paid | Invoice payment successful |
invoice.payment_failed | Invoice payment failed |
payment_intent.succeeded | Payment completed |
payment_intent.payment_failed | Payment failed |
customer.updated | Customer information changed |
charge.refunded | Refund processed |
Webhook Security
Signature Verification
Always verify webhook signatures to ensure requests come from Stripe:
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
// Process the event
await handleWebhookEvent(event);
return new Response('OK', { status: 200 });
}Idempotency
Webhooks may be delivered multiple times. Use idempotency keys to prevent duplicate processing:
import { db } from '@repo/database';
import { webhookEvent } from '@repo/database/schema';
async function handleWebhookEvent(event: Stripe.Event) {
// Check if already processed
const existing = await db.query.webhookEvent.findFirst({
where: (fields, { eq }) => eq(fields.stripeEventId, event.id),
});
if (existing) {
console.log(`Event ${event.id} already processed`);
return;
}
// Record event
await db.insert(webhookEvent).values({
stripeEventId: event.id,
type: event.type,
data: event.data.object,
createdAt: new Date(event.created * 1000),
});
// Process event
await processEvent(event);
}Event Types
Subscription Events
customer.subscription.created
Triggered when a new subscription is created.
case 'customer.subscription.created': {
const subscription = event.data.object as Stripe.Subscription;
await db.insert(subscriptions).values({
stripeSubscriptionId: subscription.id,
stripeCustomerId: subscription.customer as string,
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
planId: subscription.items.data[0].price.id,
quantity: subscription.items.data[0].quantity,
});
break;
}Data structure:
| Field | Type | Description |
|---|---|---|
id | string | Subscription ID (sub_xxx) |
customer | string | Customer ID (cus_xxx) |
status | string | active, canceled, incomplete, etc. |
current_period_start | timestamp | Start of billing period |
current_period_end | timestamp | End of billing period |
items.data[0].price.id | string | Price/plan ID |
cancel_at_period_end | boolean | Cancel at end of period |
customer.subscription.updated
Triggered when a subscription is modified.
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
const previousAttributes = event.data.previous_attributes;
await db.update(subscriptions)
.set({
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
// Log changes
if (previousAttributes?.status) {
await logSubscriptionChange({
subscriptionId: subscription.id,
field: 'status',
oldValue: previousAttributes.status,
newValue: subscription.status,
});
}
break;
}customer.subscription.deleted
Triggered when a subscription is canceled and ended.
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db.update(subscriptions)
.set({
status: 'canceled',
canceledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
// Notify user
await sendCancellationEmail(subscription.customer as string);
break;
}Invoice Events
invoice.created
Triggered when a new invoice is generated.
case 'invoice.created': {
const invoice = event.data.object as Stripe.Invoice;
await db.insert(invoices).values({
stripeInvoiceId: invoice.id,
stripeSubscriptionId: invoice.subscription as string,
stripeCustomerId: invoice.customer as string,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
status: invoice.status,
invoicePdf: invoice.invoice_pdf,
createdAt: new Date(invoice.created * 1000),
});
break;
}Data structure:
| Field | Type | Description |
|---|---|---|
id | string | Invoice ID (in_xxx) |
subscription | string | Subscription ID |
amount_due | integer | Amount due in cents |
amount_paid | integer | Amount paid in cents |
currency | string | Three-letter currency code |
status | string | draft, open, paid, uncollectible, void |
invoice_pdf | string | URL to PDF invoice |
lines.data | array | Invoice line items |
invoice.paid
Triggered when an invoice is successfully paid.
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice;
await db.transaction(async (tx) => {
// Update invoice
await tx.update(invoices)
.set({
status: 'paid',
amountPaid: invoice.amount_paid,
paidAt: new Date(),
})
.where(eq(invoices.stripeInvoiceId, invoice.id));
// Update subscription period
if (invoice.subscription) {
await tx.update(subscriptions)
.set({
currentPeriodStart: new Date(invoice.period_start * 1000),
currentPeriodEnd: new Date(invoice.period_end * 1000),
})
.where(eq(subscriptions.stripeSubscriptionId, invoice.subscription as string));
}
});
// Send receipt
await sendReceiptEmail(invoice.customer as string, invoice);
break;
}invoice.payment_failed
Triggered when an invoice payment fails.
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const paymentIntent = event.data.object.payment_intent as Stripe.PaymentIntent;
await db.update(invoices)
.set({
status: 'payment_failed',
paymentFailureMessage: paymentIntent?.last_payment_error?.message,
})
.where(eq(invoices.stripeInvoiceId, invoice.id));
// Send payment failure notification
await sendPaymentFailedEmail(invoice.customer as string, {
invoiceId: invoice.id,
amount: invoice.amount_due,
retryUrl: `${process.env.APP_URL}/billing/payment?invoice=${invoice.id}`,
});
break;
}Payment Intent Events
payment_intent.succeeded
Triggered when a payment is completed.
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
await db.insert(payments).values({
stripePaymentIntentId: paymentIntent.id,
stripeCustomerId: paymentIntent.customer as string,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
status: 'succeeded',
paymentMethod: paymentIntent.payment_method_types[0],
createdAt: new Date(paymentIntent.created * 1000),
});
break;
}payment_intent.payment_failed
Triggered when a payment fails.
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const error = paymentIntent.last_payment_error;
await db.insert(paymentFailures).values({
stripePaymentIntentId: paymentIntent.id,
stripeCustomerId: paymentIntent.customer as string,
amount: paymentIntent.amount,
errorCode: error?.code,
errorMessage: error?.message,
declineCode: error?.decline_code,
createdAt: new Date(),
});
break;
}Customer Events
customer.updated
Triggered when customer information changes.
case 'customer.updated': {
const customer = event.data.object as Stripe.Customer;
await db.update(users)
.set({
email: customer.email,
name: customer.name,
billingAddress: customer.address,
})
.where(eq(users.stripeCustomerId, customer.id));
break;
}Refund Events
charge.refunded
Triggered when a refund is processed.
case 'charge.refunded': {
const charge = event.data.object as Stripe.Charge;
for (const refund of charge.refunds?.data || []) {
await db.insert(refunds).values({
stripeRefundId: refund.id,
stripeChargeId: charge.id,
stripePaymentIntentId: charge.payment_intent as string,
amount: refund.amount,
reason: refund.reason,
status: refund.status,
createdAt: new Date(refund.created * 1000),
});
}
// Send refund confirmation
await sendRefundEmail(charge.customer as string, {
amount: charge.amount_refunded,
currency: charge.currency,
});
break;
}Webhook Event Reference
Subscription Status Values
| Status | Description |
|---|---|
incomplete | Initial state, awaiting payment |
incomplete_expired | Incomplete subscription expired |
trialing | In trial period |
active | Subscription is active |
past_due | Payment failed but retrying |
canceled | Subscription canceled |
unpaid | Payment failed, no longer retrying |
paused | Subscription is paused |
Invoice Status Values
| Status | Description |
|---|---|
draft | Invoice being prepared |
open | Invoice awaiting payment |
paid | Invoice paid successfully |
uncollectible | Payment deemed uncollectible |
void | Invoice voided |
Payment Failure Codes
| Code | Description |
|---|---|
card_declined | Card was declined |
insufficient_funds | Insufficient funds |
expired_card | Card has expired |
incorrect_cvc | CVC check failed |
processing_error | Processing error |
issuer_not_available | Card issuer unavailable |
Testing Webhooks
Using Stripe CLI
# Forward webhooks to local development
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger test events
stripe trigger customer.subscription.created
stripe trigger invoice.paid
stripe trigger invoice.payment_failedTesting Specific Events
# Test subscription creation
stripe trigger customer.subscription.created \
--add "subscription:items[0][price]=price_123" \
--add "subscription:customer=cus_456"
# Test payment failure
stripe trigger invoice.payment_failed \
--add "invoice:customer=cus_456"Error Handling
Retry Logic
Stripe retries failed webhooks with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5+ | 1 hour |
Dead Letter Queue
Implement a dead letter queue for failed events:
async function handleWebhookError(event: Stripe.Event, error: Error) {
await db.insert(webhookDeadLetter).values({
stripeEventId: event.id,
eventType: event.type,
eventData: event.data.object,
errorMessage: error.message,
errorStack: error.stack,
retryCount: event.request?.idempotency_key ? 1 : 0,
createdAt: new Date(),
});
// Alert on-call if critical
if (isCriticalEvent(event.type)) {
await pagerDuty.alert({
title: `Webhook processing failed: ${event.type}`,
description: error.message,
eventId: event.id,
});
}
}Best Practices
- Always verify signatures - Never trust unverified webhook requests
- Return 200 quickly - Process webhooks asynchronously
- Handle duplicates - Use idempotency keys
- Log everything - Maintain audit trail of all webhook events
- Monitor failures - Set up alerts for webhook delivery issues
- Use types - Leverage Stripe's TypeScript definitions
- Test thoroughly - Use Stripe CLI for local testing
- Version your endpoint - Handle API version changes gracefully
Troubleshooting
Common Issues
| Issue | Solution |
|---|---|
| Signature verification fails | Check webhook secret and raw body |
| Events not received | Verify endpoint URL and event selection |
| Duplicate processing | Implement idempotency checks |
| Timeouts | Return 200 immediately, process async |
| Missing events | Check Stripe dashboard event logs |
Debugging Tips
// Add detailed logging
console.log('Webhook received:', {
id: event.id,
type: event.type,
created: new Date(event.created * 1000),
requestId: event.request?.id,
});
// Log full event data in development
if (process.env.NODE_ENV === 'development') {
console.log('Event data:', JSON.stringify(event.data.object, null, 2));
}