Skip to main content
LadeStack LogoLadeStack
SaaS Architecture 10 min read July 28, 2024

Building Robust Subscription Billing Systems: Lifecycle, Webhooks, and Idempotency

A comprehensive engineering guide to designing production-ready subscription billing architectures with Stripe, robust webhook verification, idempotent state machines, and multi-tenant entitlement syncing.

Subscription Billing Systems Architecture Diagram

Introduction: The Complexity of Billing Engineering

In SaaS engineering, implementing subscription billing is often underestimated as a simple matter of dropping in a checkout widget and saving a subscription_id to a database. In reality, billing is one of the most operationally critical distributed systems an organization maintains.

A reliable billing engine must gracefully handle asynchronous payment confirmations, transient network drops, out-of-order webhook events, credit card expirations, plan upgrades with proration, and multi-tenant access entitlement checks. A single logic flaw can lead to revenue leakage, customer churn through improper access revocation, or regulatory non-compliance.

This guide outlines an architectural blueprint for building scalable, resilient subscription billing infrastructure. We explore the complete subscription state machine, secure checkout flows, cryptographically verified webhooks, idempotent transaction processing, and tenant entitlement synchronization.


1. High-Level Architecture & Separation of Concerns

Before writing code, teams must establish a strict separation between payment processing and application authorization:

[Client Browser]
      │
      │ 1. Initiate Checkout / Update Payment Method
      ▼
[SaaS Application Server] ──── 2. Create Checkout Session / Customer ───► [Stripe / Billing API]
      │                                                                           │
      │ 4. Read Entitlements (Local DB / Cache)                                   │
      ▼                                                                           │ 3. Asynchronous
[Application Database / Redis] ◄─── 5. Idempotent Webhook Handler ◄───────────────┘    Event Dispatch
(Tenants, Subscriptions, Invoices)

Core Design Rules

  1. Never Touch Raw Payment Details: Under no circumstances should raw credit card numbers (PANs) or CVVs touch your servers or databases. All payment method collection must occur via client-side tokenization (such as Stripe Elements or hosted checkout portals) to remain in the minimal PCI DSS SAQ A compliance scope.
  2. Webhooks as the Single Source of Truth: Never grant access or update persistent subscription state immediately in the HTTP return response of an API call. Network dropouts or client navigation can break the loop. Persistent database status should transition strictly via verified webhook events.
  3. Decouple Billing State from Access Entitlements: Keep your application database’s authorization rules separate from vendor-specific pricing models. Your application should query tenant.hasFeature('advanced_analytics'), not stripe_price_id === 'price_123'.

2. The Subscription Lifecycle State Machine

A subscription is a living entity that cycles through various states across its lifespan. The standard Stripe subscription model exposes the following state transitions:

                  ┌──────────────┐
                  │   trialing   │
                  └──────┬───────┘
                         │ Trial converted
                         ▼
┌──────────────┐  Payment  ┌──────────────┐  Payment  ┌──────────────┐
│  incomplete  ├──────────►│    active    │◄──────────┤   past_due   │
└──────┬───────┘  Success  └──────┬───────┘  Failed   └──────┬───────┘
       │                          │                          │
       │ Payment                  │ User Cancels             │ Max Retries
       │ Fails Permanently        │ or Downgrades            │ Exhausted
       ▼                          ▼                          ▼
┌──────────────┐           ┌──────────────┐           ┌──────────────┐
│  incomplete_ │           │   canceled   │           │    unpaid    │
│   expired    │           └──────────────┘           └──────────────┘
└──────────────┘

State Definitions and Handling Strategy

  • incomplete: Initial state when payment requires 3D Secure (3DS) authentication or SCA confirmation before first activation.
  • active: Payment succeeded; all paid entitlements and tier quotas are unlocked.
  • past_due: Renewal payment failed (e.g., card expired, insufficient funds). Do not immediately revoke access. Trigger smart dunning workflows and in-app payment method update prompts during this grace period.
  • canceled: Subscription terminated either by the user or due to non-payment. Access should typically be retained until the current billing cycle ends (cancel_at_period_end: true).
  • unpaid: Final state if smart retries fail without customer correction. Depending on policy, transition tenant to a free tier or read-only mode.

3. Secure Webhook Verification and Idempotent Ingestion

Stripe webhooks are delivered over HTTP POST. Because internet delivery is inherently unreliable, Stripe utilizes automatic retries with exponential backoff. This introduces two architectural guarantees you must build for:

  1. At-Least-Once Delivery: You may receive the exact same webhook payload multiple times.
  2. Out-of-Order Delivery: An invoice.payment_succeeded event might arrive before a customer.subscription.created event due to concurrent network paths.

