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
Checkout footguns
- Redirect URLs: Always use
config.buildSiteUrl(...). Never accept clientsuccessUrl/cancelUrl/ portalreturnUrl, and never trustOrigin. - Invoice PDF /
invoice_creation: Do not enable Stripe Checkoutinvoice_creation(or similar PDF invoice add-ons) casually. Some invoice features bill ~4% fees even when Dashboard toggles look “off.” Prefer explicit Dashboard settings you understand; keep session create lean (seesrc/lib/stripe/checkout.ts).
Redirect rules
- Checkout success/cancel:
src/lib/stripe/checkout.tspinsbuildSiteUrl; Zod schema has no client URLs - Magic-link / post-auth
next:isSafeAppRelativePath(src/features/auth/safe-path.ts) - Billing portal return URL: schema is empty
{};createBillingPortalSessionalways usesconfig.buildSiteUrlwith a fixed path. Clients post{}only. - HTTPS upgrade:
enforceHttpsusesresolveSiteOrigin(), not the rawHostheader
Copy these patterns from this page and Stripe Subscriptions. Do not accept client redirect URLs in checkout or portal APIs.
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
// Client body: { priceId } only. Never accept successUrl/cancelUrl.
export async function POST(req: NextRequest) {
const user = await requireAuth(req);
const { priceId } = createCheckoutSessionSchema.parse(await req.json());
// Server pins redirects via config.buildSiteUrl inside createCheckoutSession
const session = await createCheckoutSession({
userId: user.uid,
priceId,
});
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 cancelledinvoice.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
// No client returnUrl. Server uses config.buildSiteUrl("/dashboard?billing=updated").
export async function POST(req: NextRequest) {
const user = await requireAuth(req);
const portalUrl = await createBillingPortalSessionForUser(user.uid, {});
return Response.json({ url: portalUrl });
}
Usage in component:
const handleBillingPortal = async () => {
const res = await fetch("/api/billing/portal", {
method: "POST",
body: JSON.stringify({}),
});
const { url } = await res.json();
window.location.href = url; // Stripe-hosted portal URL from the server
};
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 (server-side only)
const session = await stripe.checkout.sessions.create({
mode: "payment", // One-time payment
line_items: [
{
price: "price_one_time_product",
quantity: 1,
},
],
success_url: config.buildSiteUrl("/dashboard?checkout=success"),
cancel_url: config.buildSiteUrl("/pricing?checkout=cancelled"),
});
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