Skip to main content

Stripe vs LemonSqueezy 2026: Add Payments to Your App

·PkgPulse Team
0

TL;DR

Stripe for full control and complex billing. LemonSqueezy as Merchant of Record (MoR) for indie developers and small teams. Stripe (~3M weekly downloads) gives you complete control over the payment experience — but you are responsible for tax compliance, fraud, and chargebacks globally. LemonSqueezy acts as the MoR: they collect and remit VAT and sales tax to every jurisdiction, so your legal exposure is near-zero. The fee difference (Stripe 2.9% + $0.30 vs LemonSqueezy 5% + $0.50) often costs less than a tax consultant or accountant once you cross into international sales.

Key Takeaways

  • Stripe: ~3M npm downloads/week — best documentation, most flexible, industry standard
  • LemonSqueezy: ~20K npm downloads — Merchant of Record handles all tax globally
  • The MoR distinction is the whole decision — if you do not want to think about VAT, use LemonSqueezy or Paddle
  • Stripe: 2.9% + $0.30 per transaction — LemonSqueezy: 5% + $0.50 (but tax compliance included)
  • Webhooks are mandatory — both require a webhook handler to grant/revoke access

The Merchant of Record Distinction

This is the most important concept for choosing between these tools:

Stripe (not a MoR): When you receive a payment via Stripe, you are the seller. You are responsible for:

  • Collecting and remitting VAT to the EU, UK, Australia, etc.
  • Collecting and remitting sales tax to US states (economic nexus rules apply)
  • Handling chargebacks and fraud disputes
  • PCI compliance (Stripe helps but you have obligations)
  • Filing tax returns in jurisdictions where you have customers

Stripe Tax (addon) helps automate some of this, but it adds cost and complexity, and you are still the legal entity responsible for the tax liability.

LemonSqueezy (MoR): When a customer buys through LemonSqueezy, LemonSqueezy is the seller of record. They:

  • Collect all applicable taxes at checkout (VAT, GST, sales tax)
  • Remit those taxes to every government on your behalf
  • Handle chargebacks and disputes
  • Own the PCI compliance
  • Issue receipts and invoices compliant with local requirements

You receive the net amount (sale price minus their fee minus taxes) as a vendor payment. For tax purposes in many jurisdictions, this is personal or business income, not "sales" — dramatically simplifying your accounting.

Bottom line: If you expect significant international sales and do not have an accountant handling your tax filings, LemonSqueezy's fee premium is almost certainly worth it.


npm install stripe                                    # Server SDK
npm install @stripe/stripe-js @stripe/react-stripe-js  # Client (if embedding UI)

Creating a Checkout Session

// app/api/checkout/route.ts — Next.js Route Handler
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-06-20',
});

export async function POST(request: Request) {
  const { priceId, userId, userEmail } = await request.json();

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',           // 'payment' for one-time
    payment_method_types: ['card'],
    customer_email: userEmail,
    line_items: [{ price: priceId, quantity: 1 }],
    metadata: { userId },           // Passed through to webhook
    subscription_data: {
      trial_period_days: 14,        // Optional free trial
      metadata: { userId },
    },
    allow_promotion_codes: true,
    billing_address_collection: 'auto',
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
  });

  return Response.json({ url: session.url });
}
// Client component — redirect to Stripe-hosted checkout page
function UpgradeButton({ priceId }: { priceId: string }) {
  const handleCheckout = async () => {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ priceId }),
    });
    const { url } = await res.json();
    window.location.href = url;
  };

  return (
    <button onClick={handleCheckout} className="btn-primary">
      Upgrade to Pro
    </button>
  );
}

Stripe Webhooks (Critical)

Webhooks are how your app learns that a payment succeeded, a subscription was cancelled, or a payment failed. Without webhooks, your app cannot reliably grant or revoke access.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature')!;

  // Verify the webhook signature — never skip this
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch {
    return new Response('Invalid webhook signature', { status: 400 });
  }

  switch (event.type) {
    // Payment or subscription activated
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.CheckoutSession;
      const userId = session.metadata?.userId;
      if (userId) {
        await db.update(users)
          .set({
            plan: 'pro',
            stripeCustomerId: session.customer as string,
            updatedAt: new Date(),
          })
          .where(eq(users.id, parseInt(userId)));
      }
      break;
    }

    // Subscription renewed successfully
    case 'invoice.payment_succeeded': {
      const invoice = event.data.object as Stripe.Invoice;
      // Update subscription expiry or send renewal receipt
      break;
    }

    // Payment failed — begin dunning sequence
    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      // Send payment failure email, restrict access after grace period
      break;
    }

    // Subscription cancelled (immediate or at period end)
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      const userId = subscription.metadata?.userId;
      if (userId) {
        await db.update(users)
          .set({ plan: 'free', updatedAt: new Date() })
          .where(eq(users.id, parseInt(userId)));
      }
      break;
    }
  }

  return new Response('ok', { status: 200 });
}
# Local development — forward Stripe events to localhost
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Stripe CLI prints a webhook signing secret — use it as STRIPE_WEBHOOK_SECRET

