Docs

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/stripe

Required Events

Configure your Stripe webhook to listen for these events:

EventDescription
customer.subscription.createdNew subscription created
customer.subscription.updatedSubscription modified
customer.subscription.deletedSubscription canceled
invoice.createdNew invoice generated
invoice.paidInvoice payment successful
invoice.payment_failedInvoice payment failed
payment_intent.succeededPayment completed
payment_intent.payment_failedPayment failed
customer.updatedCustomer information changed
charge.refundedRefund 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:

FieldTypeDescription
idstringSubscription ID (sub_xxx)
customerstringCustomer ID (cus_xxx)
statusstringactive, canceled, incomplete, etc.
current_period_starttimestampStart of billing period
current_period_endtimestampEnd of billing period
items.data[0].price.idstringPrice/plan ID
cancel_at_period_endbooleanCancel 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:

FieldTypeDescription
idstringInvoice ID (in_xxx)
subscriptionstringSubscription ID
amount_dueintegerAmount due in cents
amount_paidintegerAmount paid in cents
currencystringThree-letter currency code
statusstringdraft, open, paid, uncollectible, void
invoice_pdfstringURL to PDF invoice
lines.dataarrayInvoice 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

StatusDescription
incompleteInitial state, awaiting payment
incomplete_expiredIncomplete subscription expired
trialingIn trial period
activeSubscription is active
past_duePayment failed but retrying
canceledSubscription canceled
unpaidPayment failed, no longer retrying
pausedSubscription is paused

Invoice Status Values

StatusDescription
draftInvoice being prepared
openInvoice awaiting payment
paidInvoice paid successfully
uncollectiblePayment deemed uncollectible
voidInvoice voided

Payment Failure Codes

CodeDescription
card_declinedCard was declined
insufficient_fundsInsufficient funds
expired_cardCard has expired
incorrect_cvcCVC check failed
processing_errorProcessing error
issuer_not_availableCard 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_failed

Testing 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:

AttemptDelay
1Immediate
21 minute
35 minutes
430 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

  1. Always verify signatures - Never trust unverified webhook requests
  2. Return 200 quickly - Process webhooks asynchronously
  3. Handle duplicates - Use idempotency keys
  4. Log everything - Maintain audit trail of all webhook events
  5. Monitor failures - Set up alerts for webhook delivery issues
  6. Use types - Leverage Stripe's TypeScript definitions
  7. Test thoroughly - Use Stripe CLI for local testing
  8. Version your endpoint - Handle API version changes gracefully

Troubleshooting

Common Issues

IssueSolution
Signature verification failsCheck webhook secret and raw body
Events not receivedVerify endpoint URL and event selection
Duplicate processingImplement idempotency checks
TimeoutsReturn 200 immediately, process async
Missing eventsCheck 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));
}

On this page