Stripe vs LemonSqueezy 2026: Add Payments to Your App
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.
Stripe: Full-Featured Payments
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 Event | LemonSqueezy Event | Meaning |
|---|---|---|
checkout.session.completed | order_created | Grant access |
customer.subscription.created | subscription_created | Grant access |
invoice.payment_succeeded | subscription_payment_success | Renewal confirmed |
invoice.payment_failed | subscription_payment_failed | Begin dunning |
customer.subscription.deleted | subscription_cancelled | Revoke access |
customer.subscription.updated | subscription_updated | Plan change |
Pricing Structure Comparison
| Factor | Stripe | LemonSqueezy |
|---|---|---|
| Transaction fee | 2.9% + $0.30 | 5% + $0.50 |
| Tax handling | You (Stripe Tax available) | LemonSqueezy handles all |
| Monthly $100 transaction fee | $3.20 | $5.50 |
| Monthly $1,000 transaction fee | $32 | $55 |
| Tax overhead | Accountant / Stripe Tax cost | Included |
| Chargebacks | You handle | LemonSqueezy handles |
| Merchant of Record | No | Yes |
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
| Package | Weekly Downloads | Notes |
|---|---|---|
stripe | ~3M | Official Stripe Node.js SDK |
@stripe/stripe-js | ~4.5M | Client-side Stripe.js |
@lemonsqueezy/lemonsqueezy-js | ~20K | Official 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.
Internal Links
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.
See the live comparison
View stripe vs. lemonsqueezy on PkgPulse →