SaaS Architecture

SaaS Subscription Lifecycle: Stripe vs FastSpring Cancel, Resume, and Plan Upgrades on a Single Persistent Subscription ID

SaaS Subscription Lifecycle: Stripe vs FastSpring Cancel, Resume, and Plan Upgrades on a Single Persistent Subscription ID

When engineering recurring billing for a software-as-a-service (SaaS) platform, initial checkout flows receive the most attention. However, real-world subscription architectures are defined by what happens after the first payment: plan cancellations, mid-cycle reactivations, tiered upgrades, downgrades, and billing cycle switches.

A frequent architectural flaw in multi-tenant SaaS products is treating every billing action as a separate purchase or checkout session. This leads to duplicate active subscriptions, fragmented customer billing histories, desynchronized database records, and broken Monthly Recurring Revenue (MRR) metrics.

In this architectural deep dive, we explore how to manage subscription lifecycles across Stripe and FastSpring while strictly maintaining a Single Persistent Subscription ID per account, how each platform's webhook engine dispatches lifecycle updates, and how to orchestrate everything server-side using secure Supabase Edge Functions.


1. The Core Architectural Invariant: The Single Subscription ID Rule

In a robust SaaS billing engine, an organization or tenant must maintain a 1:1 relationship with an external subscription record. Every lifecycle event must mutate this existing subscription rather than generating a new one.

[Initial Checkout]  ──> Creates Subscription (sub_12345)
                           │
                           ├──> [Cancel]        ──> Mutates sub_12345 (cancel_at_period_end = true)
                           ├──> [Resume]        ──> Mutates sub_12345 (cancel_at_period_end = false)
                           ├──> [Upgrade/Plan]  ──> Mutates sub_12345 (items/products updated, prorated)
                           └──> [Cycle Switch]  ──> Mutates sub_12345 (monthly <-> annual)

Why Re-Creating Subscriptions Breaks SaaS Systems

  1. Ghost Billing & Duplicate Charges: If a user cancels and clicks "Resume" in your UI, launching a new Checkout Session or Storefront order generates a second subscription. If the first subscription was merely scheduled to cancel at the period end, the customer is now billed twice.
  2. MRR and Churn Analytics Corruption: Analytics providers (e.g., ChartMogul, Baremetrics, ProfitWell) track user churn and retention by subscription entity. Spawning new IDs inflates churn rates and corrupts lifetime value (LTV) calculations.
  3. Customer Support Nightmares: When a customer requests an invoice history or refund, fragmented subscriptions split billing transactions across disparate customer records.

The PostgreSQL Schema Rule

To enforce this constraint at the database layer, your PostgreSQL table must enforce a unique index on the tenant mapping:

CREATE TABLE subscriptions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  provider TEXT NOT NULL CHECK (provider IN ('stripe', 'fastspring')),
  customer_id TEXT NOT NULL,
  subscription_id TEXT NOT NULL UNIQUE,
  plan_id TEXT NOT NULL,
  status TEXT NOT NULL,
  cancel_at_period_end BOOLEAN DEFAULT FALSE,
  current_period_start TIMESTAMPTZ NOT NULL,
  current_period_end TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now(),
  CONSTRAINT unique_active_workspace_sub UNIQUE (workspace_id)
);

2. Stripe Subscription Lifecycle: Cancel, Resume, & Upgrades

Stripe operates as a direct payment gateway and developer-first subscription engine. In Stripe, everything revolves around the `/v1/subscriptions/{id}` resource.

A. Canceling in Stripe (Scheduled vs Immediate)

In SaaS, you almost always want cancel-at-period-end. If a customer paid for 30 days of service on Day 1 and cancels on Day 10, they are legally and contractually entitled to access until Day 30.

// Server-side API Route / Supabase Edge Function
import Stripe from 'stripe';

// 1. Cancel at period end (Graceful Churn) await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true, });

// 2. Immediate Cancellation (Hard Termination - e.g. TOS Violation) await stripe.subscriptions.cancel(subscriptionId); ```

  • Database Impact: When canceling at period end, the subscription `status` remains `'active'`, but `cancel_at_period_end` switches to `true`. Your frontend paywall checks `status === 'active'` while displaying a banner: *"Your plan will expire on [current_period_end]. Click Resume to keep your subscription."*

