Back to Journal
SaaS Engineering

Subscription Billing Systems Best Practices for Startup Teams

Battle-tested best practices for Subscription Billing Systems tailored to Startup teams, including anti-patterns to avoid and a ready-to-use checklist.

Muneer Puthiya Purayil 18 min read

Startup billing systems fail in predictable ways: implementing Stripe webhooks without idempotency, hardcoding plan structures that cannot evolve, and deferring proration logic until a customer complaint forces it. These best practices cover the billing decisions that compound over time — get them right early and the system scales with you; get them wrong and you are rebuilding from scratch at Series B.

Best Practice 1: Use Stripe as Payment Rail, Not as Source of Truth

Stripe is excellent at processing payments. It is not a billing database. Store your subscription state, plan definitions, and customer billing data in your own database and sync with Stripe via webhooks.

typescript
1// Your database is authoritative — Stripe is a payment processor
2interface Subscription {
3 id: string;
4 customerId: string;
5 stripeSubscriptionId: string; // Reference to Stripe, not the source
6 planId: string;
7 status: 'active' | 'past_due' | 'cancelled' | 'trialing';
8 quantity: number; // Seats
9 currentPeriodStart: Date;
10 currentPeriodEnd: Date;
11 cancelAtPeriodEnd: boolean;
12 trialEndsAt: Date | null;
13 createdAt: Date;
14}
15 
16// Webhook handler that syncs Stripe → your database
17async function handleStripeWebhook(event: Stripe.Event): Promise<void> {
18 // Idempotency: skip already-processed events
19 const processed = await db.webhookEvent.findUnique({
20 where: { stripeEventId: event.id },
21 });
22 if (processed) return;
23 
24 switch (event.type) {
25 case 'customer.subscription.updated': {
26 const stripeSub = event.data.object as Stripe.Subscription;
27 await db.subscription.update({
28 where: { stripeSubscriptionId: stripeSub.id },
29 data: {
30 status: mapStripeStatus(stripeSub.status),
31 quantity: stripeSub.items.data[0].quantity ?? 1,
32 currentPeriodStart: new Date(stripeSub.current_period_start * 1000),
33 currentPeriodEnd: new Date(stripeSub.current_period_end * 1000),
34 cancelAtPeriodEnd: stripeSub.cancel_at_period_end,
35 },
36 });
37 break;
38 }
39 
40 case 'invoice.payment_failed': {
41 const invoice = event.data.object as Stripe.Invoice;
42 await handlePaymentFailure(invoice);
43 break;
44 }
45 
46 case 'invoice.paid': {
47 const invoice = event.data.object as Stripe.Invoice;
48 await recordPayment(invoice);
49 break;
50 }
51 }
52 
53 // Mark event as processed
54 await db.webhookEvent.create({
55 data: { stripeEventId: event.id, processedAt: new Date() },
56 });
57}
58 

Best Practice 2: Design Plans as Data, Not Code

Plan definitions should live in your database, not in application constants. This lets you create custom plans for specific customers, run pricing experiments, and grandfather existing customers on old plans — all without code changes.

typescript
1// Plans as database records, not hardcoded constants
2interface Plan {
3 id: string;
4 name: string;
5 slug: string;
6 active: boolean;
7 billingInterval: 'month' | 'year';
8 basePriceUsd: number;
9 pricePerSeatUsd: number;
10 includedSeats: number;
11 features: string[]; // Feature flags this plan enables
12 limits: PlanLimits;
13 stripePriceId: string;
14 metadata: Record<string, unknown>;
15 createdAt: Date;
16}
17 
18interface PlanLimits {
19 maxProjects: number | null; // null = unlimited
20 maxStorageGb: number;
21 maxApiCallsPerMonth: number;
22 maxTeamMembers: number | null;
23}
24 
25// Feature gating driven by plan data
26async function checkFeatureAccess(
27 userId: string,
28 feature: string,
29): Promise<boolean> {
30 const subscription = await db.subscription.findFirst({
31 where: { customerId: userId, status: 'active' },
32 include: { plan: true },
33 });
34 
35 if (!subscription) return false;
36 return subscription.plan.features.includes(feature);
37}
38 
39// Limit enforcement
40async function checkLimit(
41 userId: string,
42 resource: string,
43 currentCount: number,
44): Promise<{ allowed: boolean; limit: number | null; current: number }> {
45 const subscription = await db.subscription.findFirst({
46 where: { customerId: userId, status: 'active' },
47 include: { plan: true },
48 });
49 
50 if (!subscription) return { allowed: false, limit: 0, current: currentCount };
51 
52 const limits = subscription.plan.limits;
53 const limit = limits[`max${capitalize(resource)}` as keyof PlanLimits] as number | null;
54 
55 return {
56 allowed: limit === null || currentCount < limit,
57 limit,
58 current: currentCount,
59 };
60}
61 

Best Practice 3: Implement Webhook Processing with Idempotency and Ordering

Stripe webhooks can arrive out of order, be duplicated, or fail and retry. Your handler must be idempotent and handle out-of-order delivery gracefully.

