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 user clicks the buy button to the moment they land on the order confirmation page with access to what they purchased.
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 Stripe session is created with a few important details:
const session = await stripe.checkout.sessions.create({
line_items: [{ price: priceId, quantity: 1 }],
discounts: [{ coupon: couponId }],
mode: 'payment',
success_url: `${env.SITE_URL}/order?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.SITE_URL}`,
allow_promotion_codes: allowPromotionCodes,
automatic_tax: { enabled: true },
custom_fields: [
{
key: 'github',
label: { type: 'custom', custom: 'GitHub Username' },
optional: false,
type: 'text',
},
],
payment_intent_data: {
capture_method: 'manual',
},
});A few things worth noting here:
Manual capture: The capture_method: 'manual' setting is the most important detail in this entire flow. It means Stripe authorizes the payment at checkout but does not actually move the money yet. The capture happens later, only after the order has been validated and fulfilled. If anything goes wrong — invalid GitHub username, user already a collaborator, GitHub API failure — the payment is cancelled and the customer is never charged. This is the right approach for any product where fulfillment requires a server-side validation step.
Custom fields: Plainform asks for a GitHub username at checkout because the product is repository access. Stripe's custom fields feature collects this directly on the checkout page, so there is no need for a separate form or a post-purchase step.
Automatic tax: Stripe calculates and collects tax automatically based on the customer's location. This is one less compliance concern to manage.
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/stripe/webhook. 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 ordercoupon.created/coupon.updated/coupon.deleted— keeps the event feed and cache in syncproduct.created/product.updated/product.deleted— invalidates the products cache
The order processing logic in handleCheckoutSessionCompleted works through a series of validation steps, and at each step, if something fails, the payment is cancelled and an email goes out explaining what went wrong.
Step 1: Validate the GitHub username
const githubUsernameRes = await checkGithubUsername(githubUsername);
if (!githubUsernameRes.ok) {
await cancelPaymentIntent(paymentIntentId);
await resend.emails.send({ /* failure email */ });
return NextResponse.json({ message: githubUsernameRes.message }, { status: 404 });
}The GitHub username is extracted from the session's custom fields. If it is missing or does not correspond to a real GitHub account, the payment is cancelled immediately.
Step 2: Check for existing collaborator status
const isCollaborator = await isAlreadyCollaborator(githubUsername);
if (!isCollaborator.ok) {
await cancelPaymentIntent(paymentIntentId);
await resend.emails.send({ /* failure email */ });
}If the user is already a collaborator on the repository, there is nothing to grant them. The payment is cancelled and they receive an email explaining the situation.
Step 3: Capture the payment
const captureResult = await capturePaymentIntent(paymentIntentId);Only after both validations pass does the payment get captured. The capturePaymentIntent function retrieves the payment intent first to check its current state — if it has already been captured (which can happen if the webhook fires twice), it returns success without trying to capture again. This makes the capture step idempotent.
Step 4: Add the GitHub collaborator
const collaboratorRes = await addGithubCollaborator(githubUsername);With the payment captured, the user is added as an outside collaborator on the repository. If this step fails, the payment is cancelled and a failure email goes out.
Step 5: Confirm success
If everything succeeds, the session metadata is updated with collaboratorStatus: 'success', the customer is added to the newsletter list, and a success email goes out with a link to the repository.
The metadata update at the start of the handler checks for this flag:
if (session.metadata?.collaboratorStatus === 'success') {
return NextResponse.json({ message: '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({
githubUsername,
orderStatus,
collaborator_status,
message,
additionalMessage,
paymentStatus,
isSuccess,
customerEmail,
amountSubtotal,
amountTotal,
amountDiscount,
});The isSuccess flag controls what the page shows. On success, the customer sees their order summary with subtotal, discount, and total, plus a button linking directly to the GitHub repository. 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 renders the correct state on the first load. There is no client-side polling or loading state — by the time the customer lands on this page, the webhook has already run and the session metadata reflects the final outcome.
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',
timestamp: event?.data?.object?.created,
});
revalidateTag('event');
}
revalidateTag('stripe/coupons');
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 means customers are never charged for orders that cannot be fulfilled
- 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.
