Comprehensive Stripe integration for one-time payments and recurring subscriptions.
Overview
ShipSafe includes full Stripe integration for handling payments and subscriptions. The system supports both one-time payments and recurring subscriptions with automatic webhook handling, billing portal access, and subscription management.
Features:
- Subscriptions - Recurring billing (monthly, yearly) with automatic webhook handling
- One-Time Payments - Single purchases (products, one-off charges)
- Stripe Checkout integration (both modes)
- Customer billing portal (subscriptions only)
- Webhook event handling (subscription lifecycle events)
- Subscription status sync with Firestore
Payment Modes:
- Subscriptions (
mode: "subscription") - UsecreateCheckoutSession()- Requires webhook events for subscription lifecycle - One-Time Payments (
mode: "payment") - UsecreateOneTimePaymentSession()- Only needscheckout.session.completedwebhook
Setup
1. Configure Stripe
See Stripe Setup Guide for complete instructions.
Required steps:
- Create Stripe account
- Get API keys (test and live)
- Create products and prices
- Set up webhook endpoint
- Add environment variables
2. Environment Variables
Add to .env.local:
# Stripe Keys
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLIC_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# Stripe Price IDs
STRIPE_PRICE_STARTER=price_...
STRIPE_PRICE_PRO=price_...
3. Configure Plans
Define your pricing plans in config.ts:
// config.ts
stripe: {
plans: [
{
name: "Starter",
price: 99,
priceId: process.env.STRIPE_PRICE_STARTER,
isFeatured: false,
features: [
"Feature 1",
"Feature 2",
],
},
{
name: "Pro",
price: 199,
priceId: process.env.STRIPE_PRICE_PRO,
isFeatured: true,
features: [
"Everything in Starter",
"Advanced Feature",
],
},
],
}
Checkout Flow
Creating Checkout Sessions
Use the ButtonCheckout component:
import ButtonCheckout from "@/components/ui/ButtonCheckout";
<ButtonCheckout priceId={plan.priceId} />
How it works:
- User clicks button
- Component calls
/api/checkoutwithpriceId - Server creates Stripe checkout session
- User is redirected to Stripe's hosted checkout
- After payment, user is redirected back to your app
API Route: /api/checkout
Handles checkout session creation:
// app/api/checkout/route.ts
export async function POST(req: NextRequest) {
const { priceId } = await req.json();
// Create checkout session
const session = await createCheckoutSession({
userId: user?.uid, // Optional: prefill if logged in
priceId,
successUrl: `${origin}/dashboard?checkout=success`,
cancelUrl: `${origin}/pricing?checkout=cancelled`,
});
return Response.json({ url: session.url });
}
Subscription Management
Webhook Events
Automatically handled in /api/webhooks/stripe/route.ts:
Supported Events:
checkout.session.completed- Customer completed checkoutcustomer.subscription.created- New subscription createdcustomer.subscription.updated- Subscription plan changedcustomer.subscription.deleted- Subscription canceledinvoice.paid- Payment succeededinvoice.payment_failed- Payment failed
Webhook Handler:
// app/api/webhooks/stripe/route.ts
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get("stripe-signature");
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case "checkout.session.completed":
// Handle successful checkout
break;
case "customer.subscription.updated":
// Update subscription in Firestore
break;
// ... more event handlers
}
}
Billing Portal
Allow customers to manage their subscriptions:
// app/api/billing/portal/route.ts
export async function POST(req: NextRequest) {
const user = await requireAuth(req);
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${origin}/dashboard`,
});
return Response.json({ url: session.url });
}
Usage in component:
const handleBillingPortal = async () => {
const res = await fetch("/api/billing/portal", {
method: "POST",
});
const { url } = await res.json();
window.location.href = url;
};
Subscription Status
Firestore Schema
Subscription data is stored in Firestore:
// Collection: users/{userId}
{
stripeCustomerId: string;
stripeSubscriptionId: string;
subscriptionStatus: "active" | "canceled" | "past_due";
subscriptionPlan: string; // e.g., "pro"
subscriptionPriceId: string;
subscriptionCurrentPeriodEnd: Timestamp;
}
Real-Time Updates
Use Firestore listeners for real-time subscription status:
"use client";
import { useEffect, useState } from "react";
import { doc, onSnapshot } from "firebase/firestore";
import { db } from "@/lib/firebase/client";
export function useSubscription(userId: string) {
const [subscription, setSubscription] = useState(null);
useEffect(() => {
if (!userId) return;
const unsubscribe = onSnapshot(
doc(db, "users", userId),
(snapshot) => {
setSubscription(snapshot.data());
}
);
return unsubscribe;
}, [userId]);
return subscription;
}
One-Time Payments
For one-off charges (not subscriptions):
// Create one-time payment checkout
const session = await stripe.checkout.sessions.create({
mode: "payment", // One-time payment
line_items: [
{
price: "price_one_time_product",
quantity: 1,
},
],
success_url: `${origin}/success`,
cancel_url: `${origin}/cancel`,
});
Testing
Test Cards
Use Stripe test cards:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - Requires auth:
4000 0025 0000 3155
Test Webhooks Locally
Use Stripe CLI:
# Install Stripe CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Copy webhook secret to .env.local
# STRIPE_WEBHOOK_SECRET=whsec_...
Best Practices
- Validate webhooks - Always verify webhook signatures
- Idempotency - Handle duplicate webhook events gracefully
- Error handling - Log and handle payment failures
- User experience - Provide clear success/error messages
- Security - Never expose secret keys client-side
Common Patterns
Check Subscription Status
import { getCurrentUserServer } from "@/lib/firebase/auth";
import { db } from "@/lib/firebase/admin";
import { doc, getDoc } from "firebase/firestore";
export async function hasActiveSubscription(userId: string) {
const userDoc = await getDoc(doc(db, "users", userId));
const data = userDoc.data();
return data?.subscriptionStatus === "active";
}
Cancel Subscription
// Via Stripe API
await stripe.subscriptions.cancel(subscriptionId);
// Webhook will update Firestore automatically
Troubleshooting
Checkout not redirecting
- Verify Stripe keys are correct
- Check webhook endpoint is accessible
- Ensure redirect URLs are whitelisted in Stripe dashboard
Webhooks not firing
- Verify webhook secret matches Stripe dashboard
- Check endpoint is publicly accessible
- Test with Stripe CLI locally
Subscription not syncing
- Check webhook handler is processing events
- Verify Firestore write permissions
- Review server logs for errors
Learn More
- Stripe Setup - Complete Stripe configuration
- Stripe Subscriptions Tutorial - Step-by-step guide
- Webhooks - Webhook handling details
- ButtonCheckout Component - Checkout button