Stripe: Taking Money Without Losing Your Mind or Your Margins

My Go-To Providers  ·  Part 4 of 6
The first time I tried to wire up payments, I spent three days reading Stripe docs, built a checkout flow I was genuinely proud of, deployed it, and then watched a test payment silently fail because I’d mixed up my live and test API keys. The charge showed up in my test dashboard like everything was fine. Nothing was fine.

That was my introduction to Stripe: a tool powerful enough to handle billions of dollars in real-world commerce, pointed at a self-taught guy who didn’t fully understand the difference between a PaymentIntent and a Charge object yet. There’s a learning curve. I won’t pretend otherwise. But on the other side of it, Stripe is the one provider in my stack I’ve never seriously considered replacing, and I’ve kicked the tires on the alternatives.


What I Actually Use (And What I Skip)

Stripe’s dashboard is a city. You can get lost in it for hours. Products, prices, customers, payment links, billing portal, tax settings, radar rules, sigma analytics, financial connections, it goes deep. Early on, I made the mistake of trying to understand all of it before I shipped anything. That was the wrong move.

Here’s the surface area I actually touch on a regular basis:

  • Products and Prices – where I define what I’m selling, whether that’s a one-time purchase or a recurring subscription tier
  • Customers – Stripe’s customer objects, which I create on signup and attach payment methods to
  • Checkout Sessions – my preferred way to handle the actual payment UI, because I’m not building a custom card form and fighting PCI compliance
  • Subscriptions – the object that tracks whether someone is actively paying me or not
  • Webhooks – the nervous system that keeps my database in sync with what Stripe knows
  • The Events tab – my first stop when something breaks

What I skip: Stripe Sigma (SQL-based analytics, overkill for my scale), Stripe Connect (marketplace payouts, not relevant yet), Stripe Issuing (card issuing, definitely not relevant), and most of the Radar fraud rules beyond the defaults. The defaults are good. I’m not running a high-risk storefront. I let Stripe’s baseline fraud detection do its job and move on.


The Checkout Flow I Actually Ship

For most of my projects, the payment flow looks like this: user clicks a button, my server creates a Stripe Checkout Session, user gets redirected to Stripe’s hosted page, they pay, Stripe redirects them back to a success URL, and then a webhook fires telling my app what happened.

That last part is the one people underestimate. The redirect back to your success URL is not a confirmation that payment succeeded. It just means the user got through the flow. The webhook is the confirmation. I learned this the hard way when I briefly built a flow that provisioned access based on the redirect and then got confused when my test environment showed customers who hadn’t actually paid.

The webhook event I care most about for one-time purchases is checkout.session.completed. For subscriptions, the list gets longer:

  • customer.subscription.created – someone just subscribed
  • customer.subscription.updated – plan change, trial ending, status flip
  • customer.subscription.deleted – they cancelled or payment finally failed hard enough to terminate the sub
  • invoice.payment_succeeded – recurring payment went through, renewal confirmed
  • invoice.payment_failed – someone’s card didn’t charge, start the dunning clock

I store subscription state in my own database. I don’t query Stripe’s API on every request to check if a user is active; that’s slow and fragile. Instead, I keep a subscription_status column synced via webhooks, and my app logic reads from that. Stripe is the source of truth; my database is the cache that makes things fast.


What Tripped Me Up Early

Let me save you some of the headaches I collected in year one.

Webhook signature verification. Stripe signs every webhook payload with a secret, and you’re supposed to verify that signature before trusting the event. I knew this conceptually but skipped it in my first implementation because I was moving fast. Don’t skip it. It’s three lines of code and it’s the difference between a real billing event and someone hitting your endpoint with a fake payload. Claude Code actually caught this when I was refactoring an older project; I had it review my webhook handler and it flagged the missing verification immediately. Useful.

Idempotency. Webhooks can fire more than once. Stripe retries failed deliveries, which means your handler might see the same event twice. If your handler on invoice.payment_succeeded credits a user’s account, you do not want that running twice. I now check whether I’ve already processed a given event ID before acting on it. Simple event_id deduplication table, problem solved.

Trial periods and subscription states. When a user starts a free trial, the subscription status is trialing, not active. I had access control logic that only checked for active and couldn’t figure out why trial users were getting locked out. The full list of states I now handle: trialing, active, past_due, canceled, unpaid, and incomplete. Most of the time it’s just the first two, but the others show up eventually.

The billing portal. Stripe has a hosted customer portal that lets users manage their own subscriptions, update payment methods, cancel, switch plans. I wasted time building a custom cancellation flow before I discovered this existed. Now I just spin up a Billing Portal session server-side and redirect the user there. It handles everything and syncs back via webhooks. I probably saved two weeks of work.


Handling Failed Payments Without Losing Sleep

Subscription businesses live and die by what happens when a card declines. Stripe’s built-in dunning logic handles a lot of this; it retries failed payments on a configurable schedule, sends recovery emails, and eventually marks a subscription as canceled if it can’t collect. For most of my projects, the default settings are fine.

What I do layer on top of that:

When a subscription moves to past_due, I flag it in my database and show the user a banner in the app. Something low-friction: “Hey, your last payment didn’t go through, update your card here.” The link goes to the Stripe billing portal. I’m not writing custom payment recovery flows. Stripe already wrote them.

When a subscription hits canceled after exhausted retries, I revoke access. Not immediately when the first payment fails; that’s too aggressive. But once Stripe gives up, I give up. The webhook fires, I update the status, the app gates them out.

The thing that surprised me most about handling failed payments is how rarely it actually becomes a problem in practice. Stripe’s recovery emails do real work. Most of the time, the user updates their card before I even see the past_due flag in my dashboard. The system handles itself.


The Margin Conversation

Let’s talk about fees, because anyone running a solo operation has to care about margins. Stripe charges 2.9% + $0.30 per successful card transaction on the standard plan. For low-ticket items, that 30-cent flat fee stings. For subscriptions where the average revenue per user is in the tens or hundreds of dollars per month, it’s a rounding error.

I’ve run the numbers on alternatives. There are cheaper processors. Some of them are fine. None of them have the documentation quality, the developer experience, the webhook reliability, or the ecosystem integrations that Stripe has, and for a solo operator, that operational overhead cost is real even if it doesn’t show up on an invoice. I’d rather pay Stripe’s fees and spend zero time maintaining my billing infrastructure than save a point of margin and spend a weekend every quarter debugging edge cases.

That’s not advice. That’s just my math.


With payments handled, the next gap in the stack is access control, specifically, how I make sure the person paying is actually the person they claim to be, and how I keep strangers out of corners of my apps that aren’t meant for them. Part 5 covers how I use Supabase Auth to handle OAuth and identity without ever touching raw credentials. It’s the piece that makes the Stripe integration trustworthy.

Leave a Reply