Stripe is the most capable payments API available, and also one of the easiest to get wrong. The surface area is enormous — billing, invoicing, tax, connect, radar, identity — and the documentation, while excellent, doesn't always make clear which path is appropriate for a simple recurring SaaS subscription. Founders who don't read carefully often implement Stripe incorrectly: missing webhook handling, implementing the payment flow wrong, or building billing logic that breaks when Stripe changes a subscription state they didn't handle.
This guide focuses on the Stripe integration you actually need for a standard SaaS product with monthly or annual subscriptions: Stripe Checkout for payment capture, Products and Prices for your plans, webhooks for subscription lifecycle events, and the Customer Portal for self-service billing management. Everything else in Stripe can come later — this is the foundation you need to ship.
Stripe vs. alternatives for new SaaS
For most new SaaS products, the choice is between Stripe (direct) and a merchant of record (MOR) like Lemon Squeezy. The distinction matters more than most founders realize. With Stripe, you are the merchant — you collect revenue, handle tax remittance in each jurisdiction where you have nexus, and manage chargebacks. With a merchant of record, the MOR collects revenue on your behalf, handles all tax compliance globally, and pays you after taking a higher percentage cut.
| Feature | Stripe | Lemon Squeezy (MOR) |
|---|---|---|
| Processing fee | 2.9% + 30¢ | 5% + 50¢ |
| Tax compliance | Your responsibility | Handled for you |
| Global selling | Complex (VAT registration required) | Simple (automatic) |
| Chargebacks | Your liability | MOR's liability |
| Customization | Full control | Limited |
| Best for | US-focused or large revenue | Global digital products, early stage |
The decision rule: if you're selling globally and expect less than $500k ARR in the next 12 months, start with a merchant of record. The higher per-transaction fee is worth less than the time you'll spend on tax compliance if you're collecting VAT from European customers, GST from Australian customers, and sales tax from US customers simultaneously. Once revenue justifies dedicated finance attention, migrating to direct Stripe is straightforward.
Core Stripe concepts every developer needs
Stripe's object model has several concepts that trip up developers who haven't internalized the relationships between them. A Customer is a Stripe object that represents a paying user — it holds payment methods, billing address, and subscription history. A Product represents what you're selling (e.g., 'Pro Plan'). A Price defines how it's billed — amount, currency, interval (monthly/annually), and billing model (flat rate, per seat, metered usage). A Subscription links a Customer to one or more Prices and manages the billing cycle.
- Customer: create one per user when they first add a payment method — never create multiple customers for the same user
- Product: one per plan name (Basic, Pro, Enterprise) — these don't change when pricing changes
- Price: one per billing configuration — create a new Price when you change pricing, don't modify existing Prices
- Subscription: the billing relationship — contains the current period, status (active, past_due, canceled), and upcoming invoice
- Invoice: auto-generated for each billing cycle — contains line items, taxes, and payment attempt history
- PaymentIntent: represents a single payment attempt — important for handling failed payments and retries
Stripe Checkout: the fastest path to payments
Stripe Checkout is a hosted payment page managed by Stripe. You create a Checkout Session server-side (specifying the Price ID, success URL, cancel URL, and optionally the customer ID), redirect the user to the hosted URL, and Stripe handles the rest — card form, 3DS authentication, Apple Pay, Google Pay, error handling, and accessibility. When the user completes payment, Stripe redirects to your success URL.
The key advantage of Checkout over a custom payment form: it handles card input in an iframe that never touches your server, which means you're out of PCI DSS scope for cardholder data. Building a custom card form requires implementing Stripe Elements (which also keeps you out of scope but is more complex to build), or worse, a completely custom form (which would put you in scope and require auditing). Start with Checkout — you can always replace it with a custom Elements flow later if you need more design control.
Subscription billing and lifecycle management
A Stripe subscription has several states you need to handle explicitly in your application: active (billing is current), past_due (payment failed but retrying), canceled (ended — either by you or the customer), incomplete (payment required but not yet made), trialing (in a free trial period), and paused (billing suspended). Your application needs to grant or restrict feature access based on these states. The most common mistake: only checking for 'active' and missing that users in 'trialing' or 'past_due' states should have different access levels.
Store the subscription status, current period end date, and Stripe customer ID on your user record in your database. Don't rely on querying Stripe in real time for every authorization check — that adds latency and creates a failure mode if Stripe is temporarily unreachable. Update your local record via webhooks when the subscription state changes.
Webhooks: the most important thing you'll implement
Webhooks are Stripe's mechanism for notifying your application of events that happen asynchronously — payment succeeded, subscription renewed, payment failed, subscription canceled, trial ended. If you don't implement webhooks, your database never gets updated when subscriptions change state, and you'll have users with canceled subscriptions still accessing premium features, or users who successfully paid still being blocked.
The minimum webhook events you need to handle for a subscription SaaS: customer.subscription.created (activate access), customer.subscription.updated (handle plan changes and status transitions), customer.subscription.deleted (revoke access), invoice.payment_succeeded (log payment, extend period_end), invoice.payment_failed (notify user, restrict access after grace period), and customer.subscription.trial_will_end (send reminder 3 days before trial ends). Register a webhook endpoint in the Stripe dashboard and verify the webhook signature using Stripe's SDK — never trust webhook payloads without signature verification.
Customer portal: upgrades, downgrades, cancellations
Stripe's Customer Portal is a hosted page where your users can manage their own subscriptions — updating payment methods, changing plans, canceling subscriptions, and viewing invoice history. You configure it in the Stripe dashboard (which plans are upgradeable to, whether cancellations are immediate or at period end, whether pausing is allowed), then generate a session URL server-side and redirect users to it.
The Customer Portal handles the most tedious parts of billing management for you. Without it, you'd need to build UI for card updates (which requires Stripe Elements), plan change flows (which require handling proration logic), and cancellation flows (which should include save flows to reduce churn). The Portal handles all of this. Customize it with your brand colors and logo in the Stripe dashboard — it looks good out of the box and integrates seamlessly with Stripe's Checkout aesthetic.
Usage-based and metered billing
If your product has a per-usage pricing model — charging per API call, per active user, per GB of storage, per video rendered — Stripe's metered billing records usage during a billing period and invoices at the end. You report usage to Stripe via the API during the period (either in real time or in batches), and Stripe calculates the invoice amount automatically. The alternative, building your own usage tracking and invoicing, is significantly more complex and error-prone.
Taxes: when to use Stripe Tax vs. Lemon Squeezy
Stripe Tax automatically calculates and collects sales tax, VAT, and GST based on the customer's location and your nexus configuration. You enable it on your account, set your product tax codes, and Stripe adds the correct tax to each invoice. However, Stripe Tax handles calculation and collection — you're still responsible for registering in each jurisdiction where you have nexus and filing periodic tax returns. This is the fundamental difference from a merchant of record: Stripe Tax makes compliance easier but doesn't remove your liability.
Testing your billing flows properly
Stripe provides a test mode with test card numbers that simulate different payment scenarios: 4242 4242 4242 4242 (success), 4000 0000 0000 9995 (insufficient funds), 4000 0025 0000 3155 (3DS authentication required), 4000 0000 0000 0341 (card declined after attaching). Test every scenario your webhook handler needs to process: successful payment, failed payment, retry after failure, subscription cancellation. Missing a test case here becomes a production incident.
Use the Stripe CLI for webhook testing in development. It forwards webhook events from Stripe's test mode to your local development server, which means you can test the full end-to-end flow without deploying. The CLI also has a stripe trigger command for manually firing any webhook event with realistic test data — invaluable for testing edge cases like subscription.deleted without going through the cancellation flow manually.
Common Stripe mistakes and how to avoid them
- Not verifying webhook signatures — always validate the Stripe-Signature header using the webhook secret, never process unverified payloads
- Creating multiple Stripe customers per user — causes billing history fragmentation and reporting problems
- Modifying existing Prices — create new Prices for pricing changes, archive old ones; old subscribers stay on their original Price
- Not handling idempotency — use Stripe's idempotency keys on charge and subscription creation to prevent duplicate charges
- Missing the past_due state — users with failed payments but active subscriptions should have limited access, not full access
- Hardcoding Price IDs — store them in environment variables so you can update them without code deploys
“Stripe's quality is in the details. Read the API docs for every object you're using — not just the quick start. The edge cases you skip reading about are the ones that become incidents.”
— Tama


