SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
May 25, 2026·Security·SecureStartKit Team

Stripe Billing Architecture: 6 Mechanical Diffs [2026]

The 6 mechanical differences between Stripe one-time and subscription billing, the code surface each adds, and why SecureStartKit ships dual-mode.

Summarize with AI

On this page

  • Table of contents
  • Why is the Stripe billing model an architectural decision, not a pricing decision?
  • How do Stripe webhook events differ between one-time and subscription modes?
  • What changes about the Customer object lifecycle?
  • How does Stripe Tax handle one-time vs recurring invoices?
  • What does dunning add to subscription billing?
  • How do refunds and access revocation differ?
  • What is the idempotency cost difference at scale?
  • What does this mean for your billing architecture?

On this page

  • Table of contents
  • Why is the Stripe billing model an architectural decision, not a pricing decision?
  • How do Stripe webhook events differ between one-time and subscription modes?
  • What changes about the Customer object lifecycle?
  • How does Stripe Tax handle one-time vs recurring invoices?
  • What does dunning add to subscription billing?
  • How do refunds and access revocation differ?
  • What is the idempotency cost difference at scale?
  • What does this mean for your billing architecture?

The six mechanical differences between Stripe one-time and subscription billing, in the order they show up in your codebase: the webhook event surface goes from 1 handler to 5+, the Customer object goes from optional to required, Stripe Tax shifts from once-at-checkout to per-invoice, dunning is added (Smart Retries + past_due state transitions), refunds couple to access revocation logic, and idempotency dedupe scales linearly with the customer base instead of with purchases. Each one adds code surface. Supporting both modes is roughly 2x the work of either alone.

This is the cluster 4.3 architectural deep-dive. The comparison page at /compare/one-time-vs-subscription-saas-billing is the decision framework for which mode to pick; this post is the implementation reference for the mechanics behind that decision. The 4.1 cluster pillar on Stripe payments with Server Actions covers the one-time happy path; this post extends to the dual-mode architecture and the trade-offs that come with subscriptions.

TL;DR:

  • The decision is architectural, not philosophical. Subscription billing adds at least 4 webhook handlers, a dunning state machine, ongoing customer data retention, and pro-rata refund math. The overhead is justified when value delivery is recurring; it is not when you are selling a one-shot outcome.
  • Webhook events are the clearest delta. One-time: 1 handler (checkout.session.completed). Subscription: 5+ handlers across customer.subscription.* and invoice.* events [5].
  • Customer object retention is the second delta. One-time defaults to guest customers; subscriptions require a persistent Customer object for recurring charges [1].
  • Dunning, Stripe Tax, refunds, and idempotency all scale with subscriptions. Each adds code paths that bound your engineering load to the customer base rather than to purchases.
  • SecureStartKit ships dual-mode webhook infrastructure but defaults to one-time. The architectural cost was already paid; switching to subscription mode is one line in actions/billing.ts.

Table of contents

  • Why is the Stripe billing model an architectural decision, not a pricing decision?
  • How do Stripe webhook events differ between one-time and subscription modes?
  • What changes about the Customer object lifecycle?
  • How does Stripe Tax handle one-time vs recurring invoices?
  • What does dunning add to subscription billing?
  • How do refunds and access revocation differ?
  • What is the idempotency cost difference at scale?
  • What does this mean for your billing architecture?

Why is the Stripe billing model an architectural decision, not a pricing decision?

Pricing decisions answer "what should we charge." Billing-model decisions answer "what code surface do we commit to maintaining for the life of the product." The two get conflated because they share the word "billing", but they are different categories of work. Pricing is a marketing decision; the billing model is an engineering decision that compounds over years.

The reason it compounds is that each mechanical difference between one-time and subscription billing adds a code path that your app has to keep alive. Webhook handlers do not get simpler over time. Dunning policies need tuning as your audience changes. Stripe Tax rules shift as regulations change. Refund logic has to handle every edge case that emerges across thousands of customer interactions. A subscription billing implementation that worked for the first 100 customers will need rework for the first 10,000; a one-time billing implementation that worked for the first 100 will mostly still work for the first 10,000.