LemonSqueezy: Merchant of Record

npm install @lemonsqueezy/lemonsqueezy-js

Creating a Checkout URL

// lib/lemonsqueezy.ts
import { lemonSqueezySetup, createCheckout } from '@lemonsqueezy/lemonsqueezy-js';

lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY! });

export async function createLSCheckoutUrl({
  variantId,
  userId,
  userEmail,
}: {
  variantId: string;
  userId: string;
  userEmail: string;
}) {
  const { data, error } = await createCheckout(
    process.env.LEMONSQUEEZY_STORE_ID!,
    variantId,
    {
      checkoutData: {
        email: userEmail,
        custom: { user_id: userId },  // Passed through to webhook
      },
      checkoutOptions: {
        dark: true,
        logo: true,
      },
      productOptions: {
        redirectUrl: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
        receiptButtonText: 'Go to Dashboard',
        receiptThankYouNote: 'Thanks for subscribing!',
      },
    }
  );

  if (error) throw new Error(error.message);
  return data?.data.attributes.url;
}
// app/api/lemonsqueezy/checkout/route.ts
import { createLSCheckoutUrl } from '@/lib/lemonsqueezy';
import { auth } from '@clerk/nextjs/server';

export async function POST(request: Request) {
  const { userId } = await auth();
  if (!userId) return new Response('Unauthorized', { status: 401 });

  const { variantId, email } = await request.json();
  const url = await createLSCheckoutUrl({ variantId, userId, userEmail: email });

  return Response.json({ url });
}

LemonSqueezy Webhooks

// app/api/webhooks/lemonsqueezy/route.ts
import crypto from 'crypto';
import { db } from '@/db';
import { users } from '@/db/schema';
import { eq } from 'drizzle-orm';

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('x-signature')!;

  // Verify webhook signature
  const hash = crypto
    .createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!)
    .update(body)
    .digest('hex');

  if (hash !== signature) {
    return new Response('Invalid signature', { status: 400 });
  }

  const event = JSON.parse(body);
  const userId = event.meta.custom_data?.user_id;

  switch (event.meta.event_name) {
    // Subscription created or one-time purchase
    case 'order_created':
    case 'subscription_created': {
      if (userId) {
        await db.update(users)
          .set({ plan: 'pro', updatedAt: new Date() })
          .where(eq(users.id, parseInt(userId)));
      }
      break;
    }

    // Payment for subscription renewal succeeded
    case 'subscription_payment_success': {
      // Update subscription expiry, send confirmation
      break;
    }

    // Subscription cancelled
    case 'subscription_cancelled':
    case 'subscription_expired': {
      if (userId) {
        await db.update(users)
          .set({ plan: 'free', updatedAt: new Date() })
          .where(eq(users.id, parseInt(userId)));
      }
      break;
    }

    // Payment failed
    case 'subscription_payment_failed': {
      // Send dunning email
      break;
    }
  }

  return new Response('ok', { status: 200 });
}

Critical Webhook Events Reference

Handle these events in both integrations:

Stripe EventLemonSqueezy EventMeaning
checkout.session.completedorder_createdGrant access
customer.subscription.createdsubscription_createdGrant access
invoice.payment_succeededsubscription_payment_successRenewal confirmed
invoice.payment_failedsubscription_payment_failedBegin dunning
customer.subscription.deletedsubscription_cancelledRevoke access
customer.subscription.updatedsubscription_updatedPlan change

Pricing Structure Comparison

FactorStripeLemonSqueezy
Transaction fee2.9% + $0.305% + $0.50
Tax handlingYou (Stripe Tax available)LemonSqueezy handles all
Monthly $100 transaction fee$3.20$5.50
Monthly $1,000 transaction fee$32$55
Tax overheadAccountant / Stripe Tax costIncluded
ChargebacksYou handleLemonSqueezy handles
Merchant of RecordNoYes

Effective cost calculation: For a solo developer with international customers, add $500–2,000/year for a tax accountant or Stripe Tax fees when using Stripe. That changes the break-even point significantly.


