How to Add Payments to Your App: Stripe vs LemonSqueezy (2026)
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 |
Internal Links
See the live comparison
View stripe vs. lemonsqueezy on PkgPulse →