ShipVeryFastShipVeryFast
Documentation

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 varPlanRequired?
STRIPE_PRICE_BASICStarter / freeRequired
STRIPE_PRICE_PROProfessional / proRequired
STRIPE_PRICE_ENTERPRISEEnterpriseOptional

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 Checkout

Server-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/stripe

stripe 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.updated

Test 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 varWhat to swap
STRIPE_SECRET_KEYTest sk_test_... → live sk_live_...
STRIPE_PUBLIC_KEYTest pk_test_... → live pk_live_...
STRIPE_WEBHOOK_SECRETThe whsec_... from your live webhook endpoint (not the stripe listen one)
STRIPE_PRICE_BASICLive price ID for the Starter product
STRIPE_PRICE_PROLive price ID for the Professional product
STRIPE_PRICE_ENTERPRISELive 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:

  1. Create the product and a recurring price in the Stripe Dashboard and copy the price ID (price_...).
  2. Add a new entry to pricingPlans with a unique id.
  3. Add a case for that id in getStripePriceId returning a new STRIPE_PRICE_* env var.
  4. Declare that env var in libs/config.ts and add it to .env.example and 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 subscriptions table and admin client.
  • Security, webhook signature verification and the service-role key.