Best Payment Integration Libraries for Node.js in 2026
TL;DR
Stripe for full control; LemonSqueezy or Paddle for Merchant of Record (they handle global taxes). Stripe (~4M weekly downloads) is the gold standard — every feature exists, rock-solid API, but you're responsible for VAT/sales tax collection. LemonSqueezy (~100K) and Paddle (~50K) are Merchant of Record services — they handle tax compliance in 100+ countries. For SaaS, MoR services save months of compliance work.
Key Takeaways
- Stripe: ~4M weekly downloads — most features, best API, you handle tax compliance
- LemonSqueezy: ~100K downloads — Merchant of Record, developer-friendly, flat 5% + $0.50 fee
- Paddle: ~50K downloads — MoR, B2B-friendly, handles invoicing and compliance
- MoR vs Direct — LemonSqueezy/Paddle collect taxes on your behalf; Stripe you do it yourself
- Stripe Tax — Stripe now offers auto-tax calculation (adds ~0.5% per transaction)
The Payment Stack Decision
Accepting payments sounds straightforward. In practice, it is one of the most consequential technology decisions a SaaS founder makes — one that affects revenue recovery, global expansion, tax compliance, and operational overhead for years.
The core question in 2026 is not just "which library has the best API?" but "do I want to be responsible for global tax compliance?" VAT collection in the EU, GST in Australia, sales tax in US states, JCT in Japan — the list of jurisdictions requiring tax registration grows every year. Getting this wrong can result in significant fines and back-payment obligations.
This article covers three options that span the spectrum from maximum control to maximum delegation: Stripe (direct processor), LemonSqueezy (indie-friendly Merchant of Record), and Paddle (enterprise-focused Merchant of Record). Each has a distinct use case, and knowing which applies to your situation is more valuable than comparing API ergonomics alone.
Stripe: The Gold Standard Direct Processor
Stripe (~4M weekly npm downloads) is the most capable payment processor available to developers. The API is comprehensive, the documentation is excellent, the test mode is seamlessly integrated, and the ecosystem of integrations is unmatched. If you know you need Stripe, you almost certainly do — nothing else comes close in raw functionality.
The trade-off is responsibility. As a direct processor, Stripe moves money on your behalf, but you are the merchant of record. That means you are responsible for collecting and remitting VAT in EU countries, handling US state sales tax (45 states have different rules), GST/HST in Canada, Australia, and New Zealand, and maintaining PCI compliance for your integration.
For teams with legal infrastructure, a US-only customer base, or high transaction volumes where fee savings outweigh compliance overhead, Stripe is the right choice.
npm install stripe
// Stripe — create a customer and subscription
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// Create a customer when user signs up
async function createStripeCustomer(userId: string, email: string, name: string) {
const customer = await stripe.customers.create({
email,
name,
metadata: { userId }, // Link Stripe customer to your user ID
});
await db.user.update({
where: { id: userId },
data: { stripeCustomerId: customer.id },
});
return customer;
}
// Create a subscription for an existing customer
async function createSubscription(customerId: string, priceId: string) {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
});
return subscription;
}
// Stripe — hosted checkout session (recommended for most cases)
// app/api/checkout/route.ts
export async function POST(req: Request) {
const { priceId, userId } = await req.json();
const user = await getUser(userId);
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.APP_URL}/pricing`,
client_reference_id: userId,
customer_email: user.email,
metadata: { userId },
billing_address_collection: 'required',
// Stripe Tax: auto-calculate taxes (requires Tax setup in Dashboard)
automatic_tax: { enabled: true },
});
return Response.json({ url: session.url });
}
Handling the customer.subscription.updated Webhook
Webhooks are how Stripe communicates subscription lifecycle events to your application. The customer.subscription.updated event fires on plan changes, payment method updates, trial endings, and dunning (failed payment recovery). Your handler must be idempotent — Stripe may deliver the same event more than once.
// Stripe — webhook handler with signature verification
// app/api/webhook/stripe/route.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
// constructEvent verifies the webhook signature — never skip this
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await activateSubscription(
session.client_reference_id!,
session.subscription as string
);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
// Fires on: plan change, trial end, payment method update, pause
await updateSubscriptionStatus(subscription.id, subscription.status);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await cancelSubscription(subscription.id);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await notifyPaymentFailed(invoice.customer as string);
break;
}
}
return new Response('OK');
}
Testing with stripe-cli
The Stripe CLI is the fastest way to test webhooks locally without deploying. It forwards events from Stripe's servers to your localhost and lets you trigger specific events on demand:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login and forward webhooks to local server
stripe login
stripe listen --forward-to localhost:3000/api/webhook/stripe
# Trigger specific events for testing
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger checkout.session.completed
The CLI prints your webhook signing secret during stripe listen. Use that as STRIPE_WEBHOOK_SECRET in your .env.local. This lets you test the full payment and webhook flow locally without mocking.
Stripe Tax Add-on
With automatic_tax: { enabled: true } in your checkout session, Stripe automatically calculates and adds the correct tax for each customer's location. You need to enable Stripe Tax in the Dashboard, register your tax locations, and set product tax codes. Cost is approximately 0.5% per transaction. This handles the calculation and reporting, but you remain the merchant of record — Stripe Tax automates the math, not the legal obligation.
LemonSqueezy: Merchant of Record for Indie SaaS
LemonSqueezy (~100K downloads) is a Merchant of Record payment platform designed specifically for indie hackers and small SaaS teams. As the MoR, LemonSqueezy is the legal seller — they collect VAT, GST, and sales tax in every jurisdiction they operate in (100+ countries). You never need to register for tax in any country where your customers are located.
The fee is 5% + $0.50 per transaction — higher than Stripe's 2.9% + $0.30, but includes all tax compliance work, EU VAT handling, and their built-in billing portal. The premium over Stripe goes away quickly when you factor in the cost of tax compliance infrastructure.
npm install @lemonsqueezy/lemonsqueezy.js
// LemonSqueezy — create a checkout link
import { lemonSqueezySetup, createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
lemonSqueezySetup({ apiKey: process.env.LEMONSQUEEZY_API_KEY! });
async function createLemonSqueezyCheckout(
userId: string,
variantId: number,
userEmail: string
) {
const { data, error } = await createCheckout(
process.env.LEMONSQUEEZY_STORE_ID!,
variantId,
{
checkoutData: {
email: userEmail,
custom: { userId }, // Passed back in webhook payload as meta.custom_data
},
productOptions: {
redirectUrl: `${process.env.APP_URL}/dashboard?success=true`,
receiptButtonText: 'Go to Dashboard',
},
}
);
if (error) throw new Error(error.message);
return data?.data.attributes.url;
}
// LemonSqueezy — webhook handler
// app/api/webhook/lemonsqueezy/route.ts
import crypto from 'crypto';
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get('x-signature')!;
// Verify HMAC-SHA256 signature
const hash = crypto
.createHmac('sha256', process.env.LEMONSQUEEZY_WEBHOOK_SECRET!)
.update(body)
.digest('hex');
if (hash !== signature) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
const eventName: string = event.meta.event_name;
const customData = event.meta.custom_data;
switch (eventName) {
case 'order_created':
await activateUser(customData.userId, event.data.id);
break;
case 'subscription_created':
await createSubscription(customData.userId, event.data);
break;
case 'subscription_updated':
await updateSubscription(event.data.id, event.data.attributes);
break;
case 'subscription_cancelled':
// User cancelled — still active until ends_at
await scheduleSubscriptionCancellation(
event.data.id,
event.data.attributes.ends_at
);
break;
case 'subscription_payment_failed':
await notifyPaymentFailed(customData.userId);
break;
}
return new Response('OK');
}
Getting Subscription Status from LemonSqueezy
// LemonSqueezy — get subscription status
import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js';
async function checkSubscriptionStatus(subscriptionId: string) {
const { data, error } = await getSubscription(subscriptionId);
if (error) throw new Error(error.message);
const attrs = data?.data.attributes;
return {
status: attrs?.status, // 'active', 'paused', 'cancelled', 'expired'
renewsAt: attrs?.renews_at,
endsAt: attrs?.ends_at,
planName: attrs?.product_name,
};
}
Paddle: Merchant of Record with B2B Focus
Paddle (~50K npm downloads) occupies the space between LemonSqueezy and enterprise billing platforms. As a Merchant of Record, Paddle handles the same global tax compliance as LemonSqueezy, but the product is oriented toward B2B SaaS — teams that need EU VAT reverse charge for business customers, annual contracts with invoice generation, seat-based pricing, and localized pricing displayed in local currencies.
Where LemonSqueezy is designed for a solo developer to get up and running in an afternoon, Paddle is designed for a SaaS company that expects to scale, close enterprise deals, and needs billing infrastructure that can handle complexity. Their pricing is typically negotiated for higher-volume accounts (generally starting around 5% + $0.50 for early-stage, with volume discounts available as ARR grows).
// Paddle.js — initialize and open a checkout
// Add to your layout: <script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
Paddle.Initialize({
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
eventCallback: function(data) {
if (data.name === 'checkout.completed') {
const transactionId = data.data.transaction_id;
window.location.href = `/dashboard?transaction=${transactionId}`;
}
}
});
// Open checkout from a button
function openCheckout(priceId, userId) {
Paddle.Checkout.open({
items: [{ priceId, quantity: 1 }],
customData: { userId },
});
}
// Paddle — webhook handler
// app/api/webhook/paddle/route.ts
import crypto from 'crypto';
export async function POST(req: Request) {
const body = await req.text();
const signatureHeader = req.headers.get('paddle-signature')!;
const parts = Object.fromEntries(
signatureHeader.split(';').map(p => p.split('=') as [string, string])
);
const expectedHash = crypto
.createHmac('sha256', process.env.PADDLE_WEBHOOK_SECRET!)
.update(`${parts.ts}:${body}`)
.digest('hex');
if (expectedHash !== parts.h1) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(body);
switch (event.event_type) {
case 'transaction.completed':
await handleTransactionCompleted(event.data);
break;
case 'subscription.activated':
await handleSubscriptionActivated(event.data);
break;
case 'subscription.canceled':
await handleSubscriptionCanceled(event.data);
break;
}
return new Response('OK');
}
Stripe vs MoR: The Core Decision
The most important payment architecture decision is whether you want to be the Merchant of Record or delegate that responsibility. This choice has implications that compound over time as your product expands internationally.
When Stripe is the right choice: US-primary customers where you can defer international tax compliance. ARR above $500K where the fee difference (~2%) meaningfully impacts margins. Need for features MoR services don't provide — Stripe Connect for marketplaces, Stripe Issuing for card programs, complex proration on mid-cycle plan changes. Presence of a legal/finance team to handle tax registration and compliance.
When LemonSqueezy or Paddle is the right choice: Selling globally from day one without wanting to deal with VAT/GST registration in each jurisdiction. Solo developer or small team without dedicated finance resources. Wanting to launch fast — MoR services remove the compliance layer entirely. The 5% fee being acceptable relative to the operational overhead of running your own compliance.
The 5% vs 2.9% fee math at different revenue levels: at $100K ARR, the difference is roughly $2,100/year — less than one hour of a tax lawyer's time per month. At $1M ARR, it's $21K/year — more meaningful, but still potentially less than the cost of tax compliance infrastructure. The real crossover point where Stripe becomes clearly cheaper is typically well into multi-million ARR territory, usually when you've already hired the finance team that can manage compliance anyway.
Webhook Security: A Universal Pattern
All three payment providers use HMAC-SHA256 signature verification for webhooks. The pattern is always the same: compute a signature from the raw request body using a shared secret, then compare it to the signature in the request headers. Never skip this verification — unsigned webhooks would allow anyone to trigger subscription activations or cancellations in your system.
The critical implementation detail: always read the raw request body before any JSON parsing. Body parsers can subtly modify the byte representation in ways that break signature verification. In Next.js App Router, await req.text() returns the raw body. Parse it yourself with JSON.parse() after verifying the signature.
// The universal webhook verification pattern
const rawBody = await req.text(); // Read raw body FIRST
const signature = req.headers.get('x-signature')!;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
if (expectedSig !== signature) {
return new Response('Unauthorized', { status: 401 });
}
const event = JSON.parse(rawBody); // Parse AFTER verification
Stripe's stripe.webhooks.constructEvent(body, sig, secret) wraps this pattern for you. LemonSqueezy and Paddle require the manual approach shown above.
Testing Webhooks Locally
One of the most common friction points when building payment integrations is testing webhook events during development. Without a publicly accessible server, the payment provider's webhook system can't reach your localhost. The standard solutions:
Stripe CLI is the gold standard for local webhook testing. It creates a persistent tunnel, forwards production-equivalent events to your local server, and lets you trigger specific event types on demand:
brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:3000/api/webhook/stripe
# In another terminal:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
For LemonSqueezy and Paddle, there's no official CLI. Use ngrok (free tier) or Cloudflare Tunnel to expose a public URL that forwards to localhost. Configure that URL as your webhook endpoint in each provider's dashboard during development.
For automated testing, mock the webhook handler directly by calling it with a fake event object, bypassing the signature verification step (or using a test secret). Unit test the business logic functions (activateSubscription, cancelSubscription) separately from the webhook routing.
Subscription State Modeling
Regardless of which provider you use, your database subscription model needs to track the same core concepts. A robust subscriptions table typically includes: the provider-specific subscription ID, the current status, the period end date (for cancellation-at-period-end logic), and the plan or price ID.
The most common bug in payment integrations is confusing canceled with "access denied." In all three providers, when a user cancels, their subscription typically remains active until the end of the current billing period. canceled in Stripe means the subscription won't renew — the user can still use the product until current_period_end. Your access check should be:
// Correct subscription access check:
function hasActiveAccess(subscription: UserSubscription): boolean {
if (subscription.status === 'active') return true;
if (subscription.status === 'trialing') return true;
if (subscription.status === 'past_due') return true; // Grace period during retry
if (subscription.status === 'canceled') {
// Still have access until period ends:
return new Date() < new Date(subscription.currentPeriodEnd);
}
return false;
}
The past_due state is also frequently mishandled. Stripe's Smart Retries will automatically retry a failed payment over several days before moving to canceled. During this window the subscription is past_due — the user's payment failed but they haven't explicitly canceled. Most applications should maintain access during this window (possibly showing a warning banner) rather than immediately locking the user out.
Free Trials
All three providers support free trials. With Stripe, you add trial_period_days to the subscription creation call or to the Price object in the Dashboard. The subscription starts in trialing status and transitions to active when the trial ends and the first payment succeeds (or canceled/past_due if it fails).
LemonSqueezy supports trial periods on variant configuration. Paddle supports trials through the Dashboard or API. The key implementation requirement: require a payment method upfront (free trial with card required) or allow card-free trials (free trial without card). Card-required trials have higher conversion rates but higher abandonment in the trial signup flow.
For B2B SaaS, a 14-day free trial with a credit card requirement is the most common pattern. For self-serve PLG products, a freemium tier often outperforms a time-limited trial.
Package Health Comparison
| Library | Weekly Downloads | Merchant of Record | Transaction Fee | Subscriptions | Best For |
|---|---|---|---|---|---|
stripe | ~4M | No | 2.9% + $0.30 | Yes | Enterprise, US-primary, marketplaces |
@lemonsqueezy/lemonsqueezy.js | ~100K | Yes | 5% + $0.50 | Yes | Indie SaaS, global, digital goods |
paddle (JS SDK) | ~50K | Yes | ~5% + $0.50 | Yes | B2B SaaS, annual contracts, invoicing |
When to Choose
Stripe — Enterprise SaaS with a legal team, US-primary customers, or high volume where fee differences compound significantly. Stripe when you need Connect for marketplaces, fine-grained subscription control, or want maximum ecosystem compatibility. The stripe-cli makes local development workflow excellent.
LemonSqueezy — Indie hackers, solo developers, small teams selling globally. Best developer experience for getting a subscription flow live quickly without the tax compliance burden. The transparent 5% fee is predictable and MoR status handles EU VAT, Australian GST, and beyond automatically.
Paddle — B2B SaaS with annual contracts, invoicing requirements, EU VAT reverse charge for business customers, or seat-based pricing. More enterprise-oriented than LemonSqueezy while providing the same MoR tax benefits.
See the live comparison
View stripe vs. lemonsqueezy on PkgPulse →