The right way to make this decision is to start from the value you deliver. If the customer adopts your product once and the value is in the foundation (a template, a calculator, a course, a lifetime tool), one-time aligns price with value. If the customer derives ongoing value from continuous service (hosting, monitoring, content delivery, support), subscription captures the recurring value and funds the recurring cost. The 6 mechanical differences below are the code-side consequences of that choice.

How do Stripe webhook events differ between one-time and subscription modes?

The event surface is the clearest delta. Both modes fire checkout.session.completed when the customer completes the Checkout Session. Subscriptions ALSO fire the lifecycle event stream over the customer's lifetime [5].

The minimum viable handler for one-time:

// app/api/webhooks/stripe/route.ts (one-time only)
switch (event.type) {
  case 'checkout.session.completed': {
    const session = event.data.object as Stripe.Checkout.Session
    if (session.mode === 'payment') {
      await admin.from('purchases').insert({
        id: (session.payment_intent as string) || session.id,
        user_id: session.metadata?.user_id ?? '',
        amount: session.amount_total || 0,
        status: 'completed',
      })
      // Trigger delivery email
    }
    break
  }
}

The minimum viable handler for subscription:

// app/api/webhooks/stripe/route.ts (subscription, condensed)
switch (event.type) {
  case 'checkout.session.completed': {
    // Often a no-op for subscriptions; the subscription.created event
    // is the authoritative provisioning trigger.
    break
  }

  case 'customer.subscription.created':
  case 'customer.subscription.updated': {
    const sub = event.data.object as Stripe.Subscription
    await admin.from('subscriptions').upsert({
      id: sub.id,
      user_id: /* lookup by sub.customer */,
      status: sub.status,
      price_id: sub.items.data[0]?.price.id,
      current_period_start: new Date(sub.current_period_start * 1000).toISOString(),
      current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
      cancel_at_period_end: sub.cancel_at_period_end,
    })
    break
  }

  case 'customer.subscription.deleted': {
    const sub = event.data.object as Stripe.Subscription
    await admin.from('subscriptions').update({ status: 'canceled' }).eq('id', sub.id)
    break
  }

  case 'invoice.paid': {
    // Successful renewal; extend access if you gate by current_period_end
    break
  }

  case 'invoice.payment_failed': {
    // Dunning trigger; notify the customer, watch for past_due transition
    break
  }
}

Five cases vs one. Each case has its own error handling, idempotency check, and downstream side effects (email notifications, access updates, audit logging). The line count is misleading; the case count is the real cost.

For dual-mode (what SecureStartKit ships), the handler combines both, with session.mode === 'payment' as the discriminator on the shared checkout.session.completed event. The webhook signature verification is identical for either mode; see Stripe webhook signature verification in Next.js for the security mechanics that apply equally to both.

What changes about the Customer object lifecycle?

One-time payments default to guest customers. Per Stripe's documentation: "Checkout Sessions that don't use existing customers or create new ones are associated with guest customers instead." [1] You can pass a customer parameter to attach the session to a known Customer object, but it is optional.

Subscriptions are different. Subscriptions explicitly require a Customer object because the recurring billing engine needs a stable identity to charge against. Per Stripe: "Unlike one-time purchases, subscriptions require you to store additional customer information for future charges." [2] The Customer object holds the default payment method, the billing address, the tax registration, and the customer's email for receipts.

The architectural impact:

  • PII surface. Subscription apps store email, name, billing address, and payment method on file at minimum. One-time apps can ship with email-and-amount as the only customer data, which means less to encrypt, audit, and rotate when keys leak. See exposed API keys for the broader leak-surface frame.
  • Account linking. Subscription apps need a customers table mapping app users to Stripe customer IDs. SecureStartKit ships one (customers.stripe_customer_id); the table exists for both modes but is populated lazily in the one-time flow and eagerly in the subscription flow.
  • GDPR and deletion flows. Customer-object deletion under right-to-erasure requests is harder when you have ongoing subscriptions because revenue recognition, refund obligations, and access revocation all couple to the deletion. One-time apps can usually delete the Stripe customer record after the chargeback window closes.

