Skip to main content

How to Add Payments to Your App: Stripe vs LemonSqueezy (2026)

·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

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.