Back to Blog

Pricing Your SaaS: Technical Considerations for Billing and Plans

Pricing Your SaaS: Technical Considerations for Billing and Plans

Pricing is a product decision, but implementing pricing is an engineering challenge that most teams underestimate. You can decide on three tiers and a free trial in an afternoon. Making that actually work — handling upgrades, downgrades, proration, failed payments, usage limits, and plan changes — takes real architectural thought.

We have built billing systems for several products at Threshline, including MindHyv and LancerSpace. Every time, the billing implementation took longer than we expected, and every time, the complexity came from edge cases we did not anticipate. Here is what we have learned.

Use Stripe Billing. Seriously.

We are not going to pretend there are many viable options here. For SaaS billing, Stripe is the answer. Paddle and LemonSqueezy handle merchant-of-record if you need that. But for a standard B2B SaaS, Stripe Billing gives you subscriptions, invoicing, proration, dunning, tax calculation, and a customer portal out of the box.

The setup is straightforward. You define your products and prices in Stripe, then use the API to create subscriptions:

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// Create a checkout session for a new subscription
async function createCheckoutSession(
  workspaceId: string,
  priceId: string,
  customerEmail: string
) {
  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    payment_method_types: ["card"],
    customer_email: customerEmail,
    line_items: [
      {
        price: priceId,
        quantity: 1,
      },
    ],
    metadata: {
      workspace_id: workspaceId,
    },
    success_url: `${process.env.APP_URL}/settings/billing?success=true`,
    cancel_url: `${process.env.APP_URL}/settings/billing?canceled=true`,
  });

  return session;
}

The key architectural decision is whether to use Stripe Checkout (hosted payment page) or Stripe Elements (embedded in your app). Our recommendation: start with Checkout. It handles PCI compliance, 3D Secure, Apple Pay, Google Pay, and dozens of edge cases you do not want to think about yet. You can switch to Elements later if you need more control over the payment flow.

Model Your Plans in Your Database

A common mistake is treating Stripe as the source of truth for what a customer can do in your app. Stripe knows about billing. Your app knows about features. You need a layer that connects the two.

We keep a plans table in our database that maps Stripe price IDs to feature flags and limits:

create table plans (
  id text primary key, -- 'free', 'starter', 'pro', 'enterprise'
  stripe_price_id_monthly text,
  stripe_price_id_annual text,
  max_seats int not null default 1,
  max_projects int not null default 3,
  max_storage_mb int not null default 500,
  features jsonb not null default '[]',
  created_at timestamptz default now()
);

create table workspace_subscriptions (
  workspace_id uuid primary key references workspaces(id),
  plan_id text references plans(id) not null default 'free',
  stripe_customer_id text,
  stripe_subscription_id text,
  status text not null default 'active', -- 'active', 'past_due', 'canceled', 'trialing'
  current_period_end timestamptz,
  cancel_at_period_end boolean default false,
  updated_at timestamptz default now()
);

The features column is a JSON array of feature flags like ["advanced_analytics", "custom_branding", "api_access", "priority_support"]. In your application code, checking whether a feature is available becomes a simple lookup:

type PlanFeature =
  | "advanced_analytics"
  | "custom_branding"
  | "api_access"
  | "priority_support"
  | "sso";

async function hasFeature(
  workspaceId: string,
  feature: PlanFeature
): Promise<boolean> {
  const { data } = await supabase
    .from("workspace_subscriptions")
    .select("plan:plans(features)")
    .eq("workspace_id", workspaceId)
    .single();

  if (!data?.plan?.features) return false;
  return data.plan.features.includes(feature);
}

async function checkLimit(
  workspaceId: string,
  resource: "seats" | "projects" | "storage_mb",
  currentUsage: number
): Promise<{ allowed: boolean; limit: number; usage: number }> {
  const { data } = await supabase
    .from("workspace_subscriptions")
    .select(`plan:plans(max_${resource})`)
    .eq("workspace_id", workspaceId)
    .single();

  const limit = data?.plan?.[`max_${resource}`] ?? 0;
  return { allowed: currentUsage < limit, limit, usage: currentUsage };
}

This separation matters because Stripe prices can change, new plans can be introduced, and promotional deals can be offered without touching your application logic. The plan table is the single source of truth for what a subscription tier includes.

Handle Webhooks Properly

Your app will not poll Stripe to check subscription status. Instead, Stripe sends webhook events when things change. This is where most billing implementations break.

The critical webhooks to handle:

import type { Stripe } from "stripe";

