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 Ethan
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:
- API key — Developer Tools → Authentication → API Keys. Sandbox keys contain
_sdbxin the identifier. Store inPADDLE_API_KEY. - Client-side token — same Authentication page, different tab. Sandbox tokens start with
test_. Store inPADDLE_CLIENT_TOKEN. - 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.completedsubscription.createdsubscription.updatedsubscription.canceledtransaction.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
| Scenario | Card number |
|---|---|
| Successful payment (no 3DS) | 4242 4242 4242 4242 |
| Successful payment (3DS) | 4000 0038 0000 0446 |
| Declined | 4000 0000 0000 0002 |
| Valid debit card | 4000 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 Billing | Stripe + Stripe Tax | |
|---|---|---|
| Your legal role | Paddle is the seller | You are the seller |
| Tax calculation | Included | +0.5% per transaction |
| Tax filing & remittance | Paddle handles it | Your responsibility |
| VAT registration | Not required | Required per country over threshold |
| Base fee | 5% + $0.50 | 2.9% + $0.30 |
| Break-even | ~$50k–$100k MRR | Below 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.