If you've tried to integrate Paddle Billing with Next.js
and hit confusing walls, this guide is for you.
I spent an embarrassing amount of time getting this right.
Here's everything I learned, including the two mistakes
that aren't documented anywhere.
Why Paddle instead of Stripe?
Stripe is excellent if you're in the US or EU. For founders
outside those regions — or anyone who wants global tax
compliance without setting up VAT registrations in multiple
countries — Paddle is a Merchant of Record. They handle
tax calculations, filings, and compliance for 180+ countries
automatically.
The tradeoff is a steeper integration. Let's go through it.
Mistake 1: Using passthrough instead of custom_data
This is the most common mistake and it's not documented
clearly.
When you create a checkout, you need to know which user
or organization completed payment so you can update your
database. The natural instinct is to pass this as a URL
parameter:
// ❌ This looks right but silently fails in Paddle Billing
const checkoutUrl = `${paddleUrl}?passthrough=${JSON.stringify({
org_id: organization.id
})}`
passthrough is a Paddle Classic parameter. Paddle Billing
(the current API at api.paddle.com) ignores it entirely.
Your webhooks arrive with custom_data: null no matter
what you put in passthrough.
The correct approach is to create the transaction via the
Paddle Billing API and set custom_data there:
// ✅ Correct — custom_data flows through all webhook events
const res = await fetch("https://sandbox-api.paddle.com/transactions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.PADDLE_API_KEY}`,
},
body: JSON.stringify({
items: [{ price_id: priceId, quantity: 1 }],
customer: { email: user.email },
custom_data: {
org_id: organization.id,
user_id: user.id,
},
checkout: {
url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
},
}),
});
const result = await res.json();
const checkoutUrl = result.data?.checkout?.url;
// Looks like: https://yourdomain.com/dashboard/billing?_ptxn=txn_...
Paddle attaches custom_data to every subsequent event
for this subscription — subscription.activated,
subscription.updated, subscription.canceled. Your
webhook reads it like this:
case "subscription.activated": {
const orgId = event.data?.custom_data?.org_id;
if (!orgId) {
console.warn("No org_id in custom_data — skipping");
break;
}
// update your database...
}
Mistake 2: Not loading Paddle.js
When the checkout route returns a URL like
https://yourdomain.com/dashboard/billing?_ptxn=txn_...,
this is NOT a redirect to Paddle's hosted checkout page.
It's designed for Paddle.js. The library detects the
_ptxn query parameter and opens the checkout overlay
automatically on top of your page.
Without Paddle.js loaded, the user gets redirected to
your own domain with an unrecognised query parameter
and nothing happens. The checkout overlay never appears.
Load Paddle.js in your root layout:
// src/components/providers/paddle-provider.tsx
"use client";
import Script from "next/script";
export function PaddleProvider() {
const token = process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN;
const env = process.env.NEXT_PUBLIC_PADDLE_ENV ?? "sandbox";
return (
<Script
src="https://cdn.paddle.com/paddle/v2/paddle.js"
strategy="afterInteractive"
onLoad={() => {
if (env === "sandbox") {
window.Paddle.Environment.set("sandbox");
}
window.Paddle.Initialize({
token,
eventCallback(event) {
if (event.name === "checkout.completed") {
// Navigate after payment — see Mistake 3
window.location.replace("/dashboard/billing");
}
},
});
}}
/>
);
}
Add it to your root layout:
// src/app/layout.tsx
import { PaddleProvider } from "@/components/providers/paddle-provider";
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<PaddleProvider />
{children}
</body>
</html>
);
}
The timing problem after checkout
When checkout.completed fires in Paddle.js, your
webhook hasn't been processed yet. If you navigate to
the billing page immediately, it reads the old plan from
the database.
Fix: poll a lightweight status endpoint until the
webhook has updated the database.
// src/app/api/billing/status/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { verifyToken } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function GET() {
const token = (await cookies()).get("token")?.value;
const payload = token ? verifyToken(token) : null;
if (!payload?.orgId) return NextResponse.json({ active: false });
const sub = await prisma.subscription.findUnique({
where: { organizationId: payload.orgId },
select: { plan: true, status: true },
});
const active =
sub?.status === "ACTIVE" &&
(sub?.plan === "PRO" || sub?.plan === "ENTERPRISE");
return NextResponse.json({ active, plan: sub?.plan });
}
Then in your PaddleProvider, poll before navigating:
eventCallback(event) {
if (event.name === "checkout.completed") {
let attempts = 0;
const poll = async () => {
attempts++;
const res = await fetch("/api/billing/status");
const data = await res.json();
if (data.active) {
window.location.replace("/dashboard/billing?activated=1");
} else if (attempts < 10) {
setTimeout(poll, 1500);
} else {
window.location.replace("/dashboard/billing");
}
};
setTimeout(poll, 1000);
}
}
Complete environment variables
PADDLE_ENV="sandbox"
NEXT_PUBLIC_PADDLE_ENV="sandbox"
PADDLE_API_KEY="pdl_sdbx_apikey_..."
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="test_..."
PADDLE_PRICE_PRO="pri_01..."
PADDLE_PRICE_ENTERPRISE="pri_01..."
PADDLE_WEBHOOK_SECRET="" # leave blank for local dev
Testing locally
Paddle can't send webhooks to localhost. Test your
webhook handler directly:
curl -X POST http://localhost:3000/api/billing/webhook \
-H "Content-Type: application/json" \
-d '{
"event_type": "subscription.activated",
"data": {
"id": "sub_test",
"customer_id": "ctm_test",
"status": "active",
"items": [{ "price": { "id": "YOUR_PADDLE_PRICE_PRO_ID" } }],
"custom_data": { "org_id": "YOUR_ORG_ID" },
"current_billing_period": { "ends_at": "2027-01-01T00:00:00Z" }
}
}'
Wrapping up
The three things that aren't documented clearly anywhere:
- Use
custom_datavia the transactions API — notpassthroughURL parameters - Load
Paddle.js— the checkout URL only works with the library present - Poll for webhook completion before navigating — don't assume the DB is updated immediately
I packaged all of this into a complete Next.js 16 SaaS
boilerplate with multi-tenancy, team management, feature
gating, and Resend email. If you want a head start:
Demo: https://nextsaasstarter.vercel.app/
Boilerplate: https://stackfoundry.gumroad.com/l/retljp
Happy to answer questions in the comments.