Cryptographic Signature Verification

To prevent spoofing attacks, every incoming webhook must be validated against the shared endpoint secret using the Stripe-Signature header.

Here is a production-grade webhook ingestion handler built with TypeScript and Express:

import express, { Request, Response } from 'express';
import Stripe from 'stripe';
import { db } from './db';
import { processWebhookEvent } from './billingService';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  apiVersion: '2024-06-20',
});

const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET as string;

// CRITICAL: Webhook endpoints must receive the raw unparsed Buffer for signature check
export const webhookHandler = async (req: Request, res: Response): Promise<void> => {
  const sig = req.headers['stripe-signature'];

  if (!sig || typeof sig !== 'string') {
    res.status(400).send('Missing or invalid Stripe-Signature header');
    return;
  }

  let event: Stripe.Event;

  try {
    // Construct and verify event cryptographically
    event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
  } catch (err: any) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    res.status(400).send(`Webhook Error: ${err.message}`);
    return;
  }

  try {
    // Process event idempotently within a database transaction
    await processWebhookEvent(event);
    res.status(200).json({ received: true });
  } catch (err: any) {
    console.error(`Failed to process webhook ${event.id}:`, err);
    // Returning 500 triggers Stripe's retry mechanism
    res.status(500).json({ error: 'Internal processing failure' });
  }
};

4. Database Schema and Idempotency Guard

To prevent duplicate side-effects (such as creating duplicate invoices or firing multiple email receipts), implement an Idempotency Register in PostgreSQL.

-- 1. Webhook Events Audit & Idempotency Table
CREATE TABLE billing_webhook_events (
    id VARCHAR(255) PRIMARY KEY, -- Stripe Event ID (e.g. evt_1Oxxxx)
    event_type VARCHAR(100) NOT NULL,
    processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    payload JSONB NOT NULL
);

