· paddle / billing / payments

How to Set Up Paddle Billing for a Small SaaS (2026)

Step-by-step: add Paddle Billing to a Node.js/TypeScript SaaS. Covers product catalog, Paddle.js v2 overlay checkout, webhook verification, and customer portal.

By

1,887 words · 10 min read

If you’re selling a SaaS globally, Stripe’s default setup leaves tax compliance on your plate. Every EU country, Australia, Canada, and a growing list of US states each have their own VAT/GST thresholds, registration requirements, and filing deadlines. Paddle sidesteps all of it: as the Merchant of Record, Paddle is the legal seller in every transaction, so it calculates, collects, and remits taxes in 200+ countries. You get paid; Paddle handles the accountant’s job. The fee is 5% + $0.50 per transaction, no monthly charge.

This tutorial wires up Paddle Billing end-to-end — product catalog, overlay checkout, webhook handler, and customer portal — using the @paddle/paddle-node-sdk v3.x and Paddle.js v2.

Who this is for

Developers building a Node.js/TypeScript SaaS who want to accept subscriptions without touching international tax compliance. If you’re already above $50k–$100k MRR and have an accounting team handling VAT, read the comparison at the end first — Stripe may be cheaper at that scale.

Prerequisites

Before writing any code, create a sandbox Paddle account at sandbox-vendors.paddle.com. The sandbox is a completely separate account from live — credentials never cross environments.

From the sandbox dashboard, collect three things:

  1. API key — Developer Tools → Authentication → API Keys. Sandbox keys contain _sdbx in the identifier. Store in PADDLE_API_KEY.
  2. Client-side token — same Authentication page, different tab. Sandbox tokens start with test_. Store in PADDLE_CLIENT_TOKEN.
  3. Webhook secret — you’ll generate this in Step 3. Store in PADDLE_WEBHOOK_SECRET.

Install the SDK:

npm install @paddle/paddle-node-sdk
# or
yarn add @paddle/paddle-node-sdk
# or
pnpm add @paddle/paddle-node-sdk

Step 1: Create your product catalog

Paddle’s data model: products are what you sell (e.g. “Pro Plan”), prices define how much and how often. You need both before opening a checkout.

Create a product

import { Environment, Paddle } from '@paddle/paddle-node-sdk'

const paddle = new Paddle(process.env.PADDLE_API_KEY!, {
  environment: Environment.sandbox, // remove for production
})

const product = await paddle.products.create({
  name: 'My SaaS Pro',
  taxCategory: 'saas',   // use 'saas' for software — affects EU/UK VAT rates
  description: 'All features, unlimited usage.',
})
// product.id → "pro_01..."

Use taxCategory: 'saas' for software products. The wrong category changes the VAT rate shown to EU and UK customers — it’s not a cosmetic field.

Create prices

// Monthly subscription — $29/month with 14-day trial
const monthlyPrice = await paddle.prices.create({
  productId: product.id,
  description: 'Pro Monthly',
  unitPrice: { amount: '2900', currencyCode: 'USD' }, // amounts are in cents
  billingCycle: { interval: 'month', frequency: 1 },
  trialPeriod: { interval: 'day', frequency: 14 },
})
// monthlyPrice.id → "pri_01..."

// Annual subscription — $290/year (two months free)
const annualPrice = await paddle.prices.create({
  productId: product.id,
  description: 'Pro Annual',
  unitPrice: { amount: '29000', currencyCode: 'USD' },
  billingCycle: { interval: 'year', frequency: 1 },
})

One hard constraint: all subscription items on a single checkout must share the same billing interval. You cannot mix a monthly item and an annual item in one session. Enforce this on your pricing page — don’t let users click “Buy” with mismatched intervals, because Paddle will reject the checkout with a clear but unhelpful error.

Save the price IDs — you pass them to the frontend checkout in Step 2.

Step 2: Embed the checkout

Paddle uses an overlay checkout: a modal hosted on Paddle’s servers that opens on top of your page. No PCI scope on your end; Paddle handles all card data.

Add Paddle.js v2 to your page <head>. Your server must inject PADDLE_CLIENT_TOKEN into the page before Paddle.js runs — process.env is a Node.js API and does not exist in browsers. The client token is safe to expose: it is a read-only public identifier, not a secret API key.

