← All articles

Designing scalable subscription architecture with Stripe

How to structure products, prices, and webhooks so your billing layer grows with you—without refactoring at scale.

17 min read

Stripe Billing is powerful, but without structure it becomes a tangle of one-off logic. The goal: a billing layer that scales with product changes, plan additions, and usage-based pricing—without rewrites. This article covers the patterns we use in production.

Products and prices as configuration

Store Stripe product and price IDs in your database, not in environment variables or hardcoded strings. When you add a new plan, you add a row. The application reads configuration at runtime. This keeps checkout flows generic: same code path for any product.

Create products and prices in Stripe first (via Dashboard or API), then insert the IDs into your plans table. The database is the source of truth for what you display and sell; Stripe is the source of truth for what gets charged. Keep them in sync via a one-time setup script or admin UI.

Why not hardcode? Because plans change. You add annual pricing, a new tier, or a limited-time offer. With configuration in the DB, you deploy a migration or run a script—no code deploy. The checkout component stays the same; it reads the price ID from the plan and passes it to Stripe. This pattern scales to dozens of plans without conditional logic.

sql
-- Plans table: single source of truth for what you sell
CREATE TABLE plans (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  stripe_product_id text NOT NULL UNIQUE,
  stripe_price_id text NOT NULL,
  name text NOT NULL,
  description text,
  interval text NOT NULL,  -- month, year
  amount_cents int NOT NULL,
  features jsonb,  -- for display on pricing page
  sort_order int DEFAULT 0,
  created_at timestamptz DEFAULT now()
);

-- Optional: separate prices for annual/monthly of same product
CREATE TABLE plan_prices (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  plan_id uuid REFERENCES plans(id),
  stripe_price_id text NOT NULL UNIQUE,
  interval text NOT NULL,
  amount_cents int NOT NULL
);

At checkout, fetch the plan by ID, read its stripe_price_id, and pass it to the Stripe Checkout Session or subscription create call. No conditional logic per plan.

Webhooks as the source of truth

Stripe events drive your local state. The customer portal and checkout create subscriptions; webhooks update your database. Never derive subscription status from a one-off API call during a request. That leads to race conditions and stale data—especially during trial conversions, plan changes, or payment retries.

Subscribe to these events at minimum: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.paid, invoice.payment_failed. Add customer.subscription.trial_will_end if you send trial reminder emails.

typescript
// Idempotent: duplicate webhooks are no-ops
async function handleSubscriptionUpdated(event: Stripe.Event) {
  const sub = event.data.object as Stripe.Subscription;
  const existing = await db.webhookEvents.findUnique({
    where: { id: event.id }
  });
  if (existing) return;

  await db.$transaction([
    db.webhookEvents.create({ data: { id: event.id } }),
    db.subscriptions.upsert({
      where: { stripeSubscriptionId: sub.id },
      create: mapStripeToSubscription(sub),
      update: mapStripeToSubscription(sub)
    })
  ]);
}

// Always verify webhook signature before processing
const sig = req.headers['stripe-signature'];
let event: Stripe.Event;
try {
  event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
  return res.status(400).send('Invalid signature');
}

Return 200 quickly. If processing is slow, acknowledge the webhook first and process asynchronously. Stripe retries failed webhooks (non-2xx) with exponential backoff. Log failures for debugging but never throw after you've started processing—you'll get duplicates.

Denormalize for reads, Stripe for disputes

Your database holds a denormalized view: status, current period end, plan name, maybe MRR. Optimized for dashboards, access checks, and feature gating. Stripe remains authoritative for billing disputes, refunds, invoices, and tax. When in doubt, Stripe wins.

Store enough to answer "does this user have access?" without calling Stripe. Store status, plan_id, current_period_end, cancel_at_period_end. For revenue reporting, you can aggregate from your table or sync from Stripe—choose one and stick to it. Avoid mixing sources for the same metric.

The split exists because Stripe's API is optimized for payment operations, not for high-frequency reads. Your app checks access on every request; calling Stripe each time would add latency and hit rate limits. The denormalized copy is eventually consistent—webhooks keep it in sync. For disputes or refunds, support uses Stripe Dashboard or API; your DB is for operational reads only.

sql
-- Minimal subscription view for access checks
CREATE TABLE subscriptions (
  id uuid PRIMARY KEY,
  org_id uuid NOT NULL REFERENCES orgs(id),
  stripe_subscription_id text UNIQUE NOT NULL,
  stripe_customer_id text NOT NULL,
  plan_id uuid REFERENCES plans(id),
  status text NOT NULL,  -- active, trialing, past_due, canceled, etc.
  current_period_start timestamptz NOT NULL,
  current_period_end timestamptz NOT NULL,
  cancel_at_period_end boolean DEFAULT false,
  updated_at timestamptz DEFAULT now()
);

Checkout and portal flow

Use Stripe Checkout for initial subscription and Stripe Customer Portal for upgrades, downgrades, and cancellation. Both handle payment collection and redirect back to your app. Pass client_reference_id or metadata to link the session to your user or org. Use the success/cancel URLs to show the right UI; don't rely on the redirect for critical state updates—webhooks handle that.

