Skip to main content

Best Payment Integration Libraries for Node.js in 2026

·PkgPulse Team

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)

Stripe (Full Control)

// Stripe — checkout session (hosted payment page)
import Stripe from 'stripe';

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

// app/api/checkout/route.ts
export async function POST(req: Request) {
  const { priceId, userId } = await req.json();

  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&session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.APP_URL}/pricing`,
    client_reference_id: userId,
    customer_email: (await getUser(userId)).email,
    metadata: { userId },
    // Collect billing address for tax
    billing_address_collection: 'required',
    // Automatic tax calculation (requires Stripe Tax setup)
    automatic_tax: { enabled: true },
  });

  return Response.json({ url: session.url });
}
// Stripe — webhooks (handle payment events)
// app/api/webhook/stripe/route.ts
import Stripe from 'stripe';

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

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

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    return new Response(`Webhook Error: ${err.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;
      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');
}
// Stripe — subscription management
async function getCustomerPortalUrl(customerId: string) {
  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.APP_URL}/dashboard`,
  });
  return session.url;
}

// Proration — upgrade/downgrade mid-cycle
async function upgradeSubscription(subscriptionId: string, newPriceId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);

  await stripe.subscriptions.update(subscriptionId, {
    items: [{
      id: subscription.items.data[0].id,
      price: newPriceId,
    }],
    proration_behavior: 'create_prorations',
  });
}

LemonSqueezy (Merchant of Record)

// LemonSqueezy — checkout URL generation
const response = await fetch('https://api.lemonsqueezy.com/v1/checkouts', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
    'Content-Type': 'application/vnd.api+json',
    'Accept': 'application/vnd.api+json',
  },
  body: JSON.stringify({
    data: {
      type: 'checkouts',
      attributes: {
        checkout_data: {
          custom: { userId: userId },
          email: user.email,
          name: user.name,
        },
        product_options: {
          redirect_url: `${process.env.APP_URL}/dashboard?success=true`,
        },
      },
      relationships: {
        store: { data: { type: 'stores', id: process.env.LEMONSQUEEZY_STORE_ID } },
        variant: { data: { type: 'variants', id: variantId } },
      },
    },
  }),
});

const { data } = await response.json();
const checkoutUrl = data.attributes.url;
// Redirect user to checkoutUrl
// 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 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 = 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_cancelled':
      await scheduleSubscriptionCancellation(event.data.id, event.data.attributes.ends_at);
      break;
    case 'subscription_payment_failed':
      await notifyPaymentFailed(customData.userId);
      break;
  }

  return new Response('OK');
}

Comparison: MoR vs Direct

AspectStripeLemonSqueezyPaddle
Tax handlingManual (or +0.5% Stripe Tax)Automatic (they collect)Automatic (they collect)
VAT/GST complianceYou (complex)They handleThey handle
Fee2.9% + $0.305% + $0.505% + $0.50
PayoutDirect to bankWeekly/monthlyWeekly/monthly
RefundsStripe handlesLemonSqueezy handlesPaddle handles
Best forHigh-volume, enterpriseIndie SaaSB2B SaaS
Checkout UIHosted or customHostedHosted

When to Choose

ScenarioPick
Enterprise with legal/tax teamStripe
Indie SaaS, don't want tax headachesLemonSqueezy
B2B sales with invoicingPaddle
High volume (>$1M ARR)Stripe (lower fees at scale)
Digital goods, one-time salesLemonSqueezy
Need subscription management portalAll three (built-in)
EU VAT compliance headachesLemonSqueezy or Paddle

Compare payment library package health on PkgPulse.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.