Payments & subscriptions
ShipVeryFast ships a complete Stripe subscription flow: typed pricing plans, a server-side Checkout endpoint that never leaks price IDs to the browser, and a signed webhook handler that keeps your subscriptions table in sync. This page walks the whole path from plan definition to live billing.
Pricing plans
Plans are defined as plain TypeScript data in libs/pricingPlans.ts. Out of the box there are three tiers, Starter (id free), Professional (id pro, marked popular) and Enterprise (id enterprise), each with monthly and yearly prices and a feature list. The shape is fully typed by the PricingPlan interface:
export interface PricingPlan {
id: string;
name: string;
description: string;
price: {
monthly: number;
yearly: number;
};
features: string[];
popular?: boolean;
buttonText: string;
stripePriceId?: {
monthly: string;
yearly: string;
};
comparisonEnabled?: boolean;
}The same file exports helpers you'll reach for in UI: getPlanById(id), getPopularPlan(), and createFreemiumPricingPlan(). Note that the inline price values are display numbers, the real money is the Stripe price IDs, which live in environment variables (next section).
Resolving Stripe price IDs server-side
Price IDs are never hardcoded in the plan data and never shipped in the client bundle. Instead, getStripePriceId(planId) maps a plan id to the matching STRIPE_PRICE_* environment variable at request time, on the server only:
export function getStripePriceId(planId: string): string | undefined {
switch (planId) {
case 'free':
case 'starter':
case 'basic':
return process.env.STRIPE_PRICE_BASIC;
case 'pro':
case 'professional':
return process.env.STRIPE_PRICE_PRO;
case 'enterprise':
return process.env.STRIPE_PRICE_ENTERPRISE;
default:
return undefined;
}
}The three variables it reads are declared in libs/config.ts and validated with Zod at startup:
| Env var | Plan | Required? |
|---|---|---|
STRIPE_PRICE_BASIC | Starter / free | Required |
STRIPE_PRICE_PRO | Professional / pro | Required |
STRIPE_PRICE_ENTERPRISE | Enterprise | Optional |
STRIPE_PRICE_ENTERPRISE is declared as .optional()because Enterprise is often a "contact sales" tier rather than a fixed price, getStripePriceId('enterprise') simply returns undefined when it is unset.
The checkout endpoint
The Checkout Session is created by app/api/checkout/session/route.ts (POST). Its Zod schema accepts either a planId (resolved server-side via getStripePriceId) or a raw priceId, with a .refine() guaranteeing at least one is present, plus an optional quantity that defaults to 1:
const sessionInputSchema = z
.object({
planId: z.string().min(1).optional(),
priceId: z.string().min(1).optional(),
quantity: z.number().int().positive().default(1),
})
.refine((d) => d.planId || d.priceId, {
message: 'Either planId or priceId is required',
});A request to start a Professional subscription is just:
// POST /api/checkout/session
const res = await fetch('/api/checkout/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId: 'pro', quantity: 1 }),
});
const { sessionUrl } = await res.json();
window.location.href = sessionUrl; // redirect to Stripe CheckoutServer-side the route prefers an explicit priceId, otherwise maps the planId. If no price can be resolved it returns 400 with a message telling you to set the matching STRIPE_PRICE_* var. Otherwise it creates a mode: 'subscription' Checkout Session and responds with { sessionUrl }:
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity }],
success_url: `${origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/cancel`,
});
return NextResponse.json({ sessionUrl: session.url });The shared Stripe client lives in libs/stripe.ts and is initialized from env.STRIPE_SECRET_KEY with API version 2025-03-31.basil.
The success_url points at /success and cancel_url at /cancel. These return URLs are yours to build, wire up post-checkout pages in your app and treat the webhook (below) as the source of truth for entitlement, not the redirect.
The webhook handler
Stripe notifies your app of billing events at app/api/webhooks/stripe/route.ts (POST). It first verifies the signature with stripe.webhooks.constructEvent using the stripe-signature header and env.STRIPE_WEBHOOK_SECRET, an invalid signature is logged via logger.critical and rejected with a 400 before any database write. It then handles three event types:
switch (event.type) {
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await supabaseAdmin
.from('subscriptions')
.update({ status: subscription.status })
.eq('stripe_subscription_id', subscription.id);
break;
}
case 'invoice.payment_succeeded': {
const invoice: any = event.data.object;
await supabaseAdmin
.from('subscriptions')
.update({ status: 'active' })
.eq('stripe_subscription_id', invoice.subscription as string);
break;
}
case 'invoice.payment_failed': {
const invoice: any = event.data.object;
await supabaseAdmin
.from('subscriptions')
.update({ status: 'past_due' })
.eq('stripe_subscription_id', invoice.subscription as string);
break;
}
default:
logger.critical('Unhandled Stripe event type', {
context: 'stripe-webhook',
event: event.type,
});
}Any unrecognized event type is logged and ignored. On success the route responds with { received: true }.
Syncing status to Supabase
Each handled event updates the subscriptions table through supabaseAdmin (the service-role client from libs/supabase), matching rows on stripe_subscription_id. The row shape is described by subscriptionSchema in models/subscription.ts:
export const subscriptionSchema = z.object({
id: z.string(),
user_id: z.string(),
stripe_customer_id: z.string(),
stripe_subscription_id: z.string(),
status: z.string(),
created_at: z.string().datetime(),
updated_at: z.string().datetime().nullable(),
});The status column ends up reflecting Stripe's own values, the raw subscription.status on customer.subscription.updated, active on a successful invoice, and past_due on a failed one. Read this column wherever you gate paid features.
Because matching is keyed on stripe_subscription_id, the row must already exist for an update to take effect. Make sure your app inserts a subscriptions row (with the customer and subscription IDs) when a checkout completes so these webhook updates have a row to land on.
Local webhook testing
Use the Stripe CLI to forward live test-mode events to your local route while npm run dev is running on http://localhost:3000:
# install once: https://stripe.com/docs/stripe-cli
stripe login
# forward events to the local webhook route
stripe listen --forward-to localhost:3000/api/webhooks/stripestripe listen prints a signing secret that looks like whsec_.... Copy it into STRIPE_WEBHOOK_SECRET in your .env.local and restart the dev server, otherwise the signature check fails with Webhook Error. You can then drive events from a second terminal:
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.updatedTest vs live mode
Everything is keyed off environment variables, so switching from test to live is a matter of swapping values, no code changes. Before launch, replace each of these with its live-mode counterpart:
| Env var | What to swap |
|---|---|
STRIPE_SECRET_KEY | Test sk_test_... → live sk_live_... |
STRIPE_PUBLIC_KEY | Test pk_test_... → live pk_live_... |
STRIPE_WEBHOOK_SECRET | The whsec_... from your live webhook endpoint (not the stripe listen one) |
STRIPE_PRICE_BASIC | Live price ID for the Starter product |
STRIPE_PRICE_PRO | Live price ID for the Professional product |
STRIPE_PRICE_ENTERPRISE | Live price ID for Enterprise (or leave unset) |
Test-mode and live-mode price IDs are different objects in Stripe, so the STRIPE_PRICE_* values must be re-pointed too, a live secret key with a test price ID will fail at checkout. In production, register a live webhook endpoint pointing at https://your-domain.com/api/webhooks/stripe and use its signing secret.
Customizing plans
To change tiers, edit the pricingPlans array in libs/pricingPlans.ts, adjust names, descriptions, features, display price values, and the popular flag freely. To add a brand-new plan with its own Stripe product:
- Create the product and a recurring price in the Stripe Dashboard and copy the price ID (
price_...). - Add a new entry to
pricingPlanswith a uniqueid. - Add a
casefor thatidingetStripePriceIdreturning a newSTRIPE_PRICE_*env var. - Declare that env var in
libs/config.tsand add it to.env.exampleand your.env.local.
The existing getStripePriceId already accepts several aliases per plan (e.g. free/starter/basic all map to STRIPE_PRICE_BASIC), which is handy if your UI and Stripe products use slightly different names. As an escape hatch, the checkout endpoint also accepts a raw priceIddirectly, so you can drive Checkout for prices that aren't in pricingPlans.ts at all.
Next steps
- Environment variables, every
STRIPE_*key and its Zod validation. - Database, the Supabase
subscriptionstable and admin client. - Security, webhook signature verification and the service-role key.
