← Back to articles

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

ApproachComplexityTax HandlingBest For
Lemon SqueezyLowHandled (MoR)Solo founders, indie SaaS
Stripe BillingMediumYou handle itFunded startups, scaling SaaS
PaddleLowHandled (MoR)Growing SaaS, global sales
Custom on StripeHighYou handle itComplex 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

  1. Starting out? Use Lemon Squeezy. Ship in a day. Worry about billing optimization later.
  2. Growing? Move to Stripe Billing when you need flexibility. Use Stripe Tax for compliance.
  3. 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.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.