typescript
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  customer: stripeCustomerId,
  line_items: [{ price: plan.stripe_price_id, quantity: 1 }],
  success_url: `${baseUrl}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${baseUrl}/pricing`,
  metadata: { org_id: orgId },
  subscription_data: {
    trial_period_days: plan.trial_days ?? 0,
    metadata: { org_id: orgId }
  }
});

Mapping Stripe data to your schema

The subscription object from Stripe is nested. Extract what you need for access checks and dashboards. The subscription items array contains price IDs; look up plan_id from your plans table. Handle multi-item subscriptions (base + add-ons) by iterating items.

Stripe uses Unix timestamps (seconds); convert to Date for your DB. The subscription has expandable fields—request expand: ['data.items.data.price'] if you need price details in the webhook payload. Otherwise you'll need a separate API call to resolve price IDs to plan names. Expanding in the webhook keeps processing self-contained.

typescript
function mapStripeToSubscription(sub: Stripe.Subscription) {
  const primaryItem = sub.items.data.find(i => i.price.recurring?.interval);
  const plan = await db.plans.findFirst({
    where: { stripe_price_id: primaryItem.price.id }
  });
  return {
    stripeSubscriptionId: sub.id,
    stripeCustomerId: sub.customer as string,
    planId: plan?.id,
    status: sub.status,
    currentPeriodStart: new Date(sub.current_period_start * 1000),
    currentPeriodEnd: new Date(sub.current_period_end * 1000),
    cancelAtPeriodEnd: sub.cancel_at_period_end
  };
}

Scaling the model

Add usage-based billing by storing meter events and syncing with Stripe Billing Metering. Add annual plans by adding price rows. Add add-ons by creating additional subscription items. The architecture stays the same; configuration grows.

For usage metering: buffer events in your DB, batch-report to Stripe (they accept up to 1000 events per request), handle the resulting invoice in webhooks. For add-ons: create a separate product/price in Stripe, add a subscription item via the API. Your plans table can reference multiple price IDs (base + add-ons) if needed.

typescript
// Usage metering: report events to Stripe
await stripe.billing.meterEvents.create({
  event_name: 'api_calls',
  payload: {
    value: '100',
    stripe_customer_id: customerId
  },
  timestamp: Math.floor(Date.now() / 1000)
});

When migrating from an old billing system: create Stripe customers and subscriptions for existing users, backfill your subscriptions table from Stripe data, then switch over. Run both systems in parallel briefly if you need a rollback path. Use Stripe's subscription create API with backdate for historical accuracy if required.

Error handling and edge cases

Webhook processing can fail mid-transaction. Use database transactions: insert event ID and update subscription atomically. If the transaction fails, Stripe will retry and you'll process again. If you've already committed the event ID, the retry is a no-op.

Edge cases: subscription with no items (deleted before processing), customer deleted before subscription, duplicate subscription.created and subscription.updated for the same subscription (process both; upsert handles it). For invoice.paid with subscription: the subscription may have been updated in a separate event—order doesn't matter if you upsert.

Testing webhooks locally

Use Stripe CLI to forward webhooks to localhost: stripe listen --forward-to localhost:3000/api/webhooks. Trigger test events with stripe trigger customer.subscription.updated. Use test mode and test clock for time-based scenarios (trial end, period rollover).

The CLI gives you a temporary webhook signing secret. Use it in your local env; production uses the real secret. For CI, use Stripe's test mode and fixture events. Avoid hitting production webhooks from tests—use a separate Stripe account or mock the HTTP endpoint.

Async webhook processing

If your handler does more than a quick DB update—sending emails, calling external APIs, updating caches—return 200 immediately and process in the background. Stripe expects a response within a few seconds. Queue the event (SQS, Inngest, Trigger.dev) and process asynchronously. The queue consumer must still be idempotent: check the event ID before doing work.

typescript
// Fast path: acknowledge and queue
export async function webhookHandler(req: Request) {
  const event = verifyAndParse(req);
  await queue.add('process-stripe-event', { eventId: event.id, type: event.type });
  return new Response('OK', { status: 200 });
}

// Worker: idempotent processing
async function processEvent(eventId: string) {
  const existing = await db.webhookEvents.findUnique({ where: { id: eventId } });
  if (existing) return;
  const event = await stripe.events.retrieve(eventId);
  // ... process ...
}

Customer and org linking

Link Stripe customers to your orgs or users. Create the Stripe customer when the user signs up or when they first hit checkout. Store stripe_customer_id on the org or user record. Use it for Checkout, Portal, and webhook lookups. When a webhook arrives, you have the subscription's customer ID—look up the org, update the subscription, and you're done.

One Stripe customer per org (or per user, if you're B2C). Don't create a new customer for every checkout—you'll fragment payment history and complicate dunning. Reuse the customer ID across subscriptions, one-off payments, and invoices.