Skip to content

Backend

Stripe Subscriptions for SaaS — The Billing Code You'll Actually Ship

Stripe Subscriptions handles 95% of recurring-billing complexity. Here's what actually ships: subscription creation, plan changes with proration, dunning, self-service portal, and cancellation workflows.

💳 📊 ♻️

Stripe Subscriptions is the canonical answer for SaaS recurring billing. You create a customer, attach a subscription to a plan, Stripe charges them monthly, and handles retries when a card fails. Unlike Payment Links (one-time), Subscriptions are stateful — the subscription object lives in Stripe, and you listen for webhooks to sync your database. Per-seat pricing, plan upgrades with prorated charges, dunning (retry logic for failed payments), and customer-facing cancellation flows are all built-in. Most SaaS founders waste weeks building custom renewal logic or integrating a separate billing platform. Stripe Subscriptions is already there. Here's the implementation pattern: subscription creation via a Netlify function, plan upgrade/downgrade with immediate or end-of-period proration, dunning configuration, a Supabase-backed customer portal for self-service, and cancel-survey integration so you capture why customers left.

Why Stripe Subscriptions, Not Roll-Your-Own

The first instinct is to build a cron job: every night, query users with renewal_date in the past, charge them via stripe.charges.create(), and update their last_charged_at timestamp. This breaks within weeks. Card declines happen — Stripe retries automatically; your cron doesn't know about retries and charges them again. Plan changes mid-billing-period require proration calculations. Customers want to downgrade mid-month and expect a credit, not a dispute. Tax handling varies by region. Stripe Subscriptions is battle-tested across millions of SaaS companies because it answers all of this without custom code.

Stripe Subscriptions creates a "subscription" object that holds the customer, the plan, the billing cycle, and renewal state. Stripe owns the retry logic, prorations, and tax calculations. Your job is: create the subscription when the user signs up, listen for webhook events to sync your database, and give customers a portal to manage their plan. That's it.

Subscription Creation: The First Charge

User signs up, chooses a plan, and lands on a Stripe checkout. Create a subscription at checkout time, not after payment. Here's a Netlify function that creates a subscription and returns a checkout URL.

// netlify/functions/create-subscription.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_KEY!
);

export default async (event: any) => {
  const { userId, email, priceId } = JSON.parse(event.body);

  try {
    // Create or retrieve Stripe customer
    const { data: user } = await supabase
      .from('users')
      .select('stripe_customer_id')
      .eq('id', userId)
      .single();

    let customerId = user?.stripe_customer_id;

    if (!customerId) {
      const customer = await stripe.customers.create({
        email,
        metadata: { user_id: userId }
      });
      customerId = customer.id;

      // Store customer ID in Supabase
      await supabase
        .from('users')
        .update({ stripe_customer_id: customerId })
        .eq('id', userId);
    }

    // Create subscription with checkout
    const session = await stripe.checkout.sessions.create({
      customer: customerId,
      line_items: [{ price: priceId, quantity: 1 }],
      mode: 'subscription',
      success_url: `${process.env.SITE_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.SITE_URL}/pricing`,
      subscription_data: {
        metadata: { user_id: userId }
      }
    });

    return {
      statusCode: 200,
      body: JSON.stringify({ url: session.url })
    };
  } catch (err: any) {
    console.error('Subscription creation failed:', err.message);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: err.message })
    };
  }
};

Key details: you create the customer once and store the stripe_customer_id in your users table. On subscription creation, you pass the same customer to the checkout session. The subscription lives in Stripe with metadata linking it back to your user. When the checkout succeeds, Stripe fires a customer.subscription.created webhook; your handler inserts the subscription ID into your database.

Plan Changes: Upgrade, Downgrade, Proration

User is on the "Starter" plan ($29/mo) and upgrades to "Pro" ($79/mo) on day 15 of their billing cycle. They should pay a prorated charge today, and the next renewal is for the full Pro price 30 days from now. Stripe handles this automatically if you use stripe.subscriptions.update().

// netlify/functions/upgrade-plan.ts
import Stripe from 'stripe';

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