How does Stripe Tax handle one-time vs recurring invoices?

For one-time payments, Stripe Tax computes tax once at the moment of checkout based on the customer's address. The tax rate is locked in at the checkout moment; refunds reverse the tax automatically. Done.

For subscriptions, tax is computed on every recurring invoice using the customer's current address at the time of the invoice. The compounding effects:

  • Address changes flow into recurring tax. A customer who moves from one VAT jurisdiction to another mid-subscription gets different tax on their next invoice. Your app needs to keep customer addresses current and trigger Stripe Tax recomputation when they change.
  • Tax registration thresholds matter per jurisdiction. A US-based SaaS that crosses the South Dakota economic nexus threshold needs to register and start collecting in that state. Stripe Tax flags this, but acting on it is your responsibility. One-time apps cross thresholds more slowly because cash arrives in batches; subscription apps cross thresholds smoothly with the recurring revenue.
  • Tax-ID validation runs per renewal. B2B customers with VAT IDs need their IDs revalidated periodically. Stripe Tax handles the call to VIES (EU) or equivalent, but your app needs to handle the case where a previously-valid ID becomes invalid mid-subscription.

For the broader Stripe security frame that applies to either model (webhook signatures, idempotency, raw-body handling in Next.js App Router), see Stripe payments with Server Actions.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

What does dunning add to subscription billing?

Dunning is the automated retry of failed payments. It only exists for subscriptions because only subscriptions have recurring charges that can fail mid-relationship.

When a subscription payment fails, Stripe transitions through several states [4]:

  1. incomplete: the initial subscription was created but the first payment did not succeed. The customer has about 23 hours to retry before the subscription becomes incomplete_expired and the invoice voids.
  2. past_due: a renewal payment failed. Stripe fires invoice.payment_failed, sets subscription.status = 'past_due', and re-attempts payment using Smart Retries (machine-learned timing) or your custom retry rules.
  3. canceled / unpaid / past_due: after exhausted retries, the subscription moves to one of three terminal-ish states based on your dashboard configuration. canceled is the cleanest; unpaid keeps the customer record but stops creating invoices; past_due is the most forgiving and is rarely what you want long-term.

The architectural cost of dunning:

  • A state machine. Your subscriptions table needs to model these state transitions correctly. A past_due subscription might still need to grant access for a grace period or might need access revoked immediately, depending on your business policy.
  • Customer notification logic. Stripe sends emails by default if you enable them in the dashboard, but they are generic. Most SaaS apps build their own notification flow that ties to the app's brand and includes context (which payment method failed, when the next retry is, what to do).
  • Access-grant tied to renewal. Your access-gating logic needs to read the subscription state, not just whether the user has an active subscription record. A past_due user might still have feature access; a canceled user should not.

One-time billing has none of this. A failed checkout is just a failed checkout. The customer either re-initiates or does not.

How do refunds and access revocation differ?

For one-time payments, refunds are straightforward: refund the payment in Stripe, optionally revoke access. The refund is a single Stripe API call. Access revocation is a single update to the user's purchases row or an entitlement flag. Most one-time apps choose to leave access intact (the customer paid, used the product, got value, asked for a refund; the cost of revoking access often exceeds the cost of letting them keep it).

For subscriptions, refunds and access revocation are coupled by the billing-period structure:

  • Cancel at period end vs cancel immediately. If you cancel mid-period, you can either prorate a refund for the unused time or let the customer keep access until the period ends. Either way, you have to decide and implement.
  • Pro-rata math. A customer who paid for an annual subscription and cancels in month 3 gets a refund for 9 months. The refund amount depends on the price, the cancellation date, and the proration policy (do you refund the full unused portion, or apply a 30-day window, or only refund if the customer has not actively used the product).
  • Access state transitions. The subscriptions.status transitions to canceled immediately or at period end. Your access-gating code reads from this, not from a separate "active" flag, because the subscription state is the single source of truth.