typescript
1// Robust webhook processing pipeline
2class WebhookProcessor {
3 async process(event: Stripe.Event): Promise<void> {
4 // 1. Verify signature (done in middleware)
5 // 2. Check idempotency
6 const existing = await db.webhookEvent.findUnique({
7 where: { stripeEventId: event.id },
8 });
9 if (existing?.status === 'processed') return;
10 
11 // 3. Acquire lock for this resource to prevent concurrent processing
12 const resourceId = this.extractResourceId(event);
13 const lock = await this.acquireLock(resourceId, 30000);
14 
15 try {
16 // 4. Check ordering — skip if we have a newer event for this resource
17 const latestEvent = await db.webhookEvent.findFirst({
18 where: { resourceId, status: 'processed' },
19 orderBy: { stripeCreatedAt: 'desc' },
20 });
21 
22 if (latestEvent && latestEvent.stripeCreatedAt > new Date(event.created * 1000)) {
23 await db.webhookEvent.create({
24 data: {
25 stripeEventId: event.id,
26 resourceId,
27 eventType: event.type,
28 stripeCreatedAt: new Date(event.created * 1000),
29 status: 'skipped_stale',
30 },
31 });
32 return;
33 }
34 
35 // 5. Process the event
36 await this.handleEvent(event);
37 
38 // 6. Record successful processing
39 await db.webhookEvent.upsert({
40 where: { stripeEventId: event.id },
41 create: {
42 stripeEventId: event.id,
43 resourceId,
44 eventType: event.type,
45 stripeCreatedAt: new Date(event.created * 1000),
46 status: 'processed',
47 processedAt: new Date(),
48 },
49 update: { status: 'processed', processedAt: new Date() },
50 });
51 } finally {
52 await lock.release();
53 }
54 }
55 
56 private extractResourceId(event: Stripe.Event): string {
57 const obj = event.data.object as any;
58 return obj.subscription || obj.customer || obj.id;
59 }
60}
61 

Best Practice 4: Handle Trial-to-Paid Conversion Properly

The trial-to-paid transition is where most billing bugs live. Handle the three outcomes: successful conversion, payment failure at conversion, and trial expiration without conversion.

typescript
1async function handleTrialEnd(subscription: Subscription): Promise<void> {
2 if (subscription.status !== 'trialing') return;
3 
4 // Check if customer has a valid payment method
5 const customer = await stripe.customers.retrieve(subscription.stripeCustomerId);
6 const hasPaymentMethod = customer.invoice_settings?.default_payment_method !== null;
7 
8 if (!hasPaymentMethod) {
9 // No payment method — downgrade to free plan or restrict access
10 await db.subscription.update({
11 where: { id: subscription.id },
12 data: {
13 status: 'cancelled',
14 cancelledAt: new Date(),
15 cancelReason: 'trial_expired_no_payment',
16 },
17 });
18 
19 await sendEmail(subscription.customerId, 'trial-expired', {
20 trialDays: 14,
21 upgradeUrl: `${APP_URL}/billing/upgrade`,
22 });
23 
24 return;
25 }
26 
27 // Payment method exists — Stripe will attempt to charge automatically
28 // Handle the result in invoice.paid or invoice.payment_failed webhooks
29}
30 
31// Webhook: invoice.payment_failed for trial conversion
32async function handleTrialConversionFailure(invoice: Stripe.Invoice): Promise<void> {
33 if (!invoice.subscription) return;
34 
35 const subscription = await db.subscription.findFirst({
36 where: { stripeSubscriptionId: invoice.subscription as string },
37 });
38 
39 if (!subscription || subscription.status !== 'trialing') return;
40 
41 // Don't immediately cancel — give them a chance to fix payment
42 await db.subscription.update({
43 where: { id: subscription.id },
44 data: { status: 'past_due' },
45 });
46 
47 await sendEmail(subscription.customerId, 'trial-payment-failed', {
48 amount: invoice.amount_due / 100,
49 updatePaymentUrl: `${APP_URL}/billing/payment-method`,
50 gracePeriodDays: 7,
51 });
52}
53 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Best Practice 5: Implement Proration from Day One

When a customer upgrades, downgrades, or changes seat count mid-cycle, proration determines how much they owe or are credited. Not handling this correctly leads to billing disputes.

typescript
1interface ProrationResult {
2 creditAmount: number; // For unused time on current plan
3 chargeAmount: number; // For remaining time on new plan
4 netAmount: number; // charge - credit (negative = refund/credit)
5 effectiveDate: Date;
6 description: string;
7}
8 
9function calculateProration(
10 currentPlan: Plan,
11 newPlan: Plan,
12 currentQuantity: number,
13 newQuantity: number,
14 periodStart: Date,
15 periodEnd: Date,
16 changeDate: Date,
17): ProrationResult {
18 const totalDays = daysBetween(periodStart, periodEnd);
19 const remainingDays = daysBetween(changeDate, periodEnd);
20 const usedDays = totalDays - remainingDays;
21 
22 // Credit for unused portion of current plan
23 const currentDailyRate = (currentPlan.basePriceUsd + currentPlan.pricePerSeatUsd * currentQuantity) / totalDays;
24 const creditAmount = currentDailyRate * remainingDays;
25 
26 // Charge for remaining portion on new plan
27 const newDailyRate = (newPlan.basePriceUsd + newPlan.pricePerSeatUsd * newQuantity) / totalDays;
28 const chargeAmount = newDailyRate * remainingDays;
29 
30 return {
31 creditAmount: Math.round(creditAmount * 100) / 100,
32 chargeAmount: Math.round(chargeAmount * 100) / 100,
33 netAmount: Math.round((chargeAmount - creditAmount) * 100) / 100,
34 effectiveDate: changeDate,
35 description: `Proration: ${currentPlan.name} (${currentQuantity} seats) → ${newPlan.name} (${newQuantity} seats), ${remainingDays} days remaining`,
36 };
37}
38 