export default async (event: any) => {
  const { subscriptionId, newPriceId } = JSON.parse(event.body);

  try {
    // Retrieve current subscription to get the customer
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);

    // Update the subscription with the new price
    const updated = await stripe.subscriptions.update(subscriptionId, {
      items: [
        {
          id: subscription.items.data[0].id,
          price: newPriceId
        }
      ],
      proration_behavior: 'create_prorations' // Stripe calculates the credit/charge
    });

    console.log('Subscription upgraded:', updated.id);
    return {
      statusCode: 200,
      body: JSON.stringify({ subscription: updated })
    };
  } catch (err: any) {
    console.error('Plan change failed:', err.message);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: err.message })
    };
  }
};

The proration_behavior: 'create_prorations' flag tells Stripe to automatically calculate the difference between the old plan and new plan for the remaining days in the billing cycle. If upgrading results in a charge, Stripe applies it immediately and sends an invoice. If it's a credit (downgrade), it carries to the next invoice. This pattern handles mid-cycle changes without you writing a single proration calculation.

Dunning: Failed Payments and Retries

A customer's card expires, the charge fails, and Stripe automatically retries according to a dunning schedule. By default, Stripe retries 3 times over 3 days. You configure this in the Stripe dashboard, but the key webhook is invoice.payment_failed — fire this when a payment attempt fails after all retries.

// In your webhook handler (webhook-stripe.ts from before)
if (stripeEvent.type === 'invoice.payment_failed') {
  const invoice = stripeEvent.data.object;

  // Log the failure and alert the user
  await supabase
    .from('payment_failures')
    .insert({
      user_email: invoice.customer_email,
      subscription_id: invoice.subscription,
      invoice_id: invoice.id,
      reason: invoice.last_payment_error?.message,
      failed_at: new Date().toISOString()
    });

  // Send notification to user (e.g., via email or in-app)
  console.log(`Payment failed for ${invoice.customer_email}`);

  return {
    statusCode: 200,
    body: JSON.stringify({ received: true })
  };
}

Stripe's dunning logic runs automatically — you don't need to code retries. Your job is to listen for invoice.payment_failed, log it, notify the user, and give them a path to update their payment method. More on this below.

Customer Portal: Self-Service Plan and Payment Management

Instead of building a custom settings page, Stripe offers a customer portal — a white-labelled, hosted page where customers can update their card, change plans, or cancel. Create a session and redirect the user.

// netlify/functions/create-portal-session.ts
import Stripe from 'stripe';

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

export default async (event: any) => {
  const { stripeCustomerId } = JSON.parse(event.body);

  try {
    const session = await stripe.billingPortal.sessions.create({
      customer: stripeCustomerId,
      return_url: `${process.env.SITE_URL}/dashboard`,
      configuration: `${process.env.STRIPE_PORTAL_CONFIG_ID}` // optional: whitelist features
    });

    return {
      statusCode: 200,
      body: JSON.stringify({ url: session.url })
    };
  } catch (err: any) {
    return {
      statusCode: 500,
      body: JSON.stringify({ error: err.message })
    };
  }
};

The portal is configured in the Stripe dashboard. You can control which features are available (plan changes, payment methods, invoices, subscriptions, etc.). Customers click "Manage Billing" in your app, hit this endpoint, and are redirected to Stripe's portal. They update their card or cancel their subscription. Stripe sends webhooks for every change; you sync your database and return the customer to your app. No custom forms, no PCI compliance headaches.

Cancellation and Exit Surveys

When a customer cancels, capture why. Create a lightweight survey page before redirecting to the Stripe portal.

// src/pages/cancel-survey.astro
---
import Layout from '../layouts/Layout.astro';

export const prerender = false; // server-rendered for dynamic redirect

const { subscriptionId } = Astro.url.searchParams;
---


  

One quick question before you cancel

You'll be redirected to complete cancellation.

On submit, log the reason to your database, then redirect to the Stripe portal for final cancellation. This data is gold — "too expensive" tells you pricing is wrong; "switching" tells you the product isn't differentiated; "not using" tells you onboarding failed. Over 3 months, you'll have a roadmap from your customers' exit data.