When to Choose Stripe

  • You are past $10K MRR — Stripe's lower per-transaction fee compounds meaningfully at scale
  • Complex billing logic — metered billing, usage-based pricing, volume discounts
  • Marketplace / Stripe Connect — payment splitting between platform and sellers
  • Physical goods — LemonSqueezy focuses on digital products
  • You have a tax advisor or entity in a single jurisdiction — the compliance overhead is manageable
  • You need custom checkout UI — Stripe Elements lets you embed a custom payment form
  • Enterprise customers — many large companies require invoices with your entity details

When to Choose LemonSqueezy

  • Solo developer or small team — do not hire a tax accountant, just avoid the problem
  • Digital products — software, SaaS, e-books, templates, courses
  • Global sales from day one — EU VAT, UK VAT, Australian GST handled automatically
  • Simplicity over control — less code, less compliance overhead, less to think about
  • Under $10K MRR — the fee premium is small in absolute terms
  • Want the MoR to absorb chargeback risk — LemonSqueezy disputes chargebacks on your behalf

Package Health

PackageWeekly DownloadsNotes
stripe~3MOfficial Stripe Node.js SDK
@stripe/stripe-js~4.5MClient-side Stripe.js
@lemonsqueezy/lemonsqueezy-js~20KOfficial LemonSqueezy SDK

Free Trials and Conversion Optimization

Both platforms support free trials, but the implementation details affect conversion rates in measurable ways. Stripe's trial implementation allows creating a subscription with a trial_period_days parameter — the customer is not charged until the trial ends, but their payment method must be collected upfront (or not, depending on your configuration). Collecting the card at trial start consistently produces higher conversion rates than requiring payment at trial end because it removes the friction of a second decision point. Stripe's payment_behavior: 'default_incomplete' lets you create a subscription without immediately charging, which enables the "credit card required, charged at trial end" flow. LemonSqueezy's trial implementation similarly collects the payment method upfront for subscriptions and bills at the end of the trial period. Both platforms send automated emails reminding customers before their trial ends, which can be customized or disabled in favor of your own reminder emails triggered from webhook events.

Handling Subscription Upgrades and Plan Changes

Midcycle plan changes — a user upgrading from a $10/month plan to a $50/month plan on day 15 of a 30-day billing cycle — require careful handling in both platforms. Stripe's default behavior prorates the charge: the customer is immediately charged for the remaining days at the higher price, and their next invoice reflects the new plan amount. This prorated charge appears as a credit on the customer's account and an immediate charge for the difference. LemonSqueezy handles upgrades at the next billing cycle by default, which is simpler to reason about but means the customer has access to premium features without paying the premium price until renewal. For SaaS products where the upgrade delivers immediate value, Stripe's prorated immediate charge is the more defensible business model. Both platforms expose this behavior through their subscription update API, allowing you to override the default per upgrade event.

Implementation Considerations

Whichever payment processor you choose, the webhook handler deserves careful architecture from day one. Webhooks are the source of truth for payment events — your checkout completion, subscription renewal, failed charge, and refund flows all depend on reliably processing incoming webhook events. Common mistakes include: not verifying webhook signatures before processing (critical security vulnerability), processing events synchronously in the webhook handler itself (risks timeout failures for slow operations), and not implementing idempotency checks (Stripe retries failed webhooks, so your handler may receive the same event multiple times).

The recommended pattern for both Stripe and LemonSqueezy: verify the signature, write the raw event to a database table, return 200 immediately, and process the event asynchronously via a background job queue. This pattern makes your webhook handler nearly indestructible — it can't timeout, it can replay failed events, and it provides an audit trail of all payment events.

Subscription billing with either platform requires careful handling of the dunning cycle — the sequence of retry attempts and notification emails when a subscription charge fails. Stripe Billing's built-in Smart Retries uses ML to time retry attempts when card approval probability is highest. LemonSqueezy's dunning is less configurable but handled automatically. For products where subscription churn prevention matters (most SaaS products), understanding and configuring the dunning cycle is as important as the initial checkout implementation.

Testing payments before going live: both platforms offer test mode with specific card numbers that trigger different responses (success, decline, insufficient funds, 3D Secure). Never skip the test phase — payment edge cases (card decline at checkout, subscription renewal failures, prorated upgrades) are difficult to debug in production and create poor user experiences when they fail silently. Build a complete test suite covering the happy path, payment decline handling, webhook receipt and idempotency, and the full subscription lifecycle events including cancellation and renewal before accepting your first real payment in production.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.