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.