Payments are one of the most consequential pieces of any SaaS product. Get them wrong and you lose money, lose customers, or both. Get them right and they just work — the customer pays, the product is delivered, and everyone moves on.
Plainform ships with a complete Stripe integration already wired together. This post walks through exactly how it works, from the moment a signed-in user clicks the buy button to the moment their checkout is processed and their local user record is synced.
Plainform Payment Flow
Plainform uses Stripe Checkout for payment collection, verified Stripe webhooks for server-side fulfillment, and local database updates for orders, subscriptions, and user state. The important part is that the app does not trust the browser to decide whether a payment succeeded. Stripe sends the final event to the webhook, and the server handles the durable update.
The payment flow works in this order:
- A signed-in user starts checkout from the pricing flow.
- Plainform creates a Stripe Checkout session on the server.
- Stripe collects the payment details and redirects the user back.
- Stripe sends the trusted payment event to the webhook.
- Plainform verifies the webhook, updates the database, and syncs the user's order or subscription state.
The Foundation: Stripe
Plainform uses Stripe as its payment processor. The client is initialized in a single server-only file:
import { env } from '@/env';
import 'server-only';
import Stripe from 'stripe';
export const stripe = new Stripe(env.STRIPE_SECRET_KEY);The server-only import is intentional. It makes the build fail if this module is ever accidentally imported in a client component, which would expose the secret key. The stripe instance is imported wherever payment operations need to happen — the checkout route, the webhook handler, the session retrieval route.
The Pricing Page
The pricing section is a server component that fetches products and coupons in parallel at render time:
const data = await getProducts();
const couponData = await getCoupons();Both getProducts and getCoupons call their respective API routes with cache: 'force-cache' and Next.js cache tags. This means the pricing data is cached and served instantly on every page load, with no Stripe API call on each request. When a product or coupon changes, the webhook handler calls revalidateTag to bust the cache and the next request fetches fresh data.
The Discount component renders at the top of the pricing section when a valid featured coupon exists. It shows the coupon name and how many redemptions are left, which creates a sense of urgency without any manual copy changes.
Each PricingCard receives the product data and coupon details as props. The discount calculation happens inside the card:
const checkDiscount = (
amountOff: number | null,
percentOff: number | null,
isCouponValid: boolean
) => {
if (!isCouponValid) return null;
if (amountOff) return (unitAmount - amountOff) / 100;
if (percentOff) return (unitAmount * percentOff) / 100 / 100;
return null;
};Stripe supports both fixed-amount and percentage discounts. The card handles both cases and shows the original price with a strikethrough when a discount is active.
Initiating Checkout
The buy button on each pricing card is a plain HTML form that posts to /api/stripe/checkout:
<form action="/api/stripe/checkout" method="POST">
<input type="hidden" name="priceId" value="{priceId}" />
{discountedPrice && couponId && (
<input type="hidden" name="couponId" value="{couponId}" />
)}
<button type="submit">Get Started</button>
</form>Using a native form instead of a JavaScript fetch call means the checkout works without any client-side JavaScript. The form posts, the server creates a Stripe Checkout session, and the user is redirected to Stripe's hosted checkout page.
The checkout route applies strict rate limiting before doing anything else — 5 requests per 10 seconds per client. This prevents abuse without affecting real users who will never come close to that limit.
The route also requires an authenticated Clerk user. If there is no active user, the request redirects to /sign-in. When the user is present, the route upserts the local Prisma User record, creates or updates the Stripe customer, and stores the Clerk user ID in Stripe customer metadata so later subscription webhooks can sync back to the right local user.
The Stripe session is created with a few important details:
const session = await stripe.checkout.sessions.create({
customer: customerId,
customer_update: {
address: 'auto',
name: 'auto',
},
line_items: [{ price: priceId, quantity: 1 }],
discounts: [{ coupon: couponId }],
mode,
tax_id_collection: {
enabled: true,
},
success_url: `${env.SITE_URL}/order?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.SITE_URL}`,
allow_promotion_codes: allowPromotionCodes,
automatic_tax: { enabled: true },
...(mode === 'payment' && {
payment_intent_data: {
capture_method: 'manual',
},
}),
});A few things worth noting here:
Dynamic checkout mode: Plainform reads the Stripe price and chooses payment for one-time prices or subscription for recurring prices. That lets the same pricing UI support both purchase types without separate checkout routes.
Manual capture for one-time payments: The capture_method: 'manual' setting is applied only when the checkout mode is payment. Stripe authorizes the payment at checkout but does not actually move the money until the webhook captures it. This gives you a clean place to add product-specific validation before capture.
Automatic tax: Stripe calculates and collects tax automatically based on the customer's location. This is one less compliance concern to manage.
Customer records: The route creates or reuses a Stripe customer linked to the local Prisma User. This is what makes subscription state durable across webhooks and customer portal sessions.
Promotion codes: When no coupon is pre-applied, allow_promotion_codes: true lets customers enter their own codes at checkout. When a coupon is already applied via the pricing page, this is disabled to avoid stacking discounts.
The Webhook Handler
After the customer completes checkout, Stripe sends a checkout.session.completed event to the webhook endpoint at /api/webhooks/stripe. This is where the actual order processing happens.
The first thing the webhook does is verify the request signature:
event = stripe.webhooks.constructEvent(rawBody, sig, env.STRIPE_WEBHOOK_SECRET);This is non-negotiable. Without signature verification, anyone could send a fake webhook event and trigger order fulfillment without paying. The raw request body is used for verification — parsing it as JSON first would break the signature check.
The webhook handles several event types:
checkout.session.completed— processes the ordercustomer.subscription.created/updated/deleted— keeps the local user subscription state in synccoupon.created/coupon.updated/coupon.deleted— keeps the event feed and cache in syncproduct.created/product.updated/product.deleted— invalidates the products cache
The checkout handler branches based on the Stripe session mode. One-time payments are handled by handleOneTimeCheckoutSessionCompleted. Subscription checkouts are acknowledged at the checkout level, while durable subscription state is synced from the customer.subscription.* webhook events.
One Event, One Handler
The Stripe webhook route is intentionally structured as a dispatcher. It verifies the Stripe signature once, then sends each event type to the smallest handler that owns that behavior:
switch (event.type) {
case 'checkout.session.completed':
return await handleCheckoutSessionCompleted(
event.data.object as Stripe.Checkout.Session
);
case 'customer.subscription.created':
return await handleSubscriptionCreated(
event.data.object as Stripe.Subscription
);
case 'customer.subscription.updated':
return await handleSubscriptionUpdated(
event.data.object as Stripe.Subscription
);
case 'customer.subscription.deleted':
return await handleSubscriptionDeleted(
event.data.object as Stripe.Subscription
);
default:
break;
}That pattern keeps the webhook route readable as the app grows. The route owns signature verification, event dispatching, and cache revalidation for simple Stripe catalog events. Dedicated files own durable business logic like payment capture and subscription syncing.
It also makes extension straightforward. If you want to grant credits after a successful one-time payment, add that logic inside handleOneTimeCheckoutSessionCompleted after capturePaymentIntent succeeds. If you want to unlock plan features, derive them from Stripe product metadata in syncSubscription and persist them on the local User record. If you want to send an internal notification when a subscription is canceled, add it to handleSubscriptionDeleted.
For example, a credits-based product could extend the one-time payment handler like this:
if (captureResult.ok) {
await prisma.user.update({
where: { id: clerkUserId },
data: {
credits: { increment: 100 },
},
});
}And a subscription product can use Stripe product metadata to control access:
const planKey = product.metadata?.planKey ?? price.lookup_key ?? null;
await prisma.user.upsert({
where: { id: clerkUserId },
update: {
subscriptionStatus: subscription.status,
planKey,
},
create: {
id: clerkUserId,
email,
subscriptionStatus: subscription.status,
planKey,
},
});The important rule is to keep webhook work idempotent. Stripe can retry events, so every handler should be safe to run more than once.
One-time payment flow
if (session.mode === 'payment') {
return await handleOneTimeCheckoutSessionCompleted(session);
}For one-time payments, the webhook checks that the session has a customer email, captures the payment intent, updates the Stripe session metadata with the final order state, subscribes the customer to the newsletter, sends the transactional email, and revalidates Stripe-related cache tags. If capture fails, the payment is cancelled and the session metadata is marked as declined.
Subscription flow
if (session.mode === 'subscription') {
return await handleSubscriptionCheckoutSessionCompleted(session);
}For subscriptions, the checkout-completed event is intentionally light. It marks the session as accepted and revalidates cache tags. The actual subscription entitlement state is handled by customer.subscription.created, customer.subscription.updated, and customer.subscription.deleted.
Those subscription webhooks retrieve the Stripe customer, read the clerkUserId from customer metadata, and upsert the local Prisma User record with fields like stripeSubscriptionId, stripePriceId, subscriptionStatus, subscriptionCurrentPeriodEnd, cancelAtPeriodEnd, and planKey.
Idempotency guard
The metadata update at the start of the handler checks for this flag:
if (session.metadata?.paymentStatus === 'Accepted') {
return NextResponse.json(
{ message: 'Checkout session already processed.' },
{ status: 200 }
);
}This is the idempotency guard. If Stripe retries the webhook (which it will if the first delivery fails), the handler detects that the order was already processed and returns early without doing anything twice.
The Order Confirmation Page
After checkout, Stripe redirects the customer to /order?session_id={CHECKOUT_SESSION_ID}. The middleware validates the session ID before the page renders — if the session ID is missing or invalid, the user is redirected to the home page. This prevents anyone from accessing the order page directly without a valid session.
The page fetches the session data from /api/stripe/session, which returns only the fields the page needs:
return NextResponse.json({
orderStatus,
message,
additionalMessage,
paymentStatus,
isSuccess,
customerEmail,
amountSubtotal,
taxIds,
businessName,
amountTotal,
amountDiscount,
});The isSuccess flag controls what the page shows. On success, the customer sees their order summary with subtotal, discount, tax/business details when available, and total. On failure, they see an explanation of what went wrong and a button back to the home page.
The order page is a server component that reads the session metadata and renders the correct state. If the webhook has not finished yet, the page shows a processing state and refreshes every few seconds until Stripe metadata contains the final paymentStatus.
Coupons and the Event Feed
Coupons in Plainform are managed entirely through the Stripe dashboard. When you create a coupon with isFeatured: true in its metadata, the webhook handler picks it up and records it in the event feed:
case 'coupon.created':
if (event?.data?.object?.metadata?.isFeatured === 'true') {
await recordEvent({
text: event?.data?.object?.name,
slug: '#coupon',
type: 'coupon',
});
revalidateTag('event', 'max');
}
revalidateTag('stripe/coupons', 'max');
break;The event feed on the home page shows the coupon name as a live update. When the coupon is deleted, the event is removed from the feed. The pricing section cache is invalidated on every coupon change, so the discount banner appears and disappears automatically without any code deploys.
How It All Fits Together
The payment system in Plainform is built around a few core principles:
- Manual capture for one-time payments gives backend code a checkpoint before funds are captured
- Subscription webhooks keep the local Prisma
Usersubscription state synced with Stripe - Webhook signature verification means only real Stripe events trigger order processing
- Idempotency guards mean webhook retries do not cause duplicate fulfillment
- Cache tags mean product and coupon data is always fresh without hitting Stripe on every request
- Rate limiting on the checkout endpoint means the API cannot be abused
When you clone Plainform and add your Stripe keys to the environment variables, all of this works immediately. The pricing page, the checkout flow, the webhook handler, the order confirmation page — it is all there and wired together correctly.
Payments are not the interesting part of your product. Plainform makes sure they are never the thing slowing you down.