<!-- Server injects the client token (safe to expose — it's a public identifier) -->
<script>window.PADDLE_CLIENT_TOKEN = '<%= process.env.PADDLE_CLIENT_TOKEN %>';</script>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>

The <%= ... %> above is an EJS template expression; substitute your own server’s template syntax ({{ }} for Jinja/Nunjucks, ${...} in a tagged template, a JSX data attribute, etc.).

Initialize it once when the page loads:

// Sandbox mode — remove this line in production
Paddle.Environment.set("sandbox");

Paddle.Initialize({
  token: window.PADDLE_CLIENT_TOKEN, // injected by the server above
  eventCallback: function(data) {
    if (data.name === "checkout.completed") {
      window.location.href = "/dashboard?welcome=1";
    }
  }
});

Open the checkout on a button click:

function openCheckout(priceId, userEmail, userId) {
  Paddle.Checkout.open({
    items: [{ priceId: priceId, quantity: 1 }],
    customer: {
      email: userEmail,        // prefills the email field
    },
    customData: {
      userId: userId,          // passed through to every webhook for your DB lookups
    },
  });
}
<button onclick="openCheckout('pri_01...', currentUser.email, currentUser.id)">
  Start Pro Trial
</button>

Passing customer.email skips the first checkout screen entirely — customers land directly on the payment entry screen. If you also pass customer.address.countryCode, Paddle can pre-calculate tax before the customer types anything. Both are worth setting.

The customData.userId field is your lifeline for correlating webhook events to your database. Without it, you have to query Paddle’s API to figure out which user just subscribed. Set it here; read it in Step 3.

Step 3: Handle webhooks

Webhooks are how Paddle tells you about subscription state changes, payment failures, and renewals. This is where subscriptions get provisioned in your database.

Configure a notification destination

In the Paddle sandbox dashboard: Developer Tools → Notifications → New destination. Enter your webhook URL (use ngrok for local development) and select these events at minimum:

  • transaction.completed
  • subscription.created
  • subscription.updated
  • subscription.canceled
  • transaction.payment_failed

Copy the endpoint secret key (starts with pdl_ntfset_) into PADDLE_WEBHOOK_SECRET. Each destination has its own key — don’t reuse one across staging and production.

Webhook handler with Express

import express from 'express'
import { Paddle, Environment } from '@paddle/paddle-node-sdk'

const app = express()
const paddle = new Paddle(process.env.PADDLE_API_KEY!, {
  environment: Environment.sandbox,
})

// Register raw body middleware BEFORE any JSON parser
// Signature verification requires the exact raw bytes — any transformation breaks it
app.post(
  '/webhooks/paddle',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['paddle-signature'] as string
    const rawBody = req.body.toString()

    let event
    try {
      event = await paddle.webhooks.unmarshal(
        rawBody,
        process.env.PADDLE_WEBHOOK_SECRET!,
        signature
      )
    } catch {
      return res.status(401).send('Invalid signature')
    }

    switch (event.eventType) {
      case 'subscription.created':
        await db.subscriptions.create({
          userId: event.data.customData?.userId,
          paddleSubscriptionId: event.data.id,
          paddleCustomerId: event.data.customerId,
          status: event.data.status,
          currentPeriodEnd: event.data.currentBillingPeriod?.endsAt,
        })
        break

      case 'subscription.updated':
        await db.subscriptions.update({
          paddleSubscriptionId: event.data.id,
          status: event.data.status,
          currentPeriodEnd: event.data.currentBillingPeriod?.endsAt,
        })
        break

      case 'subscription.canceled':
        await db.subscriptions.update({
          paddleSubscriptionId: event.data.id,
          status: 'canceled',
        })
        break

      case 'transaction.completed':
        await provisionUserAccess(event.data.customData?.userId)
        break

      case 'transaction.payment_failed':
        await notifyUserOfFailedPayment(event.data.customData?.userId)
        break
    }

    res.send('OK')
  }
)

The raw body requirement is the most common reason webhook verification fails. If your framework applies a JSON body parser globally (common in NestJS, Fastify’s default JSON codec), you need to register the raw handler before the parser runs — not after. Check your middleware order before assuming the SDK is broken.

Store paddleCustomerId alongside paddleSubscriptionId when subscription.created fires. You need the customer ID to generate portal sessions in Step 4.

Step 4: Customer portal

The Paddle-hosted portal lets subscribers update their payment method, view invoices, and cancel — without you building any of that UI.

