Use webhooks to confirm payment fulfillment, not client-side redirects. Every other Stripe mistake follows from not understanding this one principle. Here is how to build a correct Stripe integration for a SaaS product.
The Three Core Billing Flows
Stripe supports three billing patterns that cover almost every SaaS use case.
One-time payments use the PaymentIntent API. You create a PaymentIntent on your server, return the client_secret to the browser, and use Stripe Elements or Stripe Checkout to collect card details and confirm the payment. The PaymentIntent tracks the lifecycle of the payment from creation through confirmation, processing, and success or failure.
Subscriptions use the Subscription and Customer APIs. You create a Customer (which represents a paying user in Stripe), attach a payment method, and create a Subscription tied to a Price. The Price references a Product and defines how often and how much you charge. Stripe handles recurring billing automatically: it charges the customer on each billing cycle, retries failed payments, and sends dunning emails based on your configuration. Your job is to listen to webhook events and update your database to reflect the current subscription state.
Usage-based billing uses the Meter API (the new approach as of 2024). You define a meter, report usage events as they happen (stripe.billing.meters.createEvent), and attach a metered price to a subscription. Stripe aggregates usage over the billing period and charges accordingly. This is the right pattern for APIs, AI tokens, or any resource your customers consume at variable rates.
Why Webhooks Beat Client-Side Redirects
Here is the mistake developers make when first integrating Stripe. The customer completes payment, Stripe redirects them to your /success page, and you update the database based on the URL parameters. This is wrong.
The redirect can fail. The customer can close the browser tab after payment but before the redirect. The redirect URL can be tampered with. A malicious user can navigate directly to your success page without paying.
Webhooks solve this. When a payment is confirmed, Stripe sends a POST request to your webhook endpoint with the full event payload. Your webhook handler verifies the signature, checks the event type (payment_intent.succeeded, invoice.payment_succeeded, customer.subscription.updated), and updates your database. This happens server-to-server. It cannot be bypassed by the client.
Your client-side success page should only be used for UI feedback (showing a thank you message). The actual fulfillment (granting access, activating a subscription) must happen in the webhook handler.
Webhook Signature Verification
Never process a webhook without verifying its signature. Stripe signs every webhook with your endpoint's signing secret. Verify it like this:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response("Webhook signature verification failed", { status: 400 });
}
// handle event
}
If you skip signature verification, anyone can POST fake webhook events to your endpoint and trigger fulfillment without a real payment.
Idempotency: Handling Duplicate Webhook Deliveries
Stripe guarantees at-least-once delivery of webhooks. This means the same event can be delivered more than once. If your webhook handler is not idempotent, a duplicate delivery could grant a subscription twice, credit a user's account twice, or create duplicate records.
The fix is simple: check whether you have already processed a given event ID before doing anything. Store processed event IDs in your database with a unique index on the event ID. If the event ID already exists, return 200 immediately without doing anything.
const existingEvent = await db.collection("stripe_events").findOne({ event_id: event.id });
if (existingEvent) {
return new Response("Already processed", { status: 200 });
}
// process event, then insert event_id into stripe_events
Stripe Elements vs Stripe Checkout
Stripe Elements is a set of pre-built UI components (card number, expiry, CVC, address fields) that you embed into your own page. You control the layout, styling, and user experience. Elements never touches your server directly — the customer's card details stay in Stripe's iframe and you only receive a payment method ID.
Use Elements when you need a fully custom checkout experience that matches your design system and does not look like a third-party payment page.
Stripe Checkout is a Stripe-hosted payment page. You redirect the customer to checkout.stripe.com with your session parameters, and Stripe handles everything: the UI, form validation, error messages, 3D Secure authentication, and the redirect back to your site. You get a working checkout in about 10 lines of code.
Use Checkout when you want to ship a working payment flow in an hour and are willing to accept Stripe's default styling. Checkout also handles many edge cases automatically (Apple Pay, Google Pay, BLIK, iDEAL, and other local payment methods based on the customer's location).
The Customer Portal
Stripe's Customer Portal is a hosted page where your customers can manage their own subscriptions: view invoices, update their payment method, upgrade or downgrade their plan, and cancel. You enable it in your Stripe dashboard, configure which plans customers can switch between, and generate a portal link via the API.
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: "https://yourapp.com/settings/billing",
});
redirect(session.url);
This saves you from building your own subscription management UI. For most SaaS products, the Customer Portal covers 90% of what users need to do with their billing.
Testing with the Stripe CLI
Stripe's test mode uses separate API keys. Test card numbers like 4242 4242 4242 4242 simulate successful payments. 4000 0000 0000 9995 simulates a declined card. 4000 0027 6000 3184 triggers 3D Secure authentication.
For webhooks, install the Stripe CLI and run:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This creates a local webhook listener that forwards Stripe events to your dev server in real time. You can trigger specific events manually:
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.updated
This lets you test your webhook handler without completing a real payment flow every time.
Common Mistakes That Burn Developers in Production
Relying on client-side confirmation for fulfillment. Already covered above. Do not do this.
Not handling invoice.payment_failed events. When a subscription renewal fails, Stripe retries multiple times before eventually canceling the subscription. During this period, the customer's subscription is still active but payment is failing. You should listen for invoice.payment_failed and optionally notify the customer to update their payment method.
Using the same webhook endpoint for both test and production events. Keep them separate. Your test Stripe account events should go to a dev/staging endpoint, not production.
Not storing the Stripe Customer ID on your user record. Every user who has paid should have a stripe_customer_id stored in your database. Many developers discover they need this later and have no way to link their users to Stripe customers.
Hardcoding Price IDs. Store Stripe Price IDs in environment variables so you can update them without code changes.
Keep Reading
- Next.js App Router Patterns 2026 — where to put your Stripe API routes in the App Router
- CI/CD for Small Engineering Teams — testing Stripe webhooks in your CI pipeline
- We Replaced 6 SaaS Tools with One: What Happened — consolidating billing and project management
Pristren builds AI-powered software for teams. Zlyqor is our all-in-one workspace — chat, projects, time tracking, AI meeting summaries, and invoicing — in one tool. Try it free.