Skip to content
    2025-05-15|4 min read

    Building Subscription Management in SaaS with Firebase

    #firebase#subscriptions#saas#stripe#cloud-functions

    Subscription management is the backbone of any SaaS product. Get it wrong, and you'll either leak revenue from users who should be paying or frustrate paying customers by denying them access they've already paid for.

    In this article, I'll walk through the full architecture for subscription management using Firebase and Stripe, covering entitlements, webhook handling, idempotency, and real-time access control. These patterns power the billing systems in production SaaS platforms like PeptiSync and ProfitPlate.

    Architecture Overview

    The system has four layers:

    1. Stripe — payment processing, subscription lifecycle, webhook events
    2. Firebase Cloud Functions — Stripe webhook receiver, custom checkout logic
    3. Firestore — user entitlements stored as documents
    4. Client SDK — reads entitlements and enforces UI restrictions

    Setting Up Stripe Webhooks

    When a user subscribes, upgrades, or cancels, Stripe sends webhook events to your Cloud Function endpoint. The most important events are:

    • checkout.session.completed
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_failed

    Idempotent Webhook Handler

    Stripe may deliver the same event multiple times. Your handler must be idempotent — processing the same event twice should produce the same result.

    ```typescript import as functions from 'firebase-functions'; import as admin from 'firebase-admin'; import Stripe from 'stripe';

    const stripe = new Stripe(functions.config().stripe.secret, { apiVersion: '2025-02-24.acacia', });

    export const handleStripeWebhook = functions.https.onRequest( async (req, res) => { const sig = req.headers['stripe-signature'] as string; const webhookSecret = functions.config().stripe.webhook_secret;

    let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret); } catch (err) { res.status(400).send('Invalid signature'); return; }

    const existingEvent = await admin .firestore() .collection('stripeEvents') .doc(event.id) .get();

    if (existingEvent.exists) { res.json({ received: true }); return; }

    await admin.firestore().collection('stripeEvents').doc(event.id).set({ type: event.type, processedAt: admin.firestore.FieldValue.serverTimestamp(), });

    switch (event.type) { case 'checkout.session.completed': await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); break; case 'customer.subscription.updated': await handleSubscriptionUpdated(event.data.object as Stripe.Subscription); break; case 'customer.subscription.deleted': await handleSubscriptionDeleted(event.data.object as Stripe.Subscription); break; }

    res.json({ received: true }); } ); ```

    Entitlement Documents in Firestore

    Each user gets an entitlements document that their client reads in real-time.

    ``typescript interface UserEntitlements { tier: 'free' | 'starter' | 'pro' | 'enterprise'; status: 'active' | 'past_due' | 'canceled' | 'incomplete'; currentPeriodEnd: Timestamp; features: string[]; stripeCustomerId: string; stripeSubscriptionId: string; updatedAt: Timestamp; } ``

    Granting Access After Checkout

    When the checkout completes, create or update the user's entitlements:

    ```typescript async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { const userId = session.client_reference_id!; const subscriptionId = session.subscription as string; const subscription = await stripe.subscriptions.retrieve(subscriptionId);

    const tier = mapPriceIdToTier(subscription.items.data[0].price.id); const features = getFeaturesForTier(tier);

    await admin .firestore() .collection('entitlements') .doc(userId) .set( { tier, status: mapStatus(subscription.status), currentPeriodEnd: Timestamp.fromMillis( subscription.current_period_end * 1000 ), features, stripeCustomerId: session.customer as string, stripeSubscriptionId: subscriptionId, updatedAt: FieldValue.serverTimestamp(), }, { merge: true } ); } ```

    Client-Side Enforcement

    Your React frontend reads entitlements and conditionally renders features:

    ```tsx function useEntitlements(userId: string) { const [entitlements, setEntitlements] = useState<UserEntitlements | null>(null);

    useEffect(() => { const unsub = onSnapshot( doc(db, 'entitlements', userId), (snap) => { if (snap.exists()) { setEntitlements(snap.data() as UserEntitlements); } } ); return unsub; }, [userId]);

    return entitlements; }

    function ExportButton() { const { user } = useAuth(); const entitlements = useEntitlements(user?.uid || '');

    if (!entitlements?.features.includes('export')) { return ( <Tooltip content="Upgrade to export data"> <Button variant="locked" disabled> Export CSV </Button> </Tooltip> ); }

    return <Button onClick={handleExport}>Export CSV</Button>; } ```

    Handling Grace Periods and Payment Failures

    When invoice.payment_failed fires, you have a decision: downgrade immediately or allow a grace period. I recommend 3–7 days depending on your pricing.

    ```typescript async function handlePaymentFailed(invoice: Stripe.Invoice) { const subscriptionId = invoice.subscription as string; const customerId = invoice.customer as string;

    const userSnap = await admin .firestore() .collection('entitlements') .where('stripeCustomerId', '==', customerId) .get();

    if (userSnap.empty) return;

    const userId = userSnap.docs[0].id;

    await admin .firestore() .collection('entitlements') .doc(userId) .update({ status: 'past_due', updatedAt: FieldValue.serverTimestamp(), });

    await sendPastDueEmail(userId, invoice.hosted_invoice_url); } ```

    Testing Subscription Flows

    Stripe's test mode makes it easy to test every flow:

    • Use card number 4000000000003220 to test 3D Secure authentication
    • Use 4000000000000002 to test declined charges
    • Run stripe trigger checkout.session.completed from CLI to simulate webhooks locally

    Security Rules

    Protect entitlement documents with Firestore security rules:

    ``javascript match /entitlements/{userId} { allow read: if request.auth != null && request.auth.uid == userId; allow write: if false; // only Cloud Functions write here } ``

    Cloud Functions bypass security rules, so this ensures users can only read their own entitlements but never modify them.

    Conclusion

    Subscription management requires careful handling of asynchronous events, idempotency, and edge cases. Firebase and Stripe form a powerful combination when wired correctly — Firestore provides real-time entitlement sync while Cloud Functions handle the Stripe integration reliably. The architecture described here has powered SaaS products with thousands of subscribers without a single entitlement drift incident.

    Need help setting up subscriptions for your SaaS? Reach out and I'll help you design a system that handles every edge case.

    ---

    R

    Written by

    Rahul

    Freelance developer for startups building SaaS products, MVPs, mobile apps, and conversion-focused website improvements.

    Building something?

    I am currently available for new projects. Share your idea and I will give you an honest assessment, delivery plan, and quote.