Generate a portal session

Portal sessions are temporary, signed URLs. Generate them server-side, redirect immediately, never store them.

app.get('/billing/portal', authenticateUser, async (req, res) => {
  const customerId = req.user.paddleCustomerId

  const session = await paddle.customerPortalSessions.create(customerId, {
    subscriptionIds: [req.user.paddleSubscriptionId],
  })

  // session.urls.general.overview — portal homepage
  // session.urls.subscriptions[0].cancelSubscription — direct cancel link
  // session.urls.subscriptions[0].updateSubscriptionPaymentMethod — update card link

  let portalUrl = session.urls.general.overview

  // Sandbox gotcha: portal URLs contain customer.paddle.com regardless of environment
  // Rewrite to sandbox domain when running in sandbox mode
  if (process.env.PADDLE_ENV === 'sandbox') {
    portalUrl = portalUrl.replace(
      'customer.paddle.com',
      'sandbox-customer.paddle.com'
    )
  }

  res.redirect(portalUrl)
})

The portal includes cancellation flows as of January 2026 — surveys, pause offers, and discount offers before a customer actually cancels. Paddle handles the offboarding UX; you receive the subscription.canceled or subscription.updated (status: paused) webhook afterward.

Testing

The sandbox is a separate environment — create a sandbox account even if you have a live one. Credentials are never shared.

Test cards

ScenarioCard number
Successful payment (no 3DS)4242 4242 4242 4242
Successful payment (3DS)4000 0038 0000 0446
Declined4000 0000 0000 0002
Valid debit card4000 0566 5566 5556

Use any future expiry, any name, CVV 100.

Webhook simulator

Dashboard → Developer Tools → Notifications → your destination → Simulate. You can customize event payloads with your own IDs — no need to complete a real checkout to test your webhook handler. Run through subscription.created, subscription.canceled, and transaction.payment_failed before shipping.

Sandbox webhooks retry 3× over 15 minutes (production retries 60× over 3 days), so you’ll see failures quickly without waiting.

Gotchas

1. Raw body before JSON parser. Covered above, but it’s worth repeating because it’s the top cause of 401s from the SDK. Verify your middleware stack.

2. Billing interval mismatch. Mixing monthly and annual prices on one checkout throws a clear API error. Prevent it in the UI — don’t rely on catching it after submission.

3. One active subscription per user. Paddle doesn’t prevent a user from subscribing twice. Your subscription.created handler should check whether the user already has an active subscription before creating a DB record. If they do, either update the existing record or cancel the duplicate.

4. Customer portal domain in sandbox. Portal session URLs always contain customer.paddle.com — they don’t auto-switch based on which API key you used. Rewrite the domain to sandbox-customer.paddle.com in your redirect handler when running in sandbox mode. Skip this and your customers land on a 404.

5. Multiple webhook secrets. If you add a staging webhook destination later, it gets a different pdl_ntfset_ key. Store the key per-environment in your config, not as a single shared constant.

6. Subscription charge limits. Immediate charges against subscriptions are capped at 20/hour and 100/day per subscription. If you’re running batch operations (e.g., a migration script), build in delays.

Paddle vs. Stripe Tax

Paddle BillingStripe + Stripe Tax
Your legal rolePaddle is the sellerYou are the seller
Tax calculationIncluded+0.5% per transaction
Tax filing & remittancePaddle handles itYour responsibility
VAT registrationNot requiredRequired per country over threshold
Base fee5% + $0.502.9% + $0.30
Break-even~$50k–$100k MRRBelow that, Paddle is usually cheaper net

The math: EU VAT OSS threshold is €10,000/year; most US states have nexus at $100,000/year. Until you’re spending real money on international tax compliance, Paddle’s higher percentage fee is almost certainly cheaper than the alternative. Past $50k–$100k MRR, hire a tax team and revisit. For a full breakdown of the cost trade-offs at each revenue tier, see our Stripe vs Paddle comparison or the best payment processors for low fees roundup.

Paddle Billing pricing and next steps

Paddle charges 5% + $0.50 per checkout transaction. No monthly fee, no setup fee. 200+ countries, 30+ currencies, 18 checkout locales — all included.

Sign up for a sandbox account at paddle.com, work through these steps against the sandbox, then swap Environment.sandbox to Environment.production and your live credentials to go live.