WRNexus
Back to the blog
billing 7 min read

Stripe billing best practices for B2B SaaS

Nine hard-won rules for wiring Stripe into a B2B product without painting yourself into a corner — from price IDs to proration to dunning.

Aditi Kapoor

#billing · #stripe · #engineering

We’ve built Stripe integrations for half a dozen products at this point — some greenfield, some painful migrations off a legacy payments stack. The short version is that Stripe is excellent at the payments primitives and intentionally agnostic about your billing model. The mismatch between the two is where most teams trip.

Here are the nine rules we keep coming back to.

1. Never hard-code price IDs in your application

Stripe price IDs are environment-scoped. The one you use in test mode is not the one you use in live mode. Store both in a config table keyed by your internal plan slug:

SELECT stripe_price_id
FROM   billing_plan
WHERE  slug = $1 AND environment = $2;

This single rule has saved us from at least three incidents where a stale price_… would have charged a real customer the wrong amount.

2. The product of record lives in your database, not Stripe

A Stripe Subscription is a great state machine for “is this card going to be charged next month.” It is a terrible source of truth for “does this customer have the Pro plan.” Keep a workspace_subscription row that you update from webhook events; everywhere else in your code, read from your own table.

3. Webhook handlers must be idempotent

Stripe will redeliver every event at least once. Wrap each handler in a transaction that inserts (event_id) ON CONFLICT DO NOTHING before running the business logic. We wrote up the full pattern in our Stripe webhooks post.

4. Prefer Customer Portal over building your own

The Stripe-hosted Customer Portal handles plan changes, card updates, invoice history, tax IDs, and cancellation flows. Every hour spent re-implementing this is an hour not spent on your product. Open it from your account page with a signed Portal session and let Stripe do the work.

5. Trial logic belongs in Stripe, not your app

If a customer is in a trial, the Subscription object has trial_end. Don’t store your own trial_until column. The day you offer “extend the trial by a week,” you’ll be glad the source of truth is one place.

6. Proration is a marketing decision, not a bug

When a customer upgrades mid-month, Stripe defaults to immediate proration with a charge. Some customers love this; some are confused by the unexpected receipt. Pick a default and document it on the upgrade page — silently surprising people with a $7.62 prorated charge erodes trust.

7. Dunning needs an opinion

If a card fails, Stripe will retry on a schedule (configurable in the dashboard). You still need to decide: do you suspend the workspace immediately, after grace, or never? Whatever you decide, surface it in the admin console so support engineers don’t have to guess.

8. Test mode is not staging

Stripe test mode is shared across your whole team. Treat it like a multi-tenant playground — don’t rely on the data you created last week being there today, and don’t run automated tests against it. Use the Stripe Mock for unit tests and a per-developer restricted test key for manual sanity checks.

9. Tax is its own product, not a “later” concern

Stripe Tax is the easiest path to compliant VAT / GST / sales-tax collection, but you have to enable it before you take your first international payment. Turning it on later means re-issuing invoices and reconciling with your accountant — which is almost never worth the savings of having waited.

Closing thought

The teams that ship billing without drama treat Stripe as a typed primitive layer, not a billing system. Your app owns plans, your app owns who’s on what plan, and Stripe owns the act of charging the card. Keep that boundary clean and the integration stays boring — exactly where you want it.