The dual-mode handler in SecureStartKit (app/api/webhooks/stripe/route.ts) handles refunds for both modes via the charge.dispute.created event (for chargebacks) but leaves the refund-initiated flow to manual dashboard action because the policy decision is per-business.

What is the idempotency cost difference at scale?

Stripe webhooks deliver at-least-once: every event might be delivered multiple times due to network issues, retries, or replay. Idempotency dedupe in your handler is mandatory regardless of billing mode. The difference is volume.

Per Stripe's idempotency policy [3], idempotency keys are cached for 24 hours by default. For your handler's purposes, you cannot rely on Stripe-side idempotency because the cache window does not match webhook retry behavior, which extends to 3 days for live events. The handler needs its own dedupe.

The pattern that works for both modes:

CREATE TABLE public.processed_events (
  id text PRIMARY KEY,           -- Stripe event.id
  type text NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now()
);
// Before processing any event
const { error: dedupError } = await admin
  .from('processed_events')
  .insert({ id: event.id, type: event.type })

if (dedupError?.code === '23505') {
  // Unique constraint violation, already processed
  return NextResponse.json({ received: true })
}

// Now safe to process

The volume difference between modes:

  • One-time: one event per purchase. A SaaS with 1,000 lifetime customers generates ~1,000 events.
  • Subscription: 12+ events per customer per year (one per renewal cycle, plus subscription state changes, plus any failed-payment retries). A SaaS with 1,000 active subscribers generates ~12,000+ events per year, plus whatever lifecycle churn adds.

The dedupe table grows linearly with event volume. For one-time apps, this is bounded by customer acquisition. For subscription apps, this scales with the active customer base. The architectural implication: subscription apps need either a TTL on the dedupe table (delete events older than 7 days, since Stripe will not retry beyond that) or a different dedupe strategy as the customer base grows.

For the full debugging-first deep-dive on webhook signature failures and idempotency at scale, see Stripe webhook signature verification in Next.js.

What does this mean for your billing architecture?

Three commitments, all of which the decision triggers:

  • Pick one mode and ship it well. Most indie SaaS apps should not support both. The architectural cost is real, and the customer confusion of "wait, is this a subscription or a one-time" is worse than the lost edge case where one customer would have preferred the other model.
  • Make the choice match the value you deliver. One-time for discrete-outcome SaaS (templates, courses, calculators, one-time tools). Subscription for ongoing-value SaaS (hosting, monitoring, content delivery, continuous service). The 6 mechanics above are the code-side consequences; the value-delivery shape determines which set of consequences you sign up for.
  • Build dual-mode infrastructure only if you have a clear reason. SecureStartKit ships dual-mode webhook handlers and a subscriptions table because the architectural cost was already paid when wiring the schema. The default checkout flow is mode: 'payment' (one-time), and switching modes is one line in actions/billing.ts. We document the choice openly: one-time fits a template product; subscription would not.

For the side-by-side decision framework grounded in the same 6 mechanics, see the comparison page at /compare/one-time-vs-subscription-saas-billing. For the broader architectural posture that makes both modes safe to ship (backend-only access, RLS deny-all, server-only secrets), see backend-only data access. To work out the pricing side of either model before committing, the SaaS pricing calculator covers the cost-vs-revenue math; the Stripe fee calculator handles the per-transaction net.

The billing model decision is not philosophical. It is architectural, it is permanent (or at least expensive to reverse), and the 6 mechanics above are the substrate on which everything else gets built.

Frequently Asked Questions