Schema: Tracking Subscriptions in Supabase

Your subscriptions table should track the core state: subscription ID, customer ID, price ID, status, and renewal dates.

create table subscriptions (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references users(id) on delete cascade,
  stripe_subscription_id text unique not null,
  stripe_customer_id text not null,
  price_id text not null,
  status text default 'active', -- active, past_due, unpaid, canceled
  current_period_start timestamp,
  current_period_end timestamp,
  cancel_at timestamp,
  canceled_at timestamp,
  cancellation_reason text,
  created_at timestamp default now(),
  updated_at timestamp default now()
);

create index idx_subscriptions_user_id on subscriptions(user_id);
create index idx_subscriptions_status on subscriptions(status);

Your webhook handler (customer.subscription.updated, customer.subscription.deleted) updates these fields. Query by status to find active subscribers, segment by price_id to track plan distribution, and use canceled_at with cancellation_reason to analyze churn.

Six FAQs

What happens if a customer cancels mid-cycle?

Stripe immediately stops future renewals and sets the subscription status to canceled. The current billing period continues until current_period_end. Depending on your business rules, you can either revoke access immediately (cancel surveys often say "I'm not using this anymore," so revoking immediately is fine) or grant access through the end of the billing period. Log the cancellation reason and set canceled_at in your database. Use this data to improve onboarding and positioning.

Can I offer a free trial on subscriptions?

Yes. When creating the subscription, pass trial_period_days: 14. Stripe won't charge until the trial ends. At trial end, the subscription moves to "active" and Stripe sends an invoice.payment_succeeded webhook. You can also set a trial_end timestamp if you need asymmetric trial windows (e.g., sign up on the 30th, trial ends on the 14th of next month).

What if I want annual vs. monthly pricing?

Create separate price objects in Stripe (one for monthly, one for annual). When the user upgrades from monthly to annual, use the subscription update pattern above but with billing_cycle_anchor: 'now' to reset the billing cycle to today. Stripe prorates the difference. Annual pricing is powerful for SaaS — customers commit longer, churn drops, and cash flow improves.

Can I charge per-seat or usage-based?

Stripe has two subscription types: standard (fixed monthly price) and usage-based (metered). For per-seat, you'd create multiple subscription items or update the quantity of a single item. For usage-based (e.g., $0.10 per API call), use Stripe's metering API — you report usage throughout the month, and Stripe bills at month-end. Both are more complex than fixed pricing but supported end-to-end.

What if a payment succeeds but I need to refund it?

Query the invoice via the Stripe API, find the invoice ID, and create a refund via stripe.refunds.create({ charge_id }). The refund appears on the customer's card statement. If you refund after the invoice was paid, the credit carries to the next invoice. Log the refund to your database so your accounting knows. Large refunds (>10% of monthly revenue) warrant a customer conversation — they might cancel anyway.

How do I debug a subscription that's stuck in past_due?

Query the subscription via stripe.subscriptions.retrieve(subId). Check the latest_invoice field — this is the failed invoice. Retrieve it with stripe.invoices.retrieve(invoiceId). Look at last_payment_error to see why it failed (card declined, expired, insufficient funds, etc.). Ask the customer to update their payment method via the customer portal. Once they do, Stripe retries the invoice automatically. If it succeeds, the subscription moves back to "active" and sends a webhook. If you want to manually retry, use stripe.invoices.pay(invoiceId).

The Bottom Line

Stripe Subscriptions is the standard for a reason: it handles state, retries, prorations, tax, and customer self-service all without custom code. You create subscriptions via Netlify functions, listen to webhooks in your database, give customers a portal to manage their plan, and log cancellation data to drive product decisions. The temptation to "save a few dollars" by rolling your own billing is the fastest way to ship a broken subscription flow that charges customers twice or never retries failed payments. Stripe Subscriptions + dunning + portal + exit surveys is the implementation that actually ships. See webhook signature verification for the pattern that glues subscriptions to your database, or check SaaS pricing implementation to talk through architecture fit for your product. Ready to ship recurring revenue? Let's build it.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.