How to Build SaaS Billing (2026): Stripe, Lemon Squeezy, or Build Your Own
Billing is the most important and least exciting part of your SaaS. Get it right and money flows in automatically. Get it wrong and you're debugging failed payments at 2 AM while customers churn.
Here's how to implement SaaS billing properly in 2026.
Choose Your Approach
| Approach | Complexity | Tax Handling | Best For |
|---|---|---|---|
| Lemon Squeezy | Low | Handled (MoR) | Solo founders, indie SaaS |
| Stripe Billing | Medium | You handle it | Funded startups, scaling SaaS |
| Paddle | Low | Handled (MoR) | Growing SaaS, global sales |
| Custom on Stripe | High | You handle it | Complex pricing, enterprise |
The Merchant of Record Decision
This is the biggest choice you'll make:
Merchant of Record (MoR): Lemon Squeezy or Paddle are the legal seller. They handle global sales tax, VAT, GST. You receive payouts after they handle compliance.
You as Merchant: With Stripe, you're the seller. You handle tax registration, collection, and remittance in every jurisdiction you sell to. Stripe Tax helps but doesn't make you compliant automatically.
If you sell globally (you do), start with an MoR. The compliance burden of managing VAT in 27 EU countries isn't worth it until you're at significant revenue.
Option 1: Lemon Squeezy (Fastest)
Lemon Squeezy handles everything: payments, subscriptions, tax compliance, and customer portal.
Implementation
Step 1: Set up products and plans Create your pricing in the Lemon Squeezy dashboard. Define plans with monthly/yearly billing.
Step 2: Add checkout
// Simple checkout link
<a href="https://yourstore.lemonsqueezy.com/buy/plan-id">
Upgrade to Pro
</a>
// Or use the JS SDK for overlay checkout
LemonSqueezy.Url.Open('https://yourstore.lemonsqueezy.com/buy/plan-id');
Step 3: Handle webhooks
// Listen for subscription events
app.post('/webhooks/lemonsqueezy', async (req, res) => {
const event = req.body;
switch (event.meta.event_name) {
case 'subscription_created':
await db.user.update({
where: { email: event.data.attributes.user_email },
data: { plan: 'pro', subscriptionId: event.data.id }
});
break;
case 'subscription_cancelled':
await db.user.update({
where: { subscriptionId: event.data.id },
data: { plan: 'free', cancelledAt: new Date() }
});
break;
}
res.status(200).send('OK');
});
Step 4: Gate features
function canAccessFeature(user, feature) {
const planFeatures = {
free: ['basic_dashboard'],
pro: ['basic_dashboard', 'advanced_analytics', 'api_access'],
enterprise: ['basic_dashboard', 'advanced_analytics', 'api_access', 'sso', 'audit_log'],
};
return planFeatures[user.plan]?.includes(feature);
}
Pros: Fastest implementation. Global tax handled. Customer portal included. Cons: 5% + $0.50 per transaction. Less customization.
Option 2: Stripe Billing (Most Flexible)
Stripe gives you more control but more responsibility.
Implementation
Step 1: Create products and prices
// Create in Stripe Dashboard or via API
const product = await stripe.products.create({
name: 'Pro Plan',
});
const monthlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 2900, // $29.00
currency: 'usd',
recurring: { interval: 'month' },
});
const yearlyPrice = await stripe.prices.create({
product: product.id,
unit_amount: 29000, // $290.00 (save ~17%)
currency: 'usd',
recurring: { interval: 'year' },
});
Step 2: Create checkout session
app.post('/api/checkout', async (req, res) => {
const session = await stripe.checkout.sessions.create({
customer_email: req.user.email,
line_items: [{ price: req.body.priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${BASE_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/pricing`,
metadata: { userId: req.user.id },
});
res.json({ url: session.url });
});
Step 3: Handle webhooks (critical)
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body, req.headers['stripe-signature'], WEBHOOK_SECRET
);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
await db.user.update({
where: { id: session.metadata.userId },
data: {
stripeCustomerId: session.customer,
subscriptionId: session.subscription,
plan: 'pro',
},
});
break;
}
case 'invoice.payment_succeeded': {
// Subscription renewed successfully
const invoice = event.data.object;
await db.user.update({
where: { stripeCustomerId: invoice.customer },
data: { plan: 'pro', currentPeriodEnd: new Date(invoice.period_end * 1000) },
});
break;
}
case 'invoice.payment_failed': {
// Payment failed — notify user, may need to downgrade
const invoice = event.data.object;
await sendPaymentFailedEmail(invoice.customer);
break;
}
case 'customer.subscription.deleted': {
// Subscription cancelled and period ended
const subscription = event.data.object;
await db.user.update({
where: { stripeCustomerId: subscription.customer },
data: { plan: 'free' },
});
break;
}
}
res.status(200).json({ received: true });
});
Step 4: Customer portal
// Let users manage their own subscription
app.post('/api/billing-portal', async (req, res) => {
const session = await stripe.billingPortal.sessions.create({
customer: req.user.stripeCustomerId,
return_url: `${BASE_URL}/dashboard`,
});
res.json({ url: session.url });
});
Pros: Most flexible. Lower fees (2.9% + $0.30). Huge ecosystem. Cons: Tax compliance is on you. More code to write.
Essential Billing Features
1. Trial Periods
// Stripe
const session = await stripe.checkout.sessions.create({
subscription_data: { trial_period_days: 14 },
// ...
});
2. Dunning (Failed Payment Recovery)
Configure in Stripe Dashboard → Settings → Billing → Subscriptions:
- Retry failed payments (3 attempts over 2 weeks)
- Send email on each retry
- Cancel or pause subscription after all retries fail
This alone recovers 10-15% of churning revenue.
3. Proration
When users upgrade mid-cycle, charge the difference. Stripe handles this automatically:
await stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
4. Usage-Based Billing
// Report usage to Stripe
await stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{ quantity: 150, timestamp: Math.floor(Date.now() / 1000) }
);
5. Coupons and Promotions
const coupon = await stripe.coupons.create({
percent_off: 20,
duration: 'repeating',
duration_in_months: 3,
});
Database Schema
At minimum, store this per user:
ALTER TABLE users ADD COLUMN stripe_customer_id TEXT;
ALTER TABLE users ADD COLUMN subscription_id TEXT;
ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free';
ALTER TABLE users ADD COLUMN current_period_end TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN cancelled_at TIMESTAMPTZ;
Critical rule: Always trust webhooks over client-side state. A user's plan should only change when you receive a webhook confirmation, never on checkout redirect.
Common Mistakes
1. Trusting the Success URL
The checkout success redirect is NOT confirmation of payment. A user can navigate to your success URL manually. Always wait for the checkout.session.completed webhook.
2. Not Handling Downgrades
When a subscription is cancelled, grant access until the period end:
function hasActiveSubscription(user) {
if (user.plan === 'free') return false;
if (user.cancelledAt && user.currentPeriodEnd < new Date()) return false;
return true;
}
3. Ignoring Failed Payments
Set up proper dunning. Send users clear emails: "Your payment failed. Update your card to keep your Pro features." Don't just silently downgrade.
4. Not Offering Annual Plans
Annual plans reduce churn by 30-50% and improve cash flow. Price them at a 15-20% discount from monthly.
5. Making Pricing Too Complex
Most SaaS needs exactly 3 plans: Free, Pro, Enterprise. Maybe add Team. More than 4 plans creates decision paralysis.
FAQ
Should I offer a free plan?
Yes, for developer tools and B2C products. Maybe not for B2B enterprise tools. A free plan drives adoption and creates upgrade opportunities.
When should I switch from Lemon Squeezy to Stripe?
When you need custom billing logic (usage-based, per-seat, complex prorations), or when the 5% + $0.50 fee exceeds Stripe's 2.9% + $0.30 significantly. Usually around $5K-10K MRR.
Do I need to handle refunds?
Yes. Build a refund flow. For Stripe: await stripe.refunds.create({ payment_intent: '...' }). For Lemon Squeezy: refund via dashboard or API.
How do I handle VAT for EU customers?
Use an MoR (Lemon Squeezy, Paddle) to avoid this entirely. With Stripe, use Stripe Tax ($0.50/transaction) for automated tax calculation and collection — but you still need to register and remit in applicable jurisdictions.
The Bottom Line
- Starting out? Use Lemon Squeezy. Ship in a day. Worry about billing optimization later.
- Growing? Move to Stripe Billing when you need flexibility. Use Stripe Tax for compliance.
- At scale? Build custom billing logic on top of Stripe's primitives.
Don't over-engineer billing. The best billing system is the one that ships this week and collects money while you improve everything else.