What is the architectural difference between Stripe one-time and subscription billing?
The architectural difference is the number of distinct code surfaces your app commits to maintaining. One-time billing needs one webhook handler (`checkout.session.completed`), one access-grant code path, and no lifecycle state machine. Subscription billing needs five or more webhook handlers (`customer.subscription.created/updated/deleted`, `invoice.paid`, `invoice.payment_failed`), an access state machine that maps subscription status to entitlements, a dunning policy for failed payments, pro-rata refund math, and ongoing Customer object retention. Supporting both roughly doubles the Stripe surface area; pick one and own the trade.
Why does Stripe require a Customer object for subscriptions but not for one-time payments?
Subscriptions need persistent payment method storage for recurring charges, which requires a Customer object to attach the payment method to. Per Stripe's documentation, one-time payments default to guest customers: "Checkout Sessions that don't use existing customers or create new ones are associated with guest customers instead." Subscriptions explicitly require Customer objects because the recurring billing engine needs a stable identity to bill against. For one-time SaaS that delivers a discrete outcome, skipping the Customer object reduces the PII surface area your app has to protect.
How does the Stripe webhook handler differ between one-time and subscription billing?
A one-time handler implements one switch case: `checkout.session.completed`. It reads the payment intent ID, inserts a row into a `purchases` table, and triggers the delivery flow (email, access grant). A subscription handler implements at least five cases: `customer.subscription.created/updated/deleted` for lifecycle state changes, `invoice.paid` for successful renewals, and `invoice.payment_failed` for dunning. Each case updates a `subscriptions` table with status, current period boundaries, and cancellation state. The dual-mode handler combines both, distinguishing one-time from subscription events by checking `session.mode === 'payment'` on the checkout.session.completed event.
What is dunning and why does it only exist for subscription billing?
Dunning is the automated retry of failed payments. It only exists for subscriptions because only subscriptions have recurring charges that can fail mid-relationship. When a subscription payment fails, Stripe sets the subscription to `past_due`, fires `invoice.payment_failed`, and re-attempts payment using Smart Retries (machine-learned retry timing) or your custom retry rules. After exhausted retries, the subscription transitions to `canceled`, `unpaid`, or stays `past_due` based on your dashboard configuration. One-time payments have no built-in retry: a failed checkout is just a failed checkout, and the customer must re-initiate.
Should I support both Stripe one-time and subscription billing in the same SaaS app?
Only if you have a clear reason. Supporting both roughly doubles the Stripe surface area: two webhook event distributions to handle, two Customer object patterns (guest vs persistent), two refund flows, two access-revocation patterns, and two access-grant patterns. For indie SaaS founders shipping the first version, pick the model that matches the value you deliver and own that single path well. SecureStartKit ships dual-mode webhook infrastructure (because the architectural cost was already paid when wiring the schema) but defaults the checkout flow to one-time only; switching modes is a one-line change in `actions/billing.ts`.
How does Stripe Tax handle one-time vs subscription billing differently?
For one-time payments, Stripe Tax computes tax once at the moment of checkout based on the customer's address. For subscriptions, tax is computed on every recurring invoice using the customer's current address at the time of the invoice. The architectural impact is that subscription apps need ongoing tax-ID validation per market, address-change handling that flows into recurring tax calculations, and per-jurisdiction threshold monitoring. One-time apps settle tax at the checkout moment and never revisit it.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Stripe Checkout, How Checkout Works— docs.stripe.com
  2. Subscriptions Overview, Stripe Documentation— docs.stripe.com
  3. Idempotent Requests, Stripe API Reference— docs.stripe.com
  4. Subscription Smart Retries and Dunning, Stripe Documentation— docs.stripe.com
  5. Webhook Event Types, Stripe API Reference— docs.stripe.com

Related Posts

May 19, 2026·Security

Stripe Webhook Signature in Next.js: 5 Failure Modes [2026]

Stripe webhook signature failing in Next.js? 5 causes: parsed body, JSON re-stringify, timestamp drift, wrong secret, missing idempotency.

May 12, 2026·Security

The Secure SaaS Launch Checklist: 7 Non-Negotiables [2026]

Seven security checks every solo dev must verify before going live: auth, RLS, Zod, webhooks, headers, secrets, error handling. The pre-launch audit.

Feb 22, 2026·Tutorial

Add Stripe Payments to Next.js with Server Actions

Production-ready Stripe one-time payments in Next.js 16 with Server Actions, Zod, signed webhooks, idempotency via event ID, and delivery email.