Building Subscription Management in SaaS with Firebase
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:
- Stripe — payment processing, subscription lifecycle, webhook events
- Firebase Cloud Functions — Stripe webhook receiver, custom checkout logic
- Firestore — user entitlements stored as documents
- 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.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.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
4000000000003220to test 3D Secure authentication - Use
4000000000000002to test declined charges - Run
stripe trigger checkout.session.completedfrom 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.
---