-- 2. Tenant Subscriptions Table
CREATE TABLE tenant_subscriptions (
    tenant_id UUID PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE,
    stripe_customer_id VARCHAR(255) UNIQUE NOT NULL,
    stripe_subscription_id VARCHAR(255) UNIQUE,
    stripe_price_id VARCHAR(255),
    plan_tier VARCHAR(50) NOT NULL DEFAULT 'free',
    status VARCHAR(50) NOT NULL DEFAULT 'active',
    current_period_start TIMESTAMPTZ,
    current_period_end TIMESTAMPTZ,
    cancel_at_period_end BOOLEAN DEFAULT FALSE,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Index for customer ID lookups
CREATE INDEX idx_tenant_subscriptions_customer ON tenant_subscriptions(stripe_customer_id);

Idempotent Event Processor

import { db } from './db';
import Stripe from 'stripe';

export async function processWebhookEvent(event: Stripe.Event): Promise<void> {
  const client = await db.getClient();

  try {
    await client.query('BEGIN');

    // 1. Check if event has already been recorded
    const existing = await client.query(
      'SELECT id FROM billing_webhook_events WHERE id = $1 FOR UPDATE',
      [event.id]
    );

    if (existing.rows.length > 0) {
      console.log(`Event ${event.id} already processed. Skipping.`);
      await client.query('ROLLBACK');
      return;
    }

    // 2. Insert event ID into register
    await client.query(
      'INSERT INTO billing_webhook_events (id, event_type, payload) VALUES ($1, $2, $3)',
      [event.id, event.type, JSON.stringify(event)]
    );

    // 3. Dispatch specific business logic based on event type
    switch (event.type) {
      case 'customer.subscription.created':
      case 'customer.subscription.updated': {
        const subscription = event.data.object as Stripe.Subscription;
        await syncSubscriptionState(client, subscription);
        break;
      }
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        await handleSubscriptionCanceled(client, subscription);
        break;
      }
      case 'invoice.payment_failed': {
        const invoice = event.data.object as Stripe.Invoice;
        await handleFailedPayment(client, invoice);
        break;
      }
      default:
        console.log(`Unhandled webhook event type: ${event.type}`);
    }

    await client.query('COMMIT');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

5. Synchronizing Subscription State & Multi-Tenant Entitlements

When a subscription changes, we must translate Stripe’s raw data into our domain’s tier definitions and cache the result for fast access control:

import { PoolClient } from 'pg';
import Stripe from 'stripe';
import { redisClient } from './redis';

const PRICE_TIER_MAP: Record<string, string> = {
  'price_pro_monthly': 'pro',
  'price_pro_yearly': 'pro',
  'price_enterprise_monthly': 'enterprise',
};

async function syncSubscriptionState(client: PoolClient, sub: Stripe.Subscription): Promise<void> {
  const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer.id;
  const priceId = sub.items.data[0]?.price.id;
  const tier = PRICE_TIER_MAP[priceId] || 'free';

  // Update persistent database
  const result = await client.query(
    `UPDATE tenant_subscriptions
     SET stripe_subscription_id = $1,
         stripe_price_id = $2,
         plan_tier = $3,
         status = $4,
         current_period_start = to_timestamp($5),
         current_period_end = to_timestamp($6),
         cancel_at_period_end = $7,
         updated_at = NOW()
     WHERE stripe_customer_id = $8
     RETURNING tenant_id`,
    [
      sub.id,
      priceId,
      tier,
      sub.status,
      sub.current_period_start,
      sub.current_period_end,
      sub.cancel_at_period_end,
      customerId,
    ]
  );

  if (result.rows.length === 0) {
    throw new Error(`Tenant not found for Stripe customer ${customerId}`);
  }

  const tenantId = result.rows[0].tenant_id;

  // Refresh fast Redis entitlement cache
  const cacheKey = `entitlements:tenant:${tenantId}`;
  const entitlementData = {
    tier,
    status: sub.status,
    canAccessAI: tier === 'pro' || tier === 'enterprise',
    seatLimit: tier === 'enterprise' ? 100 : tier === 'pro' ? 10 : 1,
    currentPeriodEnd: sub.current_period_end,
  };

  await redisClient.set(cacheKey, JSON.stringify(entitlementData), 'EX', 86400); // 24h TTL
}

6. Prorations, Upgrades, and In-Flight Changes

When customers upgrade or downgrade plans mid-cycle, Stripe automatically calculates proration items by default. Engineers must understand how proration behaviors affect the user experience:

  1. Immediate Billing (proration_behavior: 'always_invoice'):
    • Ideal for plan upgrades (e.g., from Starter to Pro).
    • Generates an immediate invoice charging the difference for the remainder of the billing period.
    • Entitlements unlock instantly.
  2. End-of-Cycle Proration (proration_behavior: 'create_prorations'):
    • The prorated credit or charge is credited to the customer’s upcoming invoice.
    • Avoids micro-transactions on credit cards.
  3. No Prorations (proration_behavior: 'none'):
    • Used when tier transitions take effect strictly at the start of the next billing cycle.
// Example: Upgrading a tenant's subscription with immediate proration invoicing
export async function upgradeSubscription(
  subscriptionId: string,
  newPriceId: string
): Promise<Stripe.Subscription> {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  return await stripe.subscriptions.update(subscriptionId, {
    items: [
      {
        id: subscription.items.data[0].id,
        price: newPriceId,
      },
    ],
    proration_behavior: 'always_invoice',
    payment_behavior: 'error_if_incomplete',
  });
}

7. Handling Failed Payments & Dunning Workflows

Failed renewals are unavoidable due to expired cards, bank fraud false-positives, and card reissuances. An unhandled invoice.payment_failed can lead to unintended access revocation or lost revenue:

  1. Leverage Stripe Smart Retries: Configure Stripe’s machine-learning-driven retry scheduler (Smart Retries) within the Stripe Dashboard. This retries failed cards on optimal days and times.
  2. Dunning Email Sequences: Trigger webhook listeners on invoice.payment_action_required or customer.subscription.updated to notify the team or billing owner via email with a direct link to the Customer Portal.
  3. Grace Periods: Maintain status: 'past_due' for 7 to 14 days without disabling core tenant workflows. Only downgrade or lock tenant accounts once the status changes to unpaid or canceled.

8. Common Architectural Pitfalls to Avoid

  • Storing Card Information: Never store card numbers, expiration dates, or security codes. Rely solely on tokenized PaymentMethods and Customer IDs.
  • Synchronous Fulfillment: Do not create accounts or unlock features in the return URL redirect. The user might close their laptop lid right after paying before the redirect fires.
  • Missing Raw Body in Webhook Middleware: Web framework body-parser middleware (like express.json()) mutates the raw payload stream, causing signature verification to fail. Always use express.raw({ type: 'application/json' }) for your webhook routes.
  • Ignoring Clock Drift: Ensure your server clocks are synchronized with NTP to prevent timestamp validation errors during signature checks.

A production-ready subscription billing system is primarily a state-synchronization problem across distributed boundaries. By enforcing cryptographic signature validation, storing an explicit idempotency registry, and decoupling vendor price IDs from domain entitlements, your SaaS platform will remain rock-solid as transaction volume scales.

Next Architecture Guides

More Engineering Articles