We’ve built Stripe into three of our products, and the honest summary is that nothing exciting happened. Payments went through, renewals renewed and nobody was charged twice.
That’s how payments should be, but boring doesn’t happen by accident. The three products charge in very different ways, and each needed a different setup. This guide covers all three, then everything around them that decides whether billing stays boring: the subscription lifecycle, webhooks, edge cases and testing.
All three use Stripe’s hosted Checkout. There’s no custom payment form anywhere, which is the first decision that keeps payments boring: card details, 3D Secure, wallets and receipts are Stripe’s problem, not ours.
In this post:
- Model 1: a SaaS with fixed plans
- Model 2: a team product priced by team size
- Model 3: self-hosted software sold with license keys
- The subscription lifecycle
- Webhooks that are safe to repeat
- Edge cases worth deciding up front
- Testing without waiting a month
- Which model fits your product?
Model 1: a SaaS with fixed plans
The product: a classic SaaS. Users sign up, use a free plan, and upgrade to a paid plan for more features. Each user or organization has one subscription.
The setup: we didn’t write the billing layer ourselves. Our auth library, Better Auth, has a Stripe plugin that creates a Stripe customer at sign-up, starts Checkout sessions, opens the customer portal and receives the webhook. It handles checkout.session.completed and the customer.subscription.* events itself and keeps a subscription table up to date. Our code answers two questions: who may manage a subscription, and what each plan unlocks.
Three details made the difference.
Plans come from Stripe, not from the code. The app reads the active prices from Stripe and caches them, so changing a price or adding a plan is a Dashboard change, not a deploy:
const prices = await stripe.prices.list({ active: true, type: "recurring" });
const plans = prices.data.map((price) => ({
name: price.nickname ?? "Unnamed plan",
priceId: price.id,
}));Checkout does the paperwork. A few Checkout options cover tax and invoicing requirements for business customers:
{
allow_promotion_codes: true,
automatic_tax: { enabled: true }, // Stripe Tax calculates VAT and sales tax
tax_id_collection: { enabled: true }, // businesses can enter a VAT id
}A scheduled job double-checks Stripe. Webhooks are reliable, but not perfect: an endpoint can be down for longer than Stripe keeps retrying, or a bug can drop an event. So a job regularly finds subscriptions the database still treats as active although their period has ended, asks Stripe for their real status and fixes any that drifted. It almost never finds anything, which is the point.
Model 2: a team product priced by team size
The product: still a SaaS, but the customer is a team, not a person. The organization pays, only its owners can manage billing, and the price depends on how many people actually use the product, in bands (for example: up to 10 people, up to 25, up to 50).
The setup: the same plugin, with the organization as the subscription’s owner instead of the user, and one Stripe price per band. Each price has a lookup key like team_up_to_25_monthly, so the code refers to stable names instead of price ids, which differ between test and live mode.
The interesting part is what happens when a team grows or shrinks. Charging the difference mid-month with proration is accurate, but it creates confusing invoices full of partial charges and credits. We chose something simpler: count the team before each renewal, and change the price for the next period only.
Stripe sends invoice.upcoming a few days before every renewal (you set how many in the Dashboard under Upcoming renewal events). The plugin passes events it doesn’t handle itself to a callback, and that’s our cue to count again:
async function onUpcomingInvoice(subscriptionId: string) {
const band = bandFor(await countActiveMembers(subscriptionId));
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const item = subscription.items.data[0];
const { data: [price] } = await stripe.prices.list({
lookup_keys: [lookupKey(band)],
active: true,
});
if (item.price.id !== price.id) {
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: item.id, price: price.id }],
proration_behavior: "none", // the new price applies from the next period
});
await emailOwners(subscriptionId, band);
}
}No proration means the period already paid for stays exactly as it was: no surprise charges, no tiny refunds. If a team drops into a free band, we set cancel_at_period_end instead, so the paid period runs out normally. Either way, the owners get an email explaining the change before the renewal happens.
Model 3: self-hosted software sold with license keys
The product: not a SaaS at all. Customers install the software on their own servers, and those servers may not be able to reach the internet. The software can’t ask Stripe whether a subscription is active, so the customer needs something they can carry in: a license key.
The setup: the product itself never talks to Stripe. A small internal service sits between Stripe and the customer:
- The customer pays. Self-serve buyers use a Payment Link on the pricing page. Companies that need a quote, a purchase order or a bank transfer get a Stripe invoice instead.
- Stripe sends
invoice.paid. It fires for the first payment and every renewal, whether the customer paid by card or by bank transfer. - The service issues a key. It creates a cryptographically signed license key with the customer, the plan and an expiry date that matches the paid period, and emails it.
- The customer enters the key in the software, which verifies the signature.
Renewals follow the same path: Stripe renews the subscription, invoice.paid fires again, and the customer receives a new key for the new period.
A single event drives the whole flow. checkout.session.completed isn’t enough here, because a bank transfer for an invoice never goes through Checkout. invoice.paid covers every way of paying.
Because the service writes its own webhook handler (there’s no plugin), it also records every event it receives. That record became the admin view for billing: which payments issued a key, which were ignored, and which need a person to look at them.
The subscription lifecycle
Every subscription status needs an answer to one question: does the customer have access right now? A sensible default for a SaaS:
| Status | What it means | What the app does |
|---|---|---|
trialing | In a trial period | Full access |
active | Paid and in good standing | Full access |
incomplete | First payment failed or needs 3D Secure; the customer has 23 hours to complete it | No access yet |
incomplete_expired | The first payment was never completed | No access; a new checkout is needed |
past_due | A renewal payment failed; Stripe is retrying | Access continues, with a banner asking to update the card |
unpaid | All retries failed (if you choose this outcome) | Access revoked until the invoice is paid |
paused | A trial ended without a payment method (if you choose this outcome) | Access revoked until the subscription resumes |
canceled | The subscription has ended; this is final | Access revoked, or downgraded to the free plan |
past_due is the status that needs a real decision. Many failed renewals are expired or replaced cards, not people trying to avoid paying, so keeping access while Stripe retries is friendlier. Cutting access straight away is simpler and gets attention faster. Either is fine, as long as it’s a choice and not an accident.
Failed payments
When a renewal fails, Stripe retries it automatically. Smart Retries picks the retry times, and the recommended default is 8 tries within 2 weeks. You also choose what happens after the last retry fails: cancel the subscription, mark it unpaid, leave it past_due or pause it.
Turn on Stripe’s emails for failed payments and expiring cards in the Dashboard. They link to a page where customers can update their card, so most failed payments recover without you writing anything. Listen to invoice.payment_failed if you also want to show a message in the app.
Some failures can’t be fixed by retrying. A card reported lost or stolen returns a hard decline, and Stripe waits for a new payment method instead of retrying.
Trials
Stripe sends customer.subscription.trial_will_end three days before a trial ends by default, which is a good moment for a reminder email. If you offer trials without asking for a card, decide what happens when the trial ends without one: create an invoice anyway, cancel, or pause the subscription (trial_settings.end_behavior.missing_payment_method).
Plan changes and cancellations
Upgrades usually apply immediately with proration, because the customer wants the new features now. Downgrades usually apply at the end of the period, so the customer keeps what they paid for. Stripe’s customer portal can handle both, plus card changes, invoice history and cancellations, configured in the Dashboard without code.
Treat cancellation as “at the end of the period” by default (cancel_at_period_end). The customer keeps access until then, and you get customer.subscription.updated straight away, which is your chance to ask why they’re leaving.
Webhooks that are safe to repeat
Webhooks are where integrations quietly break. A few rules, all from Stripe’s webhook docs and all learned more pleasantly by reading than by debugging:
Verify the signature against the raw body. Stripe signs every event. If your framework parses the JSON before you verify, the signature check fails, so give the webhook route the raw request body.
Expect duplicates. Stripe can deliver the same event more than once and retries failed deliveries for up to three days in live mode. Either record the event ids you’ve handled, or write handlers that only set state (“this subscription is now active”), so running them twice changes nothing.
Don’t rely on order. Stripe doesn’t guarantee events arrive in the order they happened, and several events can share the same created timestamp. When the order matters, fetch the latest version of the object from the API instead of trusting the event payload.
Answer quickly. Return a 2xx before doing slow work, or put the event on a queue. Renewals tend to bunch up at the start of the month, and a slow handler turns that spike into timeouts and retries.
Decide which errors should be retried. A database timeout should answer 500, so Stripe tries again later. A problem a person must fix, like a price that isn’t mapped to a plan, should be logged for someone to look at and answered with 200, because three days of retries won’t fix it.
A simplified handler with all five:
app.post("/stripe/webhook", express.raw({ type: "application/json" }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"] as string, webhookSecret);
} catch {
return res.status(400).send("Bad signature");
}
if (await events.wasHandled(event.id)) return res.send("Already handled");
try {
await handle(event); // fetches fresh objects from the API where order matters
await events.markHandled(event.id);
res.send("OK");
} catch (error) {
if (error instanceof NeedsAttention) {
await events.markForReview(event.id, error.message);
return res.send("Recorded for review"); // a retry won't fix it
}
res.status(500).send("Try again"); // Stripe retries
}
});One webhook surprised us when reading the docs: if you listen to invoice.created and don’t answer it successfully, Stripe delays finalizing automatically collected invoices by up to 72 hours. Only subscribe to the events you actually handle.
Edge cases worth deciding up front
None of these happened to us often, but each needed a decision before launch, not during an angry email.
Refunds. A refund (charge.refunded) doesn’t cancel anything on its own. Decide whether a full refund also ends access, and for license keys, whether it revokes a key that’s already been issued.
Disputes. When a customer disputes a payment with their bank, the payment is reversed immediately and Stripe debits the amount plus a dispute fee from your balance. Listen to charge.dispute.created, decide whether access continues while the dispute is open, and answer it with evidence in the Dashboard. A recognizable name on card statements and an easy way to cancel help prevent them.
3D Secure on renewals. Some banks ask for authentication even on a recurring payment. Stripe sends invoice.payment_action_required, and the customer has to confirm the payment before it goes through. Stripe can email them a link to do that, which is a setting in the Dashboard.
Tax that can’t be calculated. With Stripe Tax on, an invoice can fail to finalize when Stripe doesn’t know enough about the customer’s location (invoice.finalization_failed). Checkout collects the address it needs, so this mostly affects invoices you create directly. Make sure those customers have a billing address.
Your own retries. When your code calls Stripe and the connection drops, you don’t know whether the request went through. Send an idempotency key with every POST, and a retry with the same key returns the original result instead of, say, creating a second subscription. Stripe keeps keys for at least 24 hours.
Testing without waiting a month
Billing bugs hide in renewals, retries and trials, which happen weeks after checkout. Stripe has tools to get there in minutes. All of it is described in Stripe’s billing testing guide.
Use a sandbox, separate from the one your CI uses, so automated tests don’t fill your manual testing with junk customers.
Forward webhooks locally with the Stripe CLI. stripe listen --forward-to localhost:3000/stripe/webhook sends real sandbox events to your machine, with no tunnel. stripe trigger invoice.paid creates one on demand, though triggered events contain made-up data. Real test subscriptions are more reliable for anything beyond “does my handler run”.
Move time with test clocks. A test clock lets you create a subscription and then advance time: through the end of a trial, to the next renewal, into a failed payment. Each step fires the same webhooks as the real thing. It’s the only practical way to test the renewal logic in Model 2.
Use the special test cards:
| Card | What it does |
|---|---|
4242 4242 4242 4242 | Always succeeds |
4000 0000 0000 0341 | Can be saved, but every charge fails. Combine it with a short trial to test a failed renewal |
4000 0027 6000 3184 | Requires 3D Secure |
There are also magic VAT ids for testing tax id verification (000000000 always verifies, 111111111 always fails).
Keep unit tests away from Stripe. Our automated tests create subscriptions with clearly fake ids, and background jobs like the drift check skip them. Tests stay fast and offline, and they can never touch real billing data. If you need a fake Stripe API, Stripe publishes stripe-mock.
Which model fits your product?
| Your product | Stripe setup | Event that grants access |
|---|---|---|
| SaaS with plans for users or companies | Checkout and customer portal, plans read from Stripe prices | checkout.session.completed, then customer.subscription.updated |
| Team product priced by team size or usage | One price per band, re-counted before each renewal, no proration | Same as above, plus invoice.upcoming to adjust the price |
| Software customers run on their own servers | Payment Links or invoices, license keys issued by a small service | invoice.paid |
Conclusion
Stripe doesn’t need to be the exciting part of your product. Use hosted Checkout and the customer portal, keep prices in Stripe, decide what each subscription status means for access, make webhooks safe to repeat, and test renewals with test clocks instead of waiting for them. With those decisions made up front, three very different business models ended up with the same result: payments nobody has to think about.
Further reading:
- How subscriptions work: statuses and the full lifecycle
- Receive Stripe events in your webhook endpoint: retries, ordering and signature verification
- Automate payment retries: Smart Retries and what happens after the last one
- Test your Billing integration: test clocks, test cards and trial testing
- Disputes: how chargebacks work and how to respond
Keep reading
CTO @ Wingravity and Co-Founder of Slashscore. Passionate about building tools that empower developers and teams. Have a question or want to collaborate? Reach out via our contact form.



