Stripe webhook integration for handling subscription lifecycle events and syncing data to Firestore.
Overview
ShipSafe uses Stripe webhooks to keep subscription data synchronized between Stripe and Firestore. When events occur in Stripe (subscription created, payment failed, etc.), Stripe sends webhook events to your server, which then updates Firestore and user custom claims accordingly.
Key Features:
- Signature verification - All webhooks are verified using Stripe signatures
- Event routing - Automatic routing to appropriate event handlers
- Firestore sync - Subscription data automatically synced to Firestore
- User role updates - Custom claims updated based on subscription status
- Idempotent processing - Handles duplicate events safely
Architecture
Webhook Flow
Stripe Event → Webhook → Signature Verification → Event Handler → Firestore Update
- Stripe event occurs (e.g., subscription created)
- Stripe sends webhook to
/api/webhooks/stripe - Signature verified - Ensures request is from Stripe
- Event routed - Based on event type to appropriate handler
- Firestore updated - Subscription data synced
- User claims updated - Role/permissions updated
Setup
1. Configure Webhook Endpoint in Stripe
-
Click "Add endpoint"
-
Enter your webhook URL:
https://yourdomain.com/api/webhooks/stripe -
Select events to listen for:
For Subscriptions (Required):
checkout.session.completed- Customer completed checkout (subscription or one-time)customer.subscription.created- New subscription createdcustomer.subscription.updated- Subscription plan changed, status updatedcustomer.subscription.deleted- Subscription canceledinvoice.payment_succeeded- Subscription payment succeededinvoice.payment_failed- Subscription payment failed
For One-Time Payments (Optional):
checkout.session.completed- Also handles one-time payments (checksession.mode)payment_intent.succeeded- One-time payment succeeded (optional)payment_intent.payment_failed- One-time payment failed (optional)
Note:
checkout.session.completedhandles both subscription and one-time payments. The handler checkssession.modeto determine the payment type. -
Copy the webhook signing secret
2. Add Webhook Secret to Environment
Add to .env.local:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
Important: Different secrets for development and production:
- Development: Use Stripe CLI secret (
stripe listen) - Production: Use webhook endpoint secret from Stripe Dashboard
3. Local Testing with Stripe CLI
Install Stripe CLI and forward webhooks:
# Install Stripe CLI (if not installed)
# macOS: brew install stripe/stripe-cli/stripe
# Login to Stripe
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This will output a webhook secret (starts with whsec_). Add it to .env.local:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
Webhook Events
Required Events by Payment Type
For Subscriptions (Recurring Billing): ShipSafe requires these events for full subscription lifecycle management:
| Event | Handler | Action |
|---|---|---|
checkout.session.completed | handleCheckoutSessionCompleted | Links Stripe customer to Firebase user (works for both subscription and one-time) |
customer.subscription.created | handleSubscriptionCreated | Creates subscription in Firestore, updates user role |
customer.subscription.updated | handleSubscriptionUpdated | Updates subscription in Firestore, updates user role |
customer.subscription.deleted | handleSubscriptionDeleted | Marks subscription as canceled, removes premium role |
invoice.payment_succeeded | handleInvoicePaymentSucceeded | Logs payment success (subscription already synced) |
invoice.payment_failed | handleInvoicePaymentFailed | Logs payment failure (status updated by subscription.updated) |
For One-Time Payments:
Only checkout.session.completed is required. The handler checks session.mode to determine if it's a subscription or one-time payment.
Important: If you're using subscriptions, you must configure all subscription events in Stripe Dashboard. Missing events will cause subscription data to be out of sync.
Event Flow
Subscription Flow:
- User completes checkout →
checkout.session.completed - Stripe creates subscription →
customer.subscription.created - Payment processed →
invoice.payment_succeeded - Subscription updates →
customer.subscription.updated - User cancels →
customer.subscription.deleted
One-Time Payment Flow:
- User completes checkout →
checkout.session.completed(handler checkssession.mode === "payment")
Implementation
Webhook Endpoint
Location: src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { parseWebhookEvent } from "@/lib/stripe/webhook";
import { handleWebhookEvent } from "@/features/billing/webhook";
export async function POST(req: NextRequest) {
try {
// Parse and verify webhook event (signature verification)
const event = await parseWebhookEvent(req);
// Handle webhook event
const result = await handleWebhookEvent(event);
if (result.success) {
return NextResponse.json(
{ success: true, message: result.message },
{ status: 200 }
);
} else {
// Log error but return 200 (prevents Stripe retries)
console.error("Webhook handling failed:", result.message);
return NextResponse.json(
{ success: false, message: result.message },
{ status: 200 }
);
}
} catch (error) {
// Signature verification failed - return 400
if (error instanceof Error && error.message.includes("verification")) {
return NextResponse.json(
{ error: "Webhook verification failed" },
{ status: 400 }
);
}
// Other errors - return 200 (prevents Stripe retries)
return NextResponse.json(
{ error: "Webhook processing failed" },
{ status: 200 }
);
}
}
Signature Verification
Location: src/lib/stripe/webhook.ts
The webhook handler automatically:
- Extracts raw body - Required for signature verification
- Gets signature - From
stripe-signatureheader - Verifies signature - Using Stripe SDK and webhook secret
- Returns event - Parsed and type-safe event object
Critical: Raw body is required. Next.js App Router handles this automatically.
Event Handlers
Location: src/features/billing/webhook.ts
Each event type has a dedicated handler:
// Example: Subscription created handler
export async function handleSubscriptionCreated(
event: ParsedWebhookEvent
): Promise<WebhookHandlerResult> {
const subscription = event.data.object as Stripe.Subscription;
// Extract Firebase UID from metadata
const firebaseUid = subscription.metadata?.firebase_uid;
// Create subscription in Firestore
await updateSubscriptionInFirestore(subscriptionData);
// Update user role (premium access)
await updateUserRoleFromSubscription(firebaseUid, status);
return { success: true, message: "Subscription synced" };
}
Customization
Add New Event Handler
- Add event type to
src/lib/stripe/webhook.ts:
export type StripeWebhookEventType =
| "checkout.session.completed"
| "customer.subscription.created"
// ... existing types
| "customer.updated"; // Add new event type
- Create handler function in
src/features/billing/webhook.ts:
export async function handleCustomerUpdated(
event: ParsedWebhookEvent
): Promise<WebhookHandlerResult> {
const customer = event.data.object as Stripe.Customer;
// Your custom logic here
// e.g., update customer email in Firestore
return { success: true, message: "Customer updated" };
}
- Add to router in
handleWebhookEvent:
switch (event.type) {
// ... existing cases
case "customer.updated":
return await handleCustomerUpdated(event);
}
Customize Subscription Sync
Edit handlers in src/features/billing/webhook.ts to add custom logic:
export async function handleSubscriptionCreated(
event: ParsedWebhookEvent
): Promise<WebhookHandlerResult> {
// ... existing code
// Add custom logic
await sendWelcomeEmail(firebaseUid);
await createUserWorkspace(firebaseUid);
return { success: true, message: "Subscription created" };
}
Security
Signature Verification
Critical: Always verify webhook signatures. The parseWebhookEvent function does this automatically:
// This verifies signature automatically
const event = await parseWebhookEvent(req);
Why it's important:
- Prevents attackers from sending fake webhook events
- Ensures requests are from Stripe
- Protects against replay attacks
Raw Body Requirement
Stripe signature verification requires the raw request body (as bytes), not parsed JSON. Next.js App Router provides this automatically, but ensure your route config allows raw body:
// Next.js automatically handles raw body for POST requests
export async function POST(req: NextRequest) {
// parseWebhookEvent handles raw body extraction
const event = await parseWebhookEvent(req);
}
Error Handling
Webhook handlers should:
- Return 200 for processing errors (prevents Stripe retries)
- Return 400 for signature verification failures
- Log errors for debugging
- Be idempotent (handle duplicate events safely)
Testing
Local Testing with Stripe CLI
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger test event
stripe trigger checkout.session.completed
Testing Individual Events
# Test subscription created
stripe trigger customer.subscription.created
# Test payment failed
stripe trigger invoice.payment_failed
# Test subscription updated
stripe trigger customer.subscription.updated
Viewing Webhook Logs
- Stripe Dashboard - View webhook delivery logs
- Server logs - Check console for webhook processing logs
- Stripe CLI - See events in real-time:
stripe listen
Troubleshooting
Webhook Not Received
- Check webhook endpoint - Verify URL is correct in Stripe Dashboard
- Check server logs - Look for webhook delivery attempts
- Test locally - Use Stripe CLI to test webhook delivery
- Verify secret - Ensure
STRIPE_WEBHOOK_SECRETis correct
Signature Verification Failed
- Check webhook secret - Must match the secret from Stripe
- Verify raw body - Next.js should handle this automatically
- Check headers - Ensure
stripe-signatureheader is present - Test mode - Use test mode secret, not live mode secret
Event Not Processed
- Check event type - Verify event type is in the switch statement
- Check handler logic - Look for errors in handler function
- Check Firestore - Verify subscription was created/updated
- Check user claims - Verify user role was updated
Best Practices
- Always verify signatures - Never process webhooks without verification
- Idempotent handlers - Handle duplicate events gracefully
- Error logging - Log all errors for debugging
- Return 200 for errors - Prevents Stripe from retrying (unless you want retries)
- Test thoroughly - Use Stripe CLI to test all event types
- Monitor webhook logs - Check Stripe Dashboard for delivery issues
Learn More
- Stripe Setup - Complete Stripe configuration
- Billing Features - Billing and subscription management
- Database Features - Firestore integration
- Webhook Events Documentation