
Stripe Payment Integration: Why Code That Handles Money Is Terrifying
Integrating Stripe for my SaaS was nerve-wracking. Code that handles money feels different. From Checkout Sessions to Webhooks—lessons from the trenches.

Integrating Stripe for my SaaS was nerve-wracking. Code that handles money feels different. From Checkout Sessions to Webhooks—lessons from the trenches.
Integrating a payment API is just the beginning. Idempotency, refund flows, and double-charge prevention make payment systems genuinely hard.

If your app keeps crashing on AWS/Docker. The 12 Commandments written by Heroku founders.

MCP lets AI read files, query databases, and call APIs through a standardized protocol. Think of it as USB for AI tool connections.

I needed to send signup confirmation emails but HTML email development is hell. React Email lets you build emails like components, and Resend handles delivery.

I built a SaaS. Features done. Landing page up. User accounts working. But I had no way to take payment.
I picked Stripe. Good reputation, excellent docs, everyone in the dev community seemed to use it. I signed up, copied the API keys, and started coding.
And then something strange happened. I felt nervous.
I'm not the type who panics about bugs. UI breaks? Fix it. Query is slow? Add an index. But payment code felt different. My hands were slightly unsteady. "What if someone gets double-charged?" "What if payment succeeds but my server crashes before processing it?" "What if a refund request leaves the database in a weird state?"
Code that moves real money between real people carries a different kind of weight. This post is my notes from working through that weight—the concepts I finally understood, the mistakes I almost made, and what I'd tell myself before starting.
Stripe's documentation is massive. I wasted hours reading things I didn't need. Eventually it narrowed down to three core ideas.
A Checkout Session is Stripe's hosted payment page. Instead of building my own card input form, I send users to a page Stripe controls. They enter their card details there, not on my server.
This felt wrong at first. "It's my product—why am I sending users to a Stripe URL?" But think about what that means: I never touch raw card numbers. PCI-DSS compliance, the security standard that governs cardholder data, is Stripe's problem, not mine. Achieving PCI compliance directly involves months of audits and infrastructure changes. Checkout Sessions hand that entire burden to Stripe.
Think of it like a department store gift card exchange counter. A customer walks into your store. You say, "For payment, follow me to the shared counter over there." The counter staff handles the transaction, issues a receipt, and the customer comes back to your store with proof of purchase. You never saw the card details. That's Checkout Session.
A PaymentIntent is a record that says "I intend to charge this customer this amount." When you create a Checkout Session, Stripe creates a PaymentIntent behind the scenes. As the payment progresses, the PaymentIntent's status changes: requires_payment_method → processing → succeeded.
It seemed overly complex at first. Then I understood: payment isn't binary. A card might be waiting for 3D Secure authentication from the bank. It might be flagged for fraud review. It might be in a retry state after a failed authorization. PaymentIntent is an explicit state machine that tracks all of these intermediate states. The complexity exists because the real-world process is complex.
Webhooks are the most important concept—and the one most developers get wrong initially.
Stripe sends a POST request to your server when a payment event occurs. When a subscription is paid, when a payment fails, when a subscription is cancelled. Your server receives these and acts on them.
The wrong mental model: "After payment succeeds, Stripe redirects the user to my success URL. I'll handle everything there."
This is dangerous. The client cannot be trusted. Users close their browsers mid-redirect. Network connections drop. Malicious users can type success URLs directly without paying. If you activate accounts based on redirect, you'll either have paid users stuck without access (redirect failed) or free riders with access (URL manipulated).
A Webhook is like a delivery confirmation text. Ordering a package online doesn't mean it arrived at your door. The confirmation text comes when it's actually sitting on your doorstep. Stripe's Webhook fires when the payment event actually happened, confirmed server-to-server. Only then should you activate the service.
Here's the API route for creating a Checkout Session in Next.js App Router:
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
export async function POST(req: NextRequest) {
const { priceId, userId } = await req.json();
try {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: {
userId, // We'll need this in the Webhook to identify the user
},
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error('Checkout session creation failed:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}
The client calls this endpoint and redirects to session.url. Stripe shows the payment page. On completion, Stripe redirects back to success_url.
Notice the {CHECKOUT_SESSION_ID} placeholder in success_url. Stripe replaces it with the actual session ID. You can use this on the success page to show confirmation details. But—and this is critical—you must not activate the subscription based on this redirect. That happens only in the Webhook.
The Webhook handler is where the actual activation logic lives. This is also where the security matters most.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
export async function POST(req: NextRequest) {
// Must use raw text body—parsing as JSON breaks signature verification
const body = await req.text();
const headersList = await headers();
const signature = headersList.get('stripe-signature')!;
let event: Stripe.Event;
try {
// Verify the request actually came from Stripe
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
if (userId) {
await activateSubscription({ userId, customerId, subscriptionId });
}
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await deactivateSubscription({
stripeCustomerId: subscription.customer as string,
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handlePaymentFailure({
stripeCustomerId: invoice.customer as string,
});
break;
}
}
// Always return 200. If you don't, Stripe will retry the Webhook.
return NextResponse.json({ received: true });
}
Two rules I will never break:
Never skip signature verification. constructEvent() checks that the request was actually sent by Stripe using a secret only you and Stripe know. Skip this and anyone on the internet can POST fake "payment succeeded" events to your server and get free access.
Use raw body. req.text(), not req.json(). Stripe generates the signature by hashing the raw request body. Once you parse it as JSON and re-serialize, the bytes are different and the signature won't match. This caught me off guard in Next.js App Router—I spent an hour debugging it.
Testing Webhooks locally requires the Stripe CLI. It proxies Stripe's events to your local server.
# Install (macOS)
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward Webhook events to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Running stripe listen prints a Webhook signing secret. Put that in .env.local as STRIPE_WEBHOOK_SECRET.
Trigger test events manually:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
This is the best part of the Stripe developer experience. You can test every payment scenario—successful payments, failed renewals, cancellations—without touching a real card. Combined with Stripe's test card numbers (4242 4242 4242 4242 for success, 4000 0000 0000 0002 for decline), you can cover every edge case before going live.
A few things I either got wrong or almost got wrong.
Idempotency keys. Stripe supports idempotency keys on API calls. If a network error causes a retry, the same key means Stripe processes the request only once and returns the cached response. This prevents double-charges during network failures.
await stripe.checkout.sessions.create(
{ /* ... */ },
{ idempotencyKey: `checkout-${userId}-${requestTimestamp}` }
);
Webhook deduplication. Stripe can send the same Webhook event multiple times if your server doesn't return 200. Store processed event IDs and skip re-processing events you've already handled:
const processed = await db.stripeEvents.findUnique({
where: { stripeEventId: event.id },
});
if (processed) return NextResponse.json({ received: true });
// ... process the event ...
await db.stripeEvents.create({
data: { stripeEventId: event.id, processedAt: new Date() },
});
Subscription renewal failures. Users' cards expire. Banks decline charges. If invoice.payment_failed fires and you ignore it, paying subscribers lose access without warning. Build the handler: send them an email, set a grace period, downgrade after N failed attempts. Stripe lets you configure automatic retry schedules in the dashboard, but your code needs to handle the payment_failed event gracefully.
Stripe isn't the only option. Here's what I considered:
| Stripe | Paddle | Lemon Squeezy | |
|---|---|---|---|
| Fee | 2.9% + $0.30 | 5% + $0.50 | 5% + $0.50 |
| Tax handling | You build it | Automatic | Automatic |
| Global compliance | You handle | MoR model | MoR model |
| Dev flexibility | Highest | Medium | Medium |
Stripe is the most powerful and has the best developer experience. But tax is your problem. Selling globally means dealing with EU VAT, US Sales Tax by state, Australian GST, and dozens of other regimes. Getting this right is a significant engineering project in itself.
Paddle and Lemon Squeezy use a Merchant of Record model. Technically they're the seller; you're their reseller. They handle taxes, refunds, and local compliance. The fee is higher, but for a solo developer building a first SaaS, not having to think about tax law in 40 countries is worth a lot.
My honest recommendation for a first SaaS: start with Lemon Squeezy. Simpler setup, tax handled, you can start selling in hours. When you hit scale where the higher fee becomes painful, migrate to Stripe. Don't optimize the payment infrastructure on day one.
The nervousness I felt writing that first payment code turned out to be useful. It made me slower, more careful, more likely to think through failure cases. That's the right disposition for code that handles money.
The three things that mattered most: trust Webhook events, not redirects. Verify webhook signatures, always. Test every failure scenario with Stripe CLI before going live.
Stripe's documentation is excellent. Read the Webhook integration guide end-to-end before writing a line of code. The 30 minutes it takes will save hours of debugging after launch. The architecture decisions—especially "server-side verification only"—make a lot more sense once you understand the threat model.
Code that handles money is supposed to feel serious. That feeling is telling you something true.