Hosting + Billing
Categories:
9 minute read
When you’re just getting started with your SaaS product, there’s a natural temptation to overcomplicate your infrastructure. But modern tools let you go from “idea” to “running app with customers” using very little code, and with infrastructure that scales with you. This page outlines a solid, scalable, and simple setup we recommend for most solo or small-team SaaS products, especially if you’re bootstrapping.
Frontend Hosting
If you’re using a static site or a Single Page App (SPA) - like one built with Vite, React, Svelte, or similar - then your hosting needs are pretty minimal. These apps can be compiled down to static assets (HTML, CSS, JS) and served through a CDN.
We recommend Cloudflare Pages. It’s free, fast, and deploys from GitHub with every push. Since your app gets deployed across their global edge network, it can handle thousands of users a day - or a sudden spike from a Hacker News post - without you having to do anything.
It’s worth emphasizing: by having a static or SPA frontend, your app can scale globally, instantly, and cheaply. There’s nothing to “configure” for scale. That’s a huge win.
There are trade-offs with having a “public client” (vs a “confidential client” like a server-rendered app), but for most SaaS products, this is the right choice. It’s fast, simple, and you can focus on building features instead of managing servers.
Backend Services
For your backend, Supabase is the most complete, developer-friendly option we’ve used.
It handles almost everything you’d need to write a proper SaaS:
- Database: Postgres database (with Row Level Security built in)
- AuthN and AuthZ: (including OAuth with Google, GitHub, etc.)
- REST and GraphQL APIs: that respect your security rules
- Client SDKs for JS, Python, Dart, and more
- Serverless Functions: (equivalent to AWS Lambda, but easier)
- Edge functions: for ultra-fast response times
- File storage and image resizing
- Realtime subscriptions
- Centralized logging and monitoring
We strongly recommend using the hosted Supabase platform unless you absolutely must self-host. Their infrastructure scales, the pricing is fair, and it’s fully managed - meaning less time on ops, more time building your product. Check it out, they have a very generous free tier: Supabase Pricing.
The Big Picture
Here’s what this setup looks like in practice:
- Frontend: Cloudflare Pages (infinitely scalable static/SPAs)
- Backend: Supabase (hosted, scalable Postgres, Auth, APIs, etc.)
You get end-to-end scalability and high availability out of the box. No Kubernetes. No load balancers. No EC2 instance to babysit. No disk space issues. That means less friction, faster iteration, and a smoother path to revenue.
Payment Providers
Billing is where you start handling real money - and where things get more serious. You’ll need to pick a payment processor, and thankfully, there are a few mature, well-documented options.
Here are the most common providers in order of popularity:
- Stripe - Best-in-class documentation, flexible billing models, great ecosystem.
- Paddle - Focused on SaaS, handles tax compliance globally.
- Square - More common in retail but works online too.
- Braintree - Owned by PayPal, good for complex use cases.
- Lemon Squeezy - Newer, easy to integrate, built for indie devs.
Stripe is the default choice for most devs.
It has deep language SDKs, great testing tools, and supports everything from one-time purchases to usage-based billing.
Billing Architecture
Under the hood, payments should be treated like a financial ledger. Don’t just call stripe.charge()
and move on. Here are some real-world architecture tips:
- Audit Log: Keep a local audit log of every transaction.
- Track Payment Lifecycle: Use Stripe’s webhook system to track subscription lifecycle events.
- Queue Payment Processing: Use message queues (like Supabase Functions + edge queues or a hosted service like Pipedream).
- Redundant Subscription State: Store subscription state redundantly in your database with high-precision timestamps so your app can enforce access control even when Stripe is down.
Generally, if you handle credit card data directly, you need to be PCI-DSS compliant. This is a set of security standards designed to ensure that all companies that accept, process, store or transmit credit card information maintain a secure environment. See: Wikipedia
If you don’t touch raw card data - and let Stripe or another PCI-compliant processor handle that entirely - your compliance burden drops significantly.
Use Stripe Checkout or their Elements UI components. Do not roll your own credit card form unless you really know what you’re doing.
Handling Disputes and Cancellations
You will get chargebacks. You will have people cancel. Plan for it upfront.
- Set up a clear refund and cancellation policy. Make it visible.
- Handle disputes through your payment provider’s dashboard.
- Use soft-deletion or grace periods to give users time to reactivate.
- Automate email notifications when payments fail or a subscription is about to expire.
- Store logs and notes for any manual changes to account status.
For long-term success, it’s better to be generous with refunds and cancellations. A hit to your MRR is recoverable - a bad reputation isn’t.
Research in psychology and marketing suggests that negative experiences have a more significant impact on customers than positive ones. The often-cited ratio is that it takes about seven positive experiences to counteract one negative experience. This highlights the importance of maintaining high standards in customer service and product quality.
Put another way, your happy customer will barely tell 1-2 people about their good experience with you. But your unhappy customer will tell 7-10 people about their bad experience. So, it’s worth investing in a good customer experience, especially when it comes to billing and disputes.
Payment Processing Flow
Let’s walk through what actually happens when someone clicks “Purchase” in your app. We’ll cover the happy path, error handling, and recovery strategies.
The key principle here is idempotency. Every payment request should have a unique identifier so you can safely retry operations without double-charging customers.
sequenceDiagram participant 👤 User participant 💻 Frontend participant 🗄️ Database participant 📬 Queue participant 🧠 Backend participant 💳 Stripe 👤 User->>💻 Frontend: Clicks "Purchase Pro Plan" 💻 Frontend->>🗄️ Database: Create payment_request (status: pending) 🗄️ Database-->>💻 Frontend: Returns request_id 💻 Frontend->>📬 Queue: Enqueue payment job 📬 Queue-->>💻 Frontend: Job Queued 💻 Frontend-->>👤 User: Show "Processing..." 📬 Queue->>🧠 Backend: Process payment job 🧠 Backend->>🗄️ Database: Update status: processing 🧠 Backend->>💳 Stripe: Create payment intent alt Payment Success rect rgb(0, 255, 0, 0.05) 💳 Stripe-->>🧠 Backend: Payment succeeded 🧠 Backend->>🗄️ Database: Update status: completed 🧠 Backend->>🗄️ Database: Upgrade user subscription 🧠 Backend->>📬 Queue: Enqueue welcome email 🧠 Backend-->>💻 Frontend: Webhook/polling update 💻 Frontend-->>👤 User: Show success + redirect end else Payment Declined rect rgb(255, 0, 0, 0.05) 💳 Stripe-->>🧠 Backend: Payment failed (declined) 🧠 Backend->>🗄️ Database: Update status: failed, reason: declined 🧠 Backend-->>💻 Frontend: Webhook/polling update 💻 Frontend-->>👤 User: Show error + retry option end else Backend Down rect rgb(255, 127, 0, 0.2) 📬 Queue->>📬 Queue: Retry after exponential backoff Note over 📬 Queue: Max 3 retries over 24 hours end end
Database State Management
Your database should be the single source of truth for payment state. Here’s a minimal but robust schema approach:
-- Payment requests table
CREATE TABLE payment_requests (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
plan_id VARCHAR(50) NOT NULL,
amount_cents INTEGER NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL, -- pending, processing, completed, failed, expired
failure_reason VARCHAR(100),
stripe_payment_intent_id VARCHAR(100),
idempotency_key VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- Subscription state table
CREATE TABLE subscriptions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
plan_id VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL, -- active, past_due, canceled, expired
current_period_start TIMESTAMP NOT NULL,
current_period_end TIMESTAMP NOT NULL,
stripe_subscription_id VARCHAR(100),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
Message Queue vs Database Polling
You have two main patterns for handling async payment processing:
Option 1: Message Queue (Recommended)
- Use a queue like Redis, AWS SQS, or Supabase Edge Functions
- Immediate processing with built-in retry logic
- Better separation of concerns
- Easier to monitor and debug
Option 2: Database Polling
- Background job checks for
pending
payments every few minutes - Simpler to implement initially
- Can miss time-sensitive operations
- Harder to handle complex retry logic
We recommend starting with a message queue. It’s more resilient and scales better.
Error Handling Strategies
For Declined Cards
- Don’t retry automatically - the card was actively declined
- Show a clear error message to the user
- Offer alternative payment methods
- Log the decline reason for analytics
For Network/API Errors
- Use exponential backoff: retry after 1min, 5min, 30min
- Maximum of 3-5 retries over 24 hours
- After max retries, mark as
expired
and notify user - Always use the same idempotency key for retries
For Backend Downtime
- Queue systems should handle this automatically with retries
- Database transactions should be atomic (all-or-nothing)
- Use database-level constraints to prevent inconsistent states
- Monitor failed job rates and set up alerts
Webhook Reliability
Stripe sends webhooks for subscription events, but networks fail. Here’s how to handle that:
flowchart TD A[Stripe Webhook Received] --> B{Validate Signature} B -->|Invalid| C[Return 400, Log Error] B -->|Valid| D[Parse Event Data] D --> E{Event Already Processed?} E -->|Yes| F[Return 200, Skip Processing] E -->|No| G[Process Event + Update DB] G --> H{Processing Successful?} H -->|No| I[Return 500, Stripe Will Retry] H -->|Yes| J[Mark Event as Processed] J --> K[Return 200]
Best Practices
- Always return
200 OK
for successfully processed webhooks - Return
500
for temporary failures (Stripe will retry) - Store webhook events in your database to prevent duplicates
- Validate webhook signatures to prevent spoofing
- Process webhooks idempotently
Recovery and Reconciliation
Things will go wrong. Plan for these scenarios:
- Daily reconciliation job: Compare your database against Stripe’s records
- Manual recovery tools: Admin interface to retry failed payments
- Customer support hooks: Easy way to refund, upgrade, or fix account issues
- Monitoring dashboards: Track payment success rates, failure reasons, and processing times
Test every failure scenario in your staging environment:
- Declined credit cards (use Stripe’s test card numbers)
- Network timeouts during payment processing
- Webhook delivery failures
- Backend downtime during peak traffic
Your payment system is only as strong as its weakest failure mode.
Summary
You don’t need a PhD in DevOps to launch a SaaS product. Between static hosting (Cloudflare Pages) and a powerful backend-as-a-service (Supabase), you can ship faster and sleep easier.
Payments can get complex, but with Stripe or Paddle, you can offload the hardest parts and stay within PCI compliance.
Set up your billing to be robust, auditable, and testable. Plan for edge cases like disputes, downgrades, and delinquent users.
You don’t need to reinvent this stuff. You just need a setup that scales with you and gets out of your way.