Best Practice 6: Build a Self-Service Billing Portal

Reduce support tickets by giving customers control over their billing.

typescript
1// API routes for self-service billing
2// GET /api/billing/overview
3async function getBillingOverview(userId: string) {
4 const subscription = await db.subscription.findFirst({
5 where: { customerId: userId, status: { in: ['active', 'trialing', 'past_due'] } },
6 include: { plan: true },
7 });
8 
9 const upcomingInvoice = await stripe.invoices.retrieveUpcoming({
10 customer: subscription!.stripeCustomerId,
11 });
12 
13 const invoices = await stripe.invoices.list({
14 customer: subscription!.stripeCustomerId,
15 limit: 12,
16 });
17 
18 return {
19 subscription: {
20 plan: subscription!.plan.name,
21 status: subscription!.status,
22 seats: subscription!.quantity,
23 currentPeriodEnd: subscription!.currentPeriodEnd,
24 cancelAtPeriodEnd: subscription!.cancelAtPeriodEnd,
25 },
26 upcomingInvoice: {
27 amount: upcomingInvoice.amount_due / 100,
28 dueDate: new Date(upcomingInvoice.period_end * 1000),
29 lineItems: upcomingInvoice.lines.data.map(line => ({
30 description: line.description,
31 amount: line.amount / 100,
32 })),
33 },
34 invoiceHistory: invoices.data.map(inv => ({
35 id: inv.id,
36 amount: inv.amount_paid / 100,
37 status: inv.status,
38 date: new Date(inv.created * 1000),
39 pdfUrl: inv.invoice_pdf,
40 })),
41 };
42}
43 

Anti-Patterns to Avoid

Anti-Pattern 1: Processing Webhooks Synchronously in the Request Handler

Webhook endpoints should acknowledge receipt (200 response) immediately and process events asynchronously via a queue. If your processing takes more than 5 seconds, Stripe retries the webhook, creating duplicate processing.

Anti-Pattern 2: Storing Prices in Application Code

const PRICE_PRO = 49 scattered across components breaks when you change pricing. Store prices in the database, fetch them when needed, and cache aggressively. Even better, let Stripe be the price source and read from the Stripe Price API.

Anti-Pattern 3: Not Handling Subscription Status Changes Atomically

Updating subscription status, feature flags, and usage limits in separate queries creates windows where a cancelled customer still has access. Use database transactions to update all related records atomically.

Anti-Pattern 4: Ignoring Tax Obligations

VAT (EU), GST (AU/NZ/SG), and state sales tax (US) are legal requirements, not nice-to-haves. Use Stripe Tax or a service like Avalara from the start. Retrofitting tax compliance is expensive and requires reprocessing historical invoices.

Startup Billing Checklist

  • Stripe webhook handler is idempotent with event deduplication
  • Plan definitions stored in database, not hardcoded
  • Feature gating reads from subscription plan, not environment variables
  • Trial-to-paid conversion handles payment failure gracefully
  • Proration logic for mid-cycle plan changes and seat adjustments
  • Self-service billing portal (view invoices, update payment method, change plan)
  • Dunning emails for failed payments (day 1, 3, 7, 14)
  • Subscription status changes update feature access atomically
  • Tax calculation integrated (Stripe Tax or equivalent)
  • Billing events logged for debugging and audit
  • Cancellation flow captures reason and offers retention alternatives
  • Invoice PDF generation with company details and line items
  • Webhook endpoint returns 200 immediately, processes asynchronously
  • Plan limits enforced at the API layer, not just the UI

Conclusion

The billing system you build at seed stage becomes the billing system you operate at Series A. Every shortcut — hardcoded prices, missing proration, synchronous webhook processing — creates technical debt that multiplies with customer count. A customer on a wrong invoice is a support ticket. A hundred customers on wrong invoices is a trust crisis.

Start with Stripe as the payment processor, your database as the source of truth, and idempotent webhook processing as the synchronization layer. Build plan definitions as data, implement proration from day one, and handle trial-to-paid conversion as a first-class flow rather than an afterthought. These decisions cost hours to implement correctly upfront and save weeks of emergency fixes when billing disputes start arriving.

The most impactful best practice is also the simplest: process every webhook idempotently and log every billing state change. When a customer says "I was charged twice" or "my plan didn't upgrade," the answer should take minutes to find in your audit log, not hours of Stripe dashboard spelunking.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026