async function handleStripeWebhook(event: Stripe.Event) {
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      // Link the Stripe customer and subscription to the workspace
      await activateSubscription(
        session.metadata.workspace_id,
        session.customer as string,
        session.subscription as string
      );
      break;
    }

    case "customer.subscription.updated": {
      const subscription = event.data.object as Stripe.Subscription;
      // Handle plan changes, status changes, cancellations
      await syncSubscriptionStatus(subscription);
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      // Downgrade to free plan
      await downgradeToFree(subscription.id);
      break;
    }

    case "invoice.payment_failed": {
      const invoice = event.data.object as Stripe.Invoice;
      // Mark subscription as past_due, notify the customer
      await handleFailedPayment(invoice);
      break;
    }

    case "invoice.paid": {
      const invoice = event.data.object as Stripe.Invoice;
      // Clear any past_due status, update period end
      await handleSuccessfulPayment(invoice);
      break;
    }
  }
}

Three rules for webhooks that we learned the hard way:

  1. Make handlers idempotent. Stripe can send the same event multiple times. If you process checkout.session.completed twice, it should not create two subscriptions.

  2. Verify the webhook signature. Never trust raw POST data. Stripe provides a signature header that you must validate before processing.

  3. Process asynchronously when possible. Return a 200 response immediately, then process the event in a background job. Stripe will retry if your endpoint takes too long to respond, which can cause duplicate processing.

Payment billing and invoice management on a digital device

Upgrades, Downgrades, and Proration

Plan changes are where billing gets genuinely complicated. When a customer on a monthly Starter plan upgrades to Pro mid-cycle, what happens to the money they already paid?

Stripe handles proration automatically, but you need to understand what it does. By default, Stripe creates a credit for the unused portion of the old plan and charges the prorated amount of the new plan on the next invoice.

async function changePlan(
  workspaceId: string,
  newPriceId: string
) {
  const { data: sub } = await supabase
    .from("workspace_subscriptions")
    .select("stripe_subscription_id")
    .eq("workspace_id", workspaceId)
    .single();

  const subscription = await stripe.subscriptions.retrieve(
    sub.stripe_subscription_id
  );

  // Update the subscription with the new price
  await stripe.subscriptions.update(subscription.id, {
    items: [
      {
        id: subscription.items.data[0].id,
        price: newPriceId,
      },
    ],
    proration_behavior: "create_prorations",
  });
}

The proration_behavior parameter gives you three options:

  • create_prorations — Default. Credits old plan, charges new plan proportionally.
  • none — No proration. Customer pays full price of new plan on next billing cycle.
  • always_invoice — Creates and pays an invoice immediately for the prorated difference.

For upgrades, we use always_invoice because the customer wants access to new features now and expects to pay for them now. For downgrades, we use create_prorations so the customer gets credit applied to their next invoice.

Trial Periods That Actually Convert

Free trials are standard in B2B SaaS, but the implementation details matter for conversion rates.

Stripe supports two types of trials:

  1. Trials without a payment method. Lower friction to start, but worse conversion rates. The customer has to come back and enter payment details when the trial ends.

  2. Trials with a payment method up front. Higher friction, but dramatically better conversion. The subscription converts automatically.

We recommend requiring a payment method for B2B products. The people who will not enter a credit card for a trial are rarely the people who become paying customers. Here is how to set up a trial with payment method collection:

const session = await stripe.checkout.sessions.create({
  mode: "subscription",
  payment_method_types: ["card"],
  customer_email: email,
  line_items: [{ price: priceId, quantity: 1 }],
  subscription_data: {
    trial_period_days: 14,
  },
  payment_method_collection: "always",
  success_url: `${process.env.APP_URL}/onboarding`,
  cancel_url: `${process.env.APP_URL}/pricing`,
});

On the application side, a trialing subscription should have the same features as a paid one. Do not gate features during the trial — you want the customer to experience the full value so conversion feels like a no-brainer.

The one thing we do restrict during trials is usage limits. A trial workspace might get 3 seats instead of 10, or 1GB of storage instead of 50GB. This prevents abuse while still letting the customer experience the product.

We have written about designing onboarding flows that maximize trial conversion — the billing setup is just one piece of a larger activation puzzle.

Subscription plan options displayed on a pricing page

Usage-Based Billing

Some products charge based on usage rather than flat monthly fees. API calls, storage, emails sent, active contacts — if the value scales with usage, usage-based pricing can be a good fit.

Stripe supports metered billing through usage records. The pattern is:

  1. Define a metered price in Stripe (e.g., $0.01 per API call)
  2. Report usage throughout the billing period
  3. Stripe calculates the total at invoice time
// Report usage for metered billing
async function reportUsage(
  subscriptionItemId: string,
  quantity: number
) {
  await stripe.subscriptionItems.createUsageRecord(
    subscriptionItemId,
    {
      quantity,
      timestamp: Math.floor(Date.now() / 1000),
      action: "increment",
    }
  );
}

