Complete guide to implementing Stripe subscriptions with checkout, webhooks, and billing management in ShipSafe.
Overview
ShipSafe includes a complete Stripe integration for subscription-based SaaS products. This tutorial covers:
- Creating checkout sessions - One-time subscription purchases
- Handling webhooks - Sync subscription status automatically
- Managing subscriptions - Access billing portal
- Subscription status - Check user subscription state
Architecture
Flow Overview
User clicks "Subscribe" → Checkout Session Created → Stripe Checkout →
Webhook Received → Firestore Updated → User Role Updated → Dashboard Access
Key Components:
- Checkout Button - Client component that initiates checkout
- API Route - Creates Stripe checkout session
- Webhook Handler - Processes Stripe events
- Firestore Sync - Stores subscription data
- User Role Update - Updates custom claims based on subscription
Setup
1. Configure Stripe
See Stripe Setup Guide for complete instructions.
Required:
- Stripe account created
- Products and prices created in Stripe Dashboard
- Webhook endpoint configured
- Environment variables set
2. Environment Variables
Add to .env.local:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
3. Create Products and Prices
In Stripe Dashboard:
- Go to Products → Create product
- Set product name and description
- Add pricing (monthly/annual)
- Copy Price ID (starts with
price_...)
Add Price IDs to config.ts:
stripe: {
plans: [
{
priceId: process.env.STRIPE_PRICE_STARTER || "",
name: "Starter",
price: 29,
features: [...],
},
{
priceId: process.env.STRIPE_PRICE_PRO || "",
name: "Pro",
price: 99,
features: [...],
},
],
},
Creating Checkout Sessions
Using ButtonCheckout Component
The easiest way - use the built-in component:
import ButtonCheckout from "@/components/ui/ButtonCheckout";
import config from "@/config";
// Get featured plan (most expensive)
const featuredPlan = config.stripe.plans.find((p) => p.isFeatured) || config.stripe.plans[0];
<ButtonCheckout priceId={featuredPlan.priceId} />
Location: src/components/ui/ButtonCheckout.tsx
Features:
- ✅ Automatically handles checkout flow
- ✅ Shows loading state
- ✅ Redirects to Stripe Checkout
- ✅ Includes logo in button
Custom Checkout Implementation
If you need custom checkout logic:
"use client";
import { useState } from "react";
import { apiPost } from "@/lib/api";
function CustomCheckoutButton({ priceId }: { priceId: string }) {
const [loading, setLoading] = useState(false);
const handleCheckout = async () => {
setLoading(true);
try {
const response = await apiPost<{ url: string }>("/api/checkout", {
priceId,
successUrl: window.location.href,
cancelUrl: window.location.href,
});
if (response.success && response.data?.url) {
// Redirect to Stripe Checkout
window.location.href = response.data.url;
}
} catch (error) {
console.error("Checkout failed:", error);
alert("Failed to start checkout. Please try again.");
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleCheckout}
disabled={loading}
className="btn btn-primary"
>
{loading ? "Loading..." : "Subscribe Now"}
</button>
);
}
API Route Pattern
The checkout API route creates a Stripe session:
// src/app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUserServer } from "@/lib/firebase/auth";
import { createCheckoutSessionForUser } from "@/features/billing/server";
import { checkoutSchema } from "@/features/billing/schema";
export async function POST(req: NextRequest) {
try {
// Optional: Get user (can be anonymous checkout)
const user = await getCurrentUserServer(req);
// Parse and validate request
const body = await req.json();
const data = checkoutSchema.parse(body);
// Create checkout session
const session = await createCheckoutSessionForUser(
user?.uid || undefined,
{
priceId: data.priceId,
successUrl: data.successUrl,
cancelUrl: data.cancelUrl,
}
);
return NextResponse.json({
success: true,
data: {
url: session.url,
sessionId: session.id,
},
});
} catch (error) {
// Error handling...
}
}
Webhook Handling
Webhook Events
ShipSafe handles these Stripe webhook events automatically:
checkout.session.completed- User completed checkoutcustomer.subscription.created- New subscription createdcustomer.subscription.updated- Subscription updated (plan change, etc.)customer.subscription.deleted- Subscription canceledinvoice.paid- Recurring payment succeededinvoice.payment_failed- Payment failed
Location: src/app/api/webhooks/stripe/route.ts
Webhook Processing
The webhook handler:
- Verifies signature - Ensures request is from Stripe
- Extracts Firebase UID - From subscription metadata
- Syncs to Firestore - Updates subscription document
- Updates user role - Sets custom claims based on status
// Automatic webhook handling - already implemented
// Located in: src/app/api/webhooks/stripe/route.ts
// Webhooks are automatically processed when configured correctly
Configuring Webhooks
-
In Stripe Dashboard:
- Go to Developers → Webhooks
- Click Add endpoint
- URL:
https://yourdomain.com/api/webhooks/stripe - Select events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
-
Get Webhook Secret:
- After creating endpoint, copy Signing secret (starts with
whsec_...) - Add to
.env.local:STRIPE_WEBHOOK_SECRET=whsec_...
- After creating endpoint, copy Signing secret (starts with
-
Test with Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Managing Subscriptions
Accessing Subscription Status
Server-Side (API Route)
import { getUserSubscriptionFromFirestore } from "@/features/billing/server";
export async function GET(req: NextRequest) {
const user = await requireAuth(req);
// Get user's subscription
const subscription = await getUserSubscriptionFromFirestore(user.uid);
if (!subscription) {
return NextResponse.json({
success: true,
data: { hasSubscription: false },
});
}
return NextResponse.json({
success: true,
data: {
hasSubscription: true,
subscription: {
status: subscription.status,
priceId: subscription.priceId,
currentPeriodEnd: subscription.currentPeriodEnd,
},
},
});
}
Client-Side (Component)
"use client";
import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
function SubscriptionStatus({ userId }: { userId: string }) {
const [subscription, setSubscription] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchSubscription() {
try {
const response = await apiGet("/api/subscription/status");
if (response.success && response.data) {
setSubscription(response.data.subscription);
}
} catch (error) {
console.error("Failed to fetch subscription:", error);
} finally {
setLoading(false);
}
}
fetchSubscription();
}, []);
if (loading) return <div>Loading...</div>;
if (!subscription) return <div>No active subscription</div>;
return (
<div>
<p>Status: {subscription.status}</p>
<p>Plan: {subscription.priceId}</p>
</div>
);
}
Billing Portal
Allow users to manage their subscription (update payment method, cancel, etc.):
Using API Route
"use client";
import { useState } from "react";
import { apiPost } from "@/lib/api";
function ManageBillingButton() {
const [loading, setLoading] = useState(false);
const handleManageBilling = async () => {
setLoading(true);
try {
const response = await apiPost<{ url: string }>("/api/billing/portal", {
returnUrl: window.location.href,
});
if (response.success && response.data?.url) {
// Redirect to Stripe billing portal
window.location.href = response.data.url;
}
} catch (error) {
console.error("Failed to open billing portal:", error);
alert("Failed to open billing portal. Please try again.");
} finally {
setLoading(false);
}
};
return (
<button
onClick={handleManageBilling}
disabled={loading}
className="btn btn-outline"
>
{loading ? "Loading..." : "Manage Billing"}
</button>
);
}
API Route: src/app/api/billing/portal/route.ts
The billing portal allows users to:
- ✅ Update payment method
- ✅ View billing history
- ✅ Cancel subscription
- ✅ Update billing information
Subscription Status Checks
Check if User Has Active Subscription
import { getUserSubscriptionFromFirestore } from "@/features/billing/server";
import { SubscriptionStatus } from "@/models/subscription";
export async function hasActiveSubscription(userId: string): Promise<boolean> {
const subscription = await getUserSubscriptionFromFirestore(userId);
if (!subscription) {
return false;
}
return subscription.status === SubscriptionStatus.ACTIVE;
}
Get Subscription Plan
import { getUserSubscriptionFromFirestore } from "@/features/billing/server";
import config from "@/config";
export async function getUserPlan(userId: string) {
const subscription = await getUserSubscriptionFromFirestore(userId);
if (!subscription) {
return null;
}
// Find plan in config by priceId
const plan = config.stripe.plans.find(
(p) => p.priceId === subscription.priceId
);
return plan || null;
}
Example: Complete Subscription Component
"use client";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
import Card from "@/components/ui/Card";
export default function SubscriptionManagement() {
const [subscription, setSubscription] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [openingPortal, setOpeningPortal] = useState(false);
useEffect(() => {
async function fetchSubscription() {
try {
const response = await apiGet("/api/subscription/status");
if (response.success && response.data) {
setSubscription(response.data.subscription);
}
} catch (error) {
console.error("Failed to fetch subscription:", error);
} finally {
setLoading(false);
}
}
fetchSubscription();
}, []);
const handleManageBilling = async () => {
setOpeningPortal(true);
try {
const response = await apiPost<{ url: string }>("/api/billing/portal", {
returnUrl: window.location.href,
});
if (response.success && response.data?.url) {
window.location.href = response.data.url;
}
} catch (error) {
console.error("Failed to open billing portal:", error);
alert("Failed to open billing portal. Please try again.");
} finally {
setOpeningPortal(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!subscription) {
return (
<Card>
<Card.Body>
<h2 className="text-2xl font-bold mb-4">No Active Subscription</h2>
<p className="text-base-content/70 mb-6">
Subscribe to a plan to get started.
</p>
<Button>View Pricing</Button>
</Card.Body>
</Card>
);
}
const getStatusVariant = (status: string) => {
switch (status) {
case "active":
return "success";
case "canceled":
case "past_due":
return "error";
default:
return "warning";
}
};
return (
<Card>
<Card.Body>
<div className="flex items-center justify-between mb-4">
<h2 className="text-2xl font-bold">Subscription</h2>
<Badge variant={getStatusVariant(subscription.status)}>
{subscription.status}
</Badge>
</div>
<div className="space-y-2 mb-6">
<div>
<span className="font-semibold">Plan:</span> {subscription.priceId}
</div>
<div>
<span className="font-semibold">Next billing:</span>{" "}
{new Date(subscription.currentPeriodEnd * 1000).toLocaleDateString()}
</div>
</div>
{subscription.cancelAtPeriodEnd && (
<div className="alert alert-warning mb-4">
Your subscription will cancel at the end of the current period.
</div>
)}
<Button
onClick={handleManageBilling}
loading={openingPortal}
fullWidth
>
Manage Billing
</Button>
</Card.Body>
</Card>
);
}
Testing
Test Mode
Use Stripe test mode for development:
-
Test Cards:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - 3D Secure:
4000 0027 6000 3184
- Success:
-
Test Webhooks:
stripe listen --forward-to localhost:3000/api/webhooks/stripe -
Trigger Test Events:
stripe trigger checkout.session.completed stripe trigger customer.subscription.created
Production Checklist
- ✅ Switch to live API keys
- ✅ Configure production webhook endpoint
- ✅ Test checkout flow end-to-end
- ✅ Verify webhook events are received
- ✅ Confirm Firestore sync works
- ✅ Test billing portal access
Best Practices
1. Always Store Firebase UID in Metadata
✅ Do:
metadata: {
firebase_uid: userId, // For webhook linking
}
2. Handle Webhook Events Idempotently
✅ Do:
- Check if subscription already exists before creating
- Use subscription ID as document ID in Firestore
- Handle duplicate events gracefully
3. Verify Webhook Signatures
✅ Do:
- Always verify Stripe webhook signatures
- Use webhook secret from environment variables
- Reject unsigned or invalid requests
4. Update User Roles Based on Subscription
✅ Do:
- Update custom claims when subscription changes
- Grant/revoke access based on subscription status
- Handle subscription cancellations gracefully
5. Provide Clear User Feedback
✅ Do:
- Show subscription status clearly
- Provide access to billing portal
- Handle payment failures gracefully
- Send email notifications for important events
Troubleshooting
Checkout Session Not Created
- ✅ Check Stripe API keys are correct
- ✅ Verify price ID exists in Stripe
- ✅ Check API route authentication
Webhook Events Not Received
- ✅ Verify webhook endpoint URL is correct
- ✅ Check webhook secret matches
- ✅ Ensure webhook events are selected in Stripe Dashboard
- ✅ Test with Stripe CLI locally
Subscription Status Not Updating
- ✅ Check webhook handler logs
- ✅ Verify Firestore write permissions
- ✅ Check subscription metadata contains Firebase UID
- ✅ Verify webhook events are being processed
Learn More
- Stripe Setup Guide - Complete setup instructions
- Billing Features - Billing implementation details
- Webhook Handling - Webhook patterns
- Stripe Documentation - Official Stripe docs