How to Add Subscription Billing to Your SaaS App
How to Add Subscription Billing to Your SaaS App
The moment most developers delay adding billing to their SaaS is the exact moment they should do it. The psychological barrier is real—making your product "paid" feels like a commitment, and there's always one more feature that seems essential before monetization. But this thinking is backwards. If you can't convince anyone to pay for what you've built right now, building more features won't magically create willingness to pay. Integrating billing early forces you to confront whether you're building something valuable or just something interesting.
This guide covers the complete process of adding subscription billing to a SaaS application, from choosing a payment provider through handling the edge cases that break most implementations. You'll learn the technical integration steps, the business logic that determines access control, how to handle the inevitable failures and subscription lifecycle events, and the specific mistakes that create revenue leaks or compliance issues. The focus is on getting billing working correctly the first time, because billing bugs don't just annoy users—they directly cost you money.
We'll walk through provider selection, webhook handling, subscription state management, access control, tax compliance, dunning (failed payment recovery), and handling plan changes. Each section addresses both the happy path and the edge cases that production billing systems must handle.
Choosing Your Billing Infrastructure
Your billing provider choice affects far more than payment processing—it determines your pricing flexibility, international expansion options, and how much billing logic you'll need to build yourself. The landscape splits into three tiers, each with distinct tradeoffs.
Stripe is the default choice for developer-focused SaaS. The API is excellent, documentation is comprehensive, and the feature set covers everything from simple subscriptions to usage-based billing, invoicing, and multi-currency support. Stripe Billing handles subscription lifecycle automatically—creating invoices, charging payment methods, retrying failed payments. The downside is cost: 2.9% + $0.30 per transaction in the US, higher for international cards. For a $10/month subscription, you're paying $0.59 per charge—almost 6% of revenue. But that percentage buys you compliance (PCI, SCA, tax calculation), fraud prevention, and not having to build subscription management yourself.
Paddle is an alternative that acts as merchant of record, meaning Paddle (not you) is the seller for tax and legal purposes. This simplifies tax compliance enormously—Paddle handles VAT, sales tax, and remittance globally. The tradeoff is less control and higher fees (5% + $0.50 per transaction). Use Paddle if your target market is global B2C where dealing with tax across 50+ jurisdictions would be prohibitive, or if you're a non-US company selling globally and want to avoid establishing tax presence everywhere.
PayPal and legacy processors (Authorize.net, Braintree) are viable if your market specifically requests them (e.g., markets where credit cards are uncommon), but they lack the developer experience and subscription management features of Stripe. You'll build more billing logic yourself.
| Provider | Best For | Fees | Key Advantage |
|---|---|---|---|
| Stripe | Developer tools, B2B SaaS | 2.9% + $0.30 | Best API, extensive features |
| Paddle | Global B2C, tax simplification | 5% + $0.50 | Merchant of record (handles tax) |
| PayPal | Markets where PayPal dominates | 2.9% + $0.30 | High market recognition |
Database Schema for Subscription Management
Your database needs to track subscription state independently of your payment provider. This is not duplication—it's essential for access control and handling webhook delays or failures. When a user logs in, you check your database to determine their subscription status, not the payment provider's API (which would be slow and create a dependency on external service uptime).
The minimal schema requires a subscriptions table linked to your users or organizations table. Key fields: stripe_customer_id (the ID in Stripe's system), stripe_subscription_id, status (active, past_due, canceled, trialing), current_period_start, current_period_end, plan_id, and cancel_at_period_end (boolean flag for whether the subscription will cancel at the end of the current period).
Status values mirror Stripe's subscription statuses but live in your database. Active means the subscription is paid and valid. Past_due means the most recent payment failed but you haven't canceled yet (dunning period). Canceled means the subscription ended. Trialing means the user is in a trial period. Your access control logic checks: if (subscription.status === 'active' || subscription.status === 'trialing') to determine if the user should have access.
The cancel_at_period_end flag is crucial for UX. When a user cancels, you set this flag but don't immediately revoke access—they keep access until the period they paid for ends. This is both legally correct (they paid for the full month) and better UX (doesn't punish users for canceling).
Implementing the Subscription Creation Flow
The user flow for starting a subscription has several steps, each with potential failure points. The robust implementation handles failures gracefully and avoids leaving users in inconsistent states (payment processed but account not activated, or vice versa).
Step one is creating a customer in Stripe. This happens when a user first enters payment information. You send their email and metadata (your internal user ID) to Stripe's create customer endpoint, which returns a customer ID. Store this stripe_customer_id in your users table. This customer object persists across multiple subscriptions—if a user cancels and re-subscribes later, you reuse the same customer ID.
Step two is creating the subscription. You send Stripe the customer ID, the price ID (which plan they're subscribing to), and any trial period. Stripe returns a subscription object with status, current_period_end, and other details. You store this in your subscriptions table.
The critical failure mode is payment failure during subscription creation. Stripe may return a subscription with status=incomplete if the payment failed or requires additional authentication (3D Secure). Your code must handle this: don't grant access if status is incomplete. Instead, present the user with next steps—entering a different card, completing authentication. Only grant access when you receive a webhook confirming the subscription is active.
Here's the pattern for idempotent subscription creation: before creating a subscription, check if one already exists for this user. If it does and it's active, redirect to the dashboard. If it exists but is incomplete, resume the existing subscription flow rather than creating a duplicate. This prevents bugs where users click "subscribe" multiple times and end up with multiple charges.
Webhook Handling: The Critical Infrastructure
Webhooks are how Stripe notifies your application about events: subscription created, payment succeeded, payment failed, subscription canceled. Your entire billing system depends on processing these webhooks correctly. Webhook bugs create revenue leaks (users accessing without paying) or access problems (paying users locked out).
The webhook endpoint receives POST requests from Stripe containing event data. The first critical step is signature verification—confirming the request actually came from Stripe and wasn't forged. Stripe includes a signature header that you verify using a webhook secret. Every webhook library handles this. If verification fails, reject the request immediately.
The second critical step is idempotent processing. Stripe may send the same webhook multiple times (network retries, infrastructure issues). Your code must handle receiving the same event twice without creating duplicate database entries or performing duplicate actions. The pattern is checking if you've already processed this event ID: SELECT id FROM processed_events WHERE stripe_event_id = ?. If found, return 200 immediately. If not, process the event and record the event ID.
The events that matter most for subscription billing are customer.subscription.created (new subscription), customer.subscription.updated (plan change, cancellation), invoice.payment_succeeded (successful recurring charge), invoice.payment_failed (failed recurring charge), and customer.subscription.deleted (subscription fully canceled). For each event type, you update your database to match the new state.
When invoice.payment_failed arrives, you don't immediately cancel the subscription. Stripe automatically retries failed payments over several days (the dunning period). Your job is updating the subscription status to past_due and notifying the user. Only when customer.subscription.deleted arrives do you revoke access.
| Webhook Event | What It Means | Your Action |
|---|---|---|
| customer.subscription.created | New subscription started | Create subscription record, grant access |
| invoice.payment_succeeded | Recurring payment succeeded | Update period_end, ensure status is active |
| invoice.payment_failed | Payment failed (card declined) | Set status to past_due, notify user |
| customer.subscription.deleted | Subscription permanently ended | Revoke access, set status to canceled |
Access Control Based on Subscription Status
Your application needs a consistent pattern for checking whether a user should have access to features. This logic lives in a central authorization function that every protected endpoint calls. The naive implementation checks subscription.status === 'active', but this misses important cases.
A user should have access if their subscription is active OR trialing OR past_due (with a grace period). The past_due grace period is important—you don't want to immediately lock out a user whose card declined, especially if the decline was temporary (daily limit reached, fraud detection). Give them 3-7 days to update payment information before revoking access.
The authorization function also needs to check current_period_end. If a user canceled but their paid period hasn't ended, they should still have access. This is handled by the cancel_at_period_end flag: if it's true and current_period_end is in the future, grant access.
For feature-gated access (different plans have different features), maintain a plan_features table mapping plan IDs to feature flags. Your authorization function checks if (hasFeature(user.subscription.plan_id, 'advanced-analytics')). This centralizes feature access logic so you're not scattering plan checks throughout your codebase.
Handling Plan Changes and Upgrades
Users will want to change plans—upgrading to more features, downgrading to save money, or switching between annual and monthly billing. Stripe handles the billing math automatically (proration), but you need to update your database and handle the timing correctly.
When a user upgrades (moves to a more expensive plan), the change should take effect immediately. You call Stripe's update subscription endpoint with the new price ID and proration_behavior: 'always_invoice'. Stripe immediately charges the prorated difference and updates the subscription. Your webhook handler receives customer.subscription.updated and updates your database to reflect the new plan.
When a user downgrades, there are two approaches. Immediate downgrade charges a prorated refund and switches to the cheaper plan now. End-of-period downgrade keeps them on the current plan until period_end, then switches. The end-of-period approach is better UX—users don't lose features they've paid for, and you avoid the complexity of partial refunds. Implement this by setting proration_behavior: 'none' and effective: 'at_period_end' when updating the subscription.
The database update pattern for plan changes is important: update the subscription record in a transaction that also checks current state. You don't want race conditions where two simultaneous plan change requests create conflicting states. Use optimistic locking (version column) or explicit locks (SELECT FOR UPDATE) to ensure atomic plan changes.
Tax Compliance and International Billing
Tax compliance is where many SaaS apps create legal liability. In the US, you're required to collect sales tax in states where you have nexus (physical presence or economic thresholds). In the EU, you must collect VAT based on the customer's location. Getting this wrong means you're personally liable for uncollected tax, potentially going back years with penalties.
Stripe Tax automates this entirely. Enable it in your Stripe dashboard, and Stripe calculates correct tax rates based on customer location, adds tax to invoices, and provides reports for tax remittance. The cost is 0.5% of transaction volume, which is trivial compared to the cost of tax compliance mistakes or hiring tax professionals.
The implementation is adding tax_calculation: 'auto' when creating subscriptions. Stripe handles the rest. For customers, you collect their country and postal code (for accurate tax calculation), and Stripe's checkout handles this automatically.
The non-obvious issue is determining customer location. For B2C, use IP geolocation with user confirmation. For B2B, require a billing address and VAT ID (for EU business customers, who are typically VAT-exempt via reverse charge). Stripe validates VAT IDs and applies correct tax treatment automatically.
Failed Payment Recovery (Dunning)
Payment failures are inevitable—cards expire, reach limits, get flagged for fraud. How you handle failed payments directly affects your revenue. Companies with effective dunning recover 30-40% of failed payments. Companies that just let subscriptions cancel lose that revenue permanently.
Stripe's Smart Retries automatically retry failed payments over 3-4 weeks using machine learning to optimize retry timing. This handles the technical side. Your job is user communication—notifying users about payment failure and making it easy to update payment information.
The email sequence for dunning should be: immediate notification when payment fails (action required), reminder after 3 days (card will be retried), final notice after 7 days (subscription will cancel soon). Each email includes a direct link to update payment information (Stripe customer portal link). The tone should be helpful, not accusatory—many payment failures are temporary issues, not intentional.
For high-value customers, consider reaching out personally when payment fails. A personal email from the founder to a customer paying $500/month has a much higher recovery rate than automated emails. This doesn't scale to thousands of customers, but for your top 50 customers by revenue, personal outreach is worth the time.
The technical implementation is reacting to invoice.payment_failed webhooks. Set subscription status to past_due, trigger the email sequence, and log the failure. When Stripe successfully retries and you receive invoice.payment_succeeded, set status back to active and stop the email sequence.
Cancellation Flow and Retention
How you handle cancellations affects both churn rate and brand perception. The worst pattern is making cancellation difficult—requiring users to email support, hiding the cancel button, or using dark patterns. This creates angry users who complain publicly. The best pattern is making cancellation easy but offering alternatives before confirming.
The cancellation UX should be: clear cancel button in account settings, optional feedback form asking why they're canceling, offer to pause instead of cancel (if your product supports pausing), confirm cancellation, and notify them they'll have access until period end. This respects the user while giving you retention opportunities.
The technical implementation calls Stripe's cancel subscription endpoint with at_period_end: true. This sets cancel_at_period_end but doesn't immediately delete the subscription. Stripe automatically deletes it when the period ends, triggering customer.subscription.deleted webhook. Your database stays in sync by updating the cancel_at_period_end flag immediately and handling the final deletion webhook later.
The cancellation feedback is valuable data—track cancellation reasons in your database to identify patterns. If 40% of cancellations cite "too expensive," you have a pricing problem. If users cancel after 2 months consistently, you have an activation or value delivery problem.
Frequently Asked Questions
Should I offer a free trial, and how long should it be?
Free trials increase conversion when your product requires time investment to see value—complex tools, workflow changes, team adoption. For products with immediate value (simple utilities), trials are less important and may attract non-serious users. Trial length should match your time-to-value: if users see value in 1 week, a 14-day trial is appropriate. If your product takes a month to integrate and see ROI, offer a 30-day trial. Stripe supports trials natively—specify trial_period_days when creating subscriptions. The key implementation detail is requiring a payment method upfront (trial_card_required). This dramatically increases conversion from trial to paid because the barrier to continuing is low (doing nothing), whereas requiring card entry after the trial creates a second friction point where many users drop off.
How do I handle refunds for subscription cancellations?
The standard policy is no refunds for subscription cancellations—users keep access until the end of the period they paid for, but you don't refund the partial unused time. This is both common industry practice and technically simpler. However, for customer satisfaction and in some jurisdictions (EU has mandatory cooling-off periods), you may need to issue refunds. Stripe's refund API makes this straightforward: create a refund for the most recent invoice, optionally partial (prorated based on unused time). The webhook invoice.refunded notifies you when refunds complete. Your database should track refunds to prevent abuse—if a user repeatedly subscribes, uses the service, then refunds, you can flag this pattern and require non-refundable payment.
What's the best way to implement usage-based billing?
Usage-based billing charges customers based on consumption rather than flat rates. This is common for API products (charge per API call), infrastructure (charge per GB stored), or communication tools (charge per message sent). Stripe supports this via metered billing: you create a price with usage_type: 'metered', then report usage via the usage records API throughout the billing period. At period end, Stripe automatically calculates charges based on total usage. The implementation pattern is batching usage reports—don't send a usage record for every API call, as this creates excessive API traffic. Instead, aggregate usage locally (in Redis or your database) and report to Stripe hourly or daily. Track usage in your database for audit purposes, even though Stripe stores the canonical billing data.
How should I handle annual vs monthly billing?
Offering annual billing with a discount (typically 15-20% off compared to monthly) improves cash flow and reduces churn. Implementation is creating two price objects in Stripe for each plan: one with interval: 'month', one with interval: 'year'. Your pricing page presents both options. The complexity is handling plan changes between monthly and annual—when a monthly subscriber switches to annual mid-month, do you charge them immediately for a full year minus the current month prorated? Stripe's proration handles this automatically if you set proration_behavior: 'always_invoice'. For users on annual plans who want to switch to monthly, the cleanest approach is scheduling the change for the next renewal rather than prorating a partial refund of the annual fee.
What happens if my webhook endpoint is down when Stripe sends events?
Stripe retries failed webhooks automatically for up to 3 days with exponential backoff. If your endpoint returns a 2xx status, Stripe considers it delivered. If it returns 4xx/5xx or times out, Stripe retries. This means temporary outages don't cause missed events. However, if your endpoint is down for multiple days, you'll miss events. To handle this, implement a backfill mechanism: periodically (daily) fetch recent subscription objects from Stripe and sync your database. Check for subscriptions that exist in Stripe but not in your database, or subscriptions with different statuses. This catches any missed webhooks. The pattern is using Stripe's list API with created or updated filters to get subscriptions changed recently, then comparing to your database.
How do I implement multi-currency support?
Multi-currency support lets customers pay in their local currency. Stripe supports this via presentment currency—you create prices in multiple currencies (USD, EUR, GBP), and customers see prices in their selected currency. The implementation creates multiple price objects for each plan, one per currency. When creating subscriptions, use the price ID matching the customer's currency. The complexity is maintaining price parity across currencies as exchange rates fluctuate. Most SaaS apps set prices manually per currency (e.g., $100, €95, £85) and update them quarterly rather than dynamically converting based on exchange rates. This provides pricing stability and allows local market pricing rather than pure conversion.
Should I store credit card details in my own database?
Never store credit card details in your database. This creates PCI compliance requirements that are expensive and complex to meet. Instead, use Stripe's tokenization: collect card details via Stripe Elements (embedded forms), which sends card data directly to Stripe and returns a payment method ID. You store only this ID, which is useless for fraud because it only works with your Stripe account and can't be used elsewhere. When you need to charge the card, you reference the payment method ID in API calls. Stripe handles all card data storage and security. This keeps you PCI-compliant with minimal requirements (SAQ-A questionnaire rather than full compliance audit).
How do I handle subscription billing for team accounts where the size changes?
Team accounts with per-seat pricing need to handle users being added or removed mid-cycle. Stripe's quantity parameter on subscriptions handles this. When a team adds a user, update the subscription quantity and Stripe automatically prorates the charge for the remaining period. When removing users, you can either prorate a credit (Stripe supports this) or more commonly, apply the credit to the next invoice rather than refunding. The implementation tracks the team's current user count and calls Stripe's update subscription endpoint whenever it changes. For teams that frequently add/remove users, consider usage-based billing instead of per-seat to avoid constant subscription updates.
What's the right approach for grandfather pricing when changing plans?
Grandfather pricing keeps existing customers on old prices when you increase rates. This is good for customer satisfaction but creates operational complexity—you're maintaining multiple price points for the same plan. Stripe supports this by keeping old price IDs active when you create new ones. Existing subscriptions continue using the old price ID; new subscriptions use the new price ID. Your database should track which price ID each subscription uses for reporting and analytics. The alternative approach is migrating all customers to new pricing with transition notice and one-time discount credits for loyal customers. This reduces operational complexity while maintaining goodwill.
How should I handle payment failures for annual subscriptions?
Annual subscription payment failures are higher stakes—a declined $1,200 charge versus a $100 monthly charge. The pattern is more aggressive recovery: immediate email with personal outreach for high-value customers, offer to switch to monthly billing to reduce the immediate charge, or allow paying the annual fee in installments (though this requires custom billing logic). Some SaaS products automatically fall back to monthly billing if an annual renewal fails, keeping the customer active while reducing the immediate payment amount. This requires checking invoice.payment_failed webhooks, identifying annual subscriptions, and creating a new monthly subscription while canceling the failed annual one.
Conclusion
Subscription billing is not a feature you add once and forget—it's infrastructure that requires ongoing attention to edge cases, compliance changes, and optimization for revenue recovery. The decisions that matter most are choosing a provider that handles complexity for you (Stripe for most use cases), implementing webhook handling with idempotency and verification from day one, maintaining subscription state in your database for fast access control, and building in dunning and retention flows that recover failed payments. Get billing working correctly early, before you have customers at scale, because fixing billing bugs with thousands of active subscriptions is far more expensive and risky than building it right initially.