pealdevDOCS
Back to peal.dev

STRIPE STARTER · PAYMENT MODE

Payment mode

The single most important pattern in this template: payments.config.ts. One field controls the entire app — which Stripe flows run, which webhooks process, which UI renders. Get this and the rest is muscle memory.

We split payment behavior into one config file on purpose. Every file that depends on payment mode reads from payments.config.ts — and only from payments.config.ts. Flip mode from "one-time" to "subscription" and the entire app adapts: pricing UI, webhook filter, checkout session params.

The three modes

TypeScript
// payments.config.ts
import type { PaymentsConfig } from "@/types/payments";

const config: PaymentsConfig = {
  mode: "subscription", // "one-time" | "subscription" | "hybrid"
  customerPortal: { enabled: true },
  redirects: {
    success: "/checkout/success",
    cancel: "/checkout/cancel",
  },
};

What mode actually controls

  • Which plan arrays render — oneTimePlans vs subscriptionPlans vs both.
  • Which Stripe session mode is used — "payment" for one-time, "subscription" for recurring (auto-detected from the price's plan type).
  • Which webhook events get processed (the rest are acknowledged with 200 but skipped).
  • Whether the monthly/yearly toggle renders (subscription/hybrid only).
  • Whether the Customer Portal button shows up after checkout (subscription/hybrid only).

Server: read it via getPaymentsConfig()

TypeScript
import { getPaymentsConfig } from "@/lib/payments";

const config = getPaymentsConfig();
if (config.customerPortal.enabled) {
  // render the portal button
}

Client: read it via usePaymentsConfig()

TypeScript
"use client";
import { usePaymentsConfig } from "@/hooks/use-payments-config";

export function MyComponent() {
  const { mode, supportsOneTime, supportsSubscriptions, customerPortal } =
    usePaymentsConfig();

  return (
    <>
      {supportsSubscriptions && <BillingToggle />}
      {supportsOneTime && <BuyOnceCard />}
      {customerPortal.enabled && <ManageBillingButton />}
    </>
  );
}

Mode → events table

TypeScript
// src/lib/payments.ts
const WEBHOOK_EVENTS_BY_MODE = {
  "one-time":    ["checkout.session.completed"],
  subscription:  ["checkout.session.completed", "invoice.paid",
                  "customer.subscription.updated",
                  "customer.subscription.deleted"],
  hybrid:        ["checkout.session.completed", "invoice.paid",
                  "customer.subscription.updated",
                  "customer.subscription.deleted"],
};

Why isEventRelevant exists

Stripe sends every event you subscribed to in the dashboard. If you're in one-time mode, your webhook will still receive invoice.paid events for any test subscription you tried earlier. isEventRelevant filters them — the route still returns 200 (so Stripe doesn't retry) but skips dispatch.

Pick mode BEFORE you start charging

Switching mode after live customers exist is doable but messy. Your Stripe Customer IDs are mode-agnostic, but your fulfillment logic isn't — a one-time customer doesn't have a subscription to extend, and a subscription customer doesn't have a one-shot fulfillment. Pick the mode for your business model and stick.

The 90% rule

When you want to change payment behavior, the answer is almost always: edit payments.config.ts or src/config/pricing.ts and restart dev. Switching to yearly-only? edit pricing.ts. Disabling the portal? customerPortal.enabled = false. Changing success page? redirects.success.