// Call this whenever a billable event occurs
// For example, when an API request is processed:
async function handleApiRequest(workspaceId: string) {
  const { data: sub } = await supabase
    .from("workspace_subscriptions")
    .select("stripe_subscription_id")
    .eq("workspace_id", workspaceId)
    .single();

  const subscription = await stripe.subscriptions.retrieve(
    sub.stripe_subscription_id
  );

  const meteredItem = subscription.items.data.find(
    (item) => item.price.recurring?.usage_type === "metered"
  );

  if (meteredItem) {
    await reportUsage(meteredItem.id, 1);
  }
}

The risk with pure usage-based billing is unpredictable revenue and unpredictable costs for the customer. A hybrid model works well: a base subscription fee plus usage charges above a certain threshold. This gives you predictable revenue and gives the customer a predictable baseline cost.

Dunning and Failed Payments

Involuntary churn from failed payments is one of the biggest revenue leaks in SaaS. Credit cards expire. Bank accounts run out of funds. Payment processors flag transactions.

Stripe’s Smart Retries handle the retry logic automatically, attempting failed charges at optimal times over a configurable period. But your app needs to respond to failures:

async function handleFailedPayment(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string;
  
  // Update local subscription status
  await supabase
    .from("workspace_subscriptions")
    .update({
      status: "past_due",
      updated_at: new Date().toISOString(),
    })
    .eq("stripe_subscription_id", subscriptionId);

  // Send a notification to the workspace admin
  await sendBillingAlert(subscriptionId, {
    type: "payment_failed",
    attemptCount: invoice.attempt_count,
    nextAttempt: invoice.next_payment_attempt,
  });
}

What should your app do when a subscription is past due? We recommend a grace period approach:

  • Days 1-3: Show a banner in the app. “Your payment failed. Please update your billing info.”
  • Days 4-7: Send email reminders. Make the banner more prominent.
  • Days 7-14: Restrict access to non-essential features. The core workflow still works.
  • Day 14+: Read-only mode. Data is preserved but the customer cannot create new content.
  • Day 30: Subscription is canceled. Data is retained for 90 days in case they resubscribe.

Never delete customer data immediately on payment failure. People come back. Making it easy to reactivate is better than losing a customer permanently.

The Customer Portal

Stripe provides a hosted customer portal where customers can update their payment method, view invoices, change plans, and cancel their subscription. Use it.

async function createPortalSession(workspaceId: string) {
  const { data: sub } = await supabase
    .from("workspace_subscriptions")
    .select("stripe_customer_id")
    .eq("workspace_id", workspaceId)
    .single();

  const session = await stripe.billingPortal.sessions.create({
    customer: sub.stripe_customer_id,
    return_url: `${process.env.APP_URL}/settings/billing`,
  });

  return session.url;
}

You can configure the portal in your Stripe Dashboard to control which actions are available. We typically enable payment method updates and invoice viewing but handle plan changes through our own UI so we can add confirmation steps and explain what changes with each tier.

SaaS dashboard showing pricing and analytics data

Testing Billing Flows

Billing code is notoriously hard to test because it involves real money and time-dependent behavior. Stripe’s test mode helps, but you need to be deliberate about what you test.

Use Stripe’s test clocks to simulate time passing. This lets you test trial expirations, renewal invoices, and dunning flows without waiting days or weeks:

// In test environments, use test clocks to simulate billing cycles
const testClock = await stripe.testHelpers.testClocks.create({
  frozen_time: Math.floor(Date.now() / 1000),
});

const customer = await stripe.customers.create({
  email: "[email protected]",
  test_clock: testClock.id,
});

// After creating a subscription, advance time to trigger renewal
await stripe.testHelpers.testClocks.advance(testClock.id, {
  frozen_time: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 32, // 32 days later
});

Test these scenarios at minimum:

  • New subscription creation
  • Successful renewal
  • Failed payment and retry
  • Upgrade mid-cycle
  • Downgrade mid-cycle
  • Trial expiration with and without payment method
  • Cancellation at period end
  • Reactivation after cancellation

We build a billing test harness for every SaaS project. It is one of those things that feels like overhead until it catches a proration bug that would have cost you real money. We discussed how this kind of forward thinking fits into broader product planning in our post on building an MVP that scales.

Keep It Simple Until Complexity Is Justified

The biggest mistake we see with SaaS billing is over-engineering it on day one. You do not need usage-based billing, per-seat pricing, add-ons, and a custom pricing calculator for your first 10 customers. Start with two or three flat-rate plans and a monthly/annual toggle.

Add complexity when customers ask for it — and when the revenue justifies the engineering investment. Every pricing dimension you add creates exponential complexity in your billing code, your marketing, and your customer support.

If you are building a SaaS product and want help getting the billing right, reach out at [email protected].