B. Resuming / Un-canceling in Stripe

If the user decides to stay before `current_period_end` passes, never open a new Stripe Checkout Session. Instead, reverse the flag on the existing subscription:

// Resume existing subscription in-place
await stripe.subscriptions.update(subscriptionId, {
  cancel_at_period_end: false,
});

Stripe immediately removes the scheduled deactivation. The next renewal invoice will charge normally on `current_period_end`, maintaining the exact same subscription ID, invoice line items, and billing timeline.

C. Upgrading and Downgrading in Stripe

To change tiers (e.g., from *Starter $29/mo* to *Scale $99/mo*), update the subscription's internal item list with proration calculations:

// 1. Retrieve the existing subscription item ID
const subscription = await stripe.subscriptions.retrieve(subscriptionId);

// 2. Update the item with the new price ID and handle prorations await stripe.subscriptions.update(subscriptionId, { items: [{ id: itemId, price: newPriceId, }], proration_behavior: 'create_prorations', // Automatically calculates credit/debit payment_behavior: 'error_if_incomplete', // Fails safely if payment method cannot be charged }); ```

  • Upgrades: Stripe creates a prorated invoice for the remaining billing period and attempts immediate charge on the customer's default payment method.
  • Downgrades: Stripe credits the unused amount toward the customer's customer balance, automatically reducing future renewal invoices.

3. FastSpring Subscription Lifecycle: Cancel, Resume, & Upgrades

FastSpring acts as a Merchant of Record (MoR). Unlike Stripe, FastSpring calculates, collects, and remits global sales tax, VAT, and GST, assuming legal tax liability. FastSpring's API structure reflects this difference via the `/subscriptions` endpoints.

A. Canceling in FastSpring

FastSpring provides native endpoints for subscription deactivation:

// FastSpring Subscriptions API (Basic Auth using API Username and Password)
const FASTSPRING_API_URL = 'https://api.fastspring.com';

// 1. Cancel at period end (Scheduled Deactivation) const response = await fetch(`${FASTSPRING_API_URL}/subscriptions/${fastspringSubId}`, { method: 'DELETE', headers: { 'Authorization': `Basic ${credentials}`, }, }); ```

  • FastSpring schedules the subscription for cancellation on the next bill date (`state: "active"`, `deactivationReason: "canceled"`).
  • To force immediate termination, append query param `?immediate=true`:
  • `DELETE /subscriptions/${fastspringSubId}?immediate=true`

B. Resuming / Un-canceling in FastSpring

If the customer cancels their cancellation prior to the deactivation date, invoke FastSpring's reactivate endpoint:

// Reactivate a subscription scheduled for cancellation
const response = await fetch(`${FASTSPRING_API_URL}/subscriptions/${fastspringSubId}/reactivate`, {
  method: 'POST',
  headers: {
    'Authorization': `Basic ${credentials}`,
  },
});

FastSpring removes the pending deactivation and leaves the existing subscription ID, customer account, and recurring payment schedule intact.

C. Upgrades and Downgrades in FastSpring

To alter a plan or quantity in FastSpring without prompting the customer to re-enter billing details, submit a modification payload to `/subscriptions`:

const response = await fetch(`${FASTSPRING_API_URL}/subscriptions`, {
  method: 'POST',
  headers: {
    'Authorization': `Basic ${credentials}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    subscriptions: [
      {
        subscription: fastspringSubId,
        product: 'scale-plan-monthly',
        quantity: 1,
        proration: 'prorate-now', // Options: 'prorate-now', 'prorate-next', 'none'
      }
    ]
  }),
});
  • `prorate-now`: FastSpring bills the prorated difference immediately using the stored payment method and updates the plan in-place.
  • `prorate-next`: FastSpring queues the adjustment to apply at the beginning of the next billing cycle without immediate charge.

4. Stripe vs FastSpring Architectural Comparison

FeatureStripe APIFastSpring API
ModelPayment Gateway / InfrastructureMerchant of Record (MoR)
Cancel Scheduled`POST /v1/subscriptions/{id}` with `cancel_at_period_end: true``DELETE /subscriptions/{id}`
Cancel Immediate`DELETE /v1/subscriptions/{id}``DELETE /subscriptions/{id}?immediate=true`
Resume (In-Period)`POST /v1/subscriptions/{id}` with `cancel_at_period_end: false``POST /subscriptions/{id}/reactivate`
Upgrade/Downgrade`POST /v1/subscriptions/{id}` updating `items` with `proration_behavior``POST /subscriptions` updating `product` with `proration: 'prorate-now'`
Webhook StructureSingle JSON event object per HTTP requestBatched JSON array containing multiple event objects
Signature Header`Stripe-Signature` (`t=...,v1=...`)`X-FS-Signature` (Raw HMAC-SHA256 Base64)

5. Secure Supabase Edge Functions: Webhooks & Database Sync

Handling subscription webhooks on serverless edges requires zero-trust security. You must authenticate incoming cryptographic signatures, prevent duplicate re-delivery side effects (idempotency), and guard against out-of-order race conditions.

The Unified Supabase Edge Function

Below is a production-grade Supabase Edge Function written in Deno handling both Stripe and FastSpring webhook payloads securely:

// supabase/functions/billing-webhook/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.38.4';

const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16', httpClient: Stripe.createSubtleCryptoProvider(), }); const stripeCryptoProvider = Stripe.createSubtleCryptoProvider();

const supabaseAdmin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! );

// FastSpring HMAC-SHA256 Verification Helper async function verifyFastSpringSignature(rawBody: string, signature: string, secret: string): Promise<boolean> { const encoder = new TextEncoder(); const keyData = encoder.encode(secret); const key = await crypto.subtle.importKey('raw', keyData, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const signatureBuffer = await crypto.subtle.sign('HMAC', key, encoder.encode(rawBody)); const computedSignature = btoa(String.fromCharCode(...new Uint8Array(signatureBuffer))); return computedSignature === signature; }

serve(async (req) => { const rawBody = await req.text(); const stripeHeader = req.headers.get('stripe-signature'); const fastSpringHeader = req.headers.get('x-fs-signature');

try { // ========================================== // 1. STRIPE WEBHOOK HANDLER // ========================================== if (stripeHeader) { const event = await stripe.webhooks.constructEventAsync( rawBody, stripeHeader, Deno.env.get('STRIPE_WEBHOOK_SECRET')!, undefined, stripeCryptoProvider );

// Idempotency Check const { data: existing } = await supabaseAdmin .from('processed_events') .select('id') .eq('event_id', event.id) .single(); if (existing) return new Response(JSON.stringify({ status: 'ignored_duplicate' }), { status: 200 });

if (event.type === 'customer.subscription.updated' || event.type === 'customer.subscription.deleted') { const sub = event.data.object as Stripe.Subscription; await supabaseAdmin .from('subscriptions') .update({ status: sub.status, cancel_at_period_end: sub.cancel_at_period_end, current_period_start: new Date(sub.current_period_start * 1000).toISOString(), current_period_end: new Date(sub.current_period_end * 1000).toISOString(), plan_id: sub.items.data[0]?.price.id, updated_at: new Date().toISOString(), }) .eq('subscription_id', sub.id); }

// Log Event ID for Idempotency await supabaseAdmin.from('processed_events').insert({ event_id: event.id, provider: 'stripe' }); return new Response(JSON.stringify({ received: true }), { status: 200 }); }

// ========================================== // 2. FASTSPRING WEBHOOK HANDLER // ========================================== if (fastSpringHeader) { const isValid = await verifyFastSpringSignature( rawBody, fastSpringHeader, Deno.env.get('FASTSPRING_WEBHOOK_SECRET')! ); if (!isValid) return new Response('Invalid FastSpring Signature', { status: 400 });

const payload = JSON.parse(rawBody); for (const event of payload.events) { // Idempotency Check const { data: existing } = await supabaseAdmin .from('processed_events') .select('id') .eq('event_id', event.id) .single(); if (existing) continue;

const subData = event.data; if (event.type === 'subscription.updated' || event.type === 'subscription.canceled' || event.type === 'subscription.activated') { await supabaseAdmin .from('subscriptions') .update({ status: subData.state, // 'active', 'canceled', 'overdue' cancel_at_period_end: subData.state === 'active' && !!subData.deactivationReason, current_period_end: new Date(subData.nextPeriodDateValue * 1000).toISOString(), plan_id: subData.product, updated_at: new Date().toISOString(), }) .eq('subscription_id', subData.id); }

await supabaseAdmin.from('processed_events').insert({ event_id: event.id, provider: 'fastspring' }); } return new Response(JSON.stringify({ received: true }), { status: 200 }); }

return new Response('Unrecognized webhook headers', { status: 400 }); } catch (err: any) { console.error(`Webhook processing failure: ${err.message}`); return new Response(`Error: ${err.message}`, { status: 500 }); } }); ```


6. Critical Security Safeguards for Subscription State

Safeguard 1: Timestamp Guarding Against Out-of-Order Delivery Webhooks travel over distributed networks. An earlier `subscription.updated` (setting status to `active`) may land **after** a `subscription.canceled` event due to HTTP retries.

To prevent resurrection of expired accounts, compare payload timestamps in SQL before executing the update:

-- Ensure incoming event is newer than the existing record
UPDATE subscriptions
SET status = new_status, updated_at = event_timestamp
WHERE subscription_id = sub_id
  AND updated_at < event_timestamp;

Safeguard 2: Zero Client-Side Entitlement Updates Never permit client-side React code to modify subscription states directly. A user clicking "Cancel" or "Upgrade" should invoke a server endpoint (e.g. Next.js Route Handler), which calls the payment gateway API. The payment gateway triggers the verified webhook, and the Edge Function updates PostgreSQL via the Supabase Service Role client.

Safeguard 3: Handling Dunning Cycles (Failed Payments) When a recurring card charge fails: - **Stripe** issues `invoice.payment_failed` and marks the subscription `past_due` or `unpaid`. - **FastSpring** issues `subscription.overdue`. - Your system must not immediately delete user records. Instead, retain access during the payment processor's grace/dunning retry period (typically 7 to 14 days), displaying payment update prompts in your dashboard before revoking access.


7. Architectural Checklist for Production

  1. Bind to One Subscription ID: Never initiate new checkout sessions for an existing customer who wants to resume, upgrade, or downgrade.
  2. Use Cancel-at-Period-End by Default: Protect user rights and eliminate refund requests by keeping access alive until the billing cycle terminates.
  3. Validate Cryptographic Signatures: Use SubtleCrypto in Deno to reject forged payloads for both Stripe and FastSpring.
  4. Enforce Database Idempotency: Log all processed webhook `event_id` strings to guarantee safe HTTP retries.
  5. Audit Prorations: Explicitly define proration behaviors (`create_prorations` in Stripe, `prorate-now` in FastSpring) to avoid billing surprises.

Technologies covered in this article:

Next.jsStripeFastSpringSupabaseEdge FunctionsPostgreSQL

Frequently Asked Questions

Why should all subscription activities remain on the initial Subscription ID?

Creating new checkout sessions for plan updates or reactivations spawns orphan customer records, causes duplicate billing, corrupts churn/MRR metrics, and fragments invoice history. All updates must mutate the existing subscription ID in-place.

How do you resume a canceled subscription before the billing period ends in Stripe?

Invoke the Stripe update API with cancel_at_period_end set to false on the existing subscription ID. This reactivates the subscription immediately without charging the customer or issuing a new subscription entity.

How does FastSpring handle subscription upgrades compared to Stripe?

FastSpring allows in-place product mutations via POST /subscriptions using proration parameters like 'prorate-now' or 'prorate-next'. It acts as a Merchant of Record (MoR) by directly adjusting global VAT and charging the payment method on file without initiating a new storefront session.

How do Supabase Edge Functions verify FastSpring webhooks?

Supabase Edge Functions use Deno Web Crypto API (crypto.subtle) to compute an HMAC-SHA256 hash of the raw request payload using the FastSpring webhook secret key, comparing the result with the incoming X-FS-Signature header.