Paddle is a merchant‑of‑record platform that lets SaaS teams accept global payments, handle tax/VAT and invoicing, and manage subscriptions without stitching together multiple services. This guide shows a production‑grade integration using TypeScript and Next.js, including products, client‑side checkout, server webhooks, and subscription lifecycle handling.
What You’ll Build
- Create products and prices in the Paddle Dashboard
- Client‑side checkout (overlay or inline) with secure initialization
- Webhook handling to provision and manage subscriptions
- Server utilities for signature verification and idempotent updates
- Testing and go‑live checklist
Prerequisites
- Next.js 14+ with the App Router
- TypeScript enabled
- Environment variables manager (e.g.,
.env.local) - Paddle account with Sandbox enabled
Step 1 - Configure Paddle and Gather Credentials
- Create a Paddle account and enable Sandbox.
- In Dashboard: add your SaaS product in
Catalog → Products, then createPrices(e.g., monthly, annual). Note or copy theirprice_ids. - In
Developer Tools → Authentication:- Generate an API Key (server only)
- Generate a Client‑side Token (used to initialize the JS SDK)
- In
Developer Tools → Events(or Notifications):- Add your webhook URL (e.g.,
https://yourapp.com/api/paddle/webhook) - Select events like
transaction.completed,subscription.created,subscription.updated,subscription.canceled - Copy the Webhook Secret
- Add your webhook URL (e.g.,
- Approve your domain for checkout if required by Paddle Checkout.
Helpful references:
Paddle Dashboard→ Catalog, Authentication, Events- Sandbox testing guide and card numbers
Step 2 - Install and Load Paddle Checkout SDK
You’ll load Paddle’s checkout script client‑side and initialize it with your client token and environment.
// app/providers/paddle-provider.tsx
"use client";
import { useEffect, useRef } from "react";
/**
* Loads Paddle JS once and exposes a global Paddle object.
* Keep this provider near the root of your client tree (e.g., in layout).
*/
export const PaddleProvider = () => {
const loadedRef = useRef(false);
useEffect(() => {
if (loadedRef.current) return;
const script = document.createElement("script");
script.src = "https://cdn.paddle.com/paddle/paddle.js";
script.async = true;
script.onload = () => {
// @ts-expect-error Paddle is injected on window
if (window.Paddle && typeof window.Paddle.Initialize === "function") {
// Initialize with your client-side token and environment
// Use sandbox: true for testing
// @ts-expect-error
window.Paddle.Initialize({
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
environment: process.env.NEXT_PUBLIC_PADDLE_ENV || "sandbox", // "sandbox" | "production"
});
}
};
document.body.appendChild(script);
loadedRef.current = true;
}, []);
return null;
};Add the provider to your root client layout so it loads once:
// app/layout.tsx (excerpt)
import { PaddleProvider } from "./providers/paddle-provider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<PaddleProvider />
{children}
</body>
</html>
);
}Environment variables:
# .env.local
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=ptk_live_or_sandbox
NEXT_PUBLIC_PADDLE_ENV=sandbox
PADDLE_WEBHOOK_SECRET=whsec_xxx
PADDLE_API_KEY=pk_live_or_sandboxNEXT_PUBLIC_PADDLE_CLIENT_TOKENis safe for client init.- Keep
PADDLE_API_KEYandPADDLE_WEBHOOK_SECRETserver‑only.
Step 3 - Trigger Checkout (Overlay or Inline)
Paddle supports opening checkout with a priceId for subscriptions. You can also prefill customer info.
// components/checkout-button.tsx
"use client";
import { useCallback } from "react";
interface CheckoutButtonProps {
priceId: string; // e.g., "pri_01hxxxx..." from Paddle Dashboard
customerEmail?: string;
successUrl?: string; // optional
cancelUrl?: string; // optional
}
/**
* Opens Paddle overlay checkout for a given priceId.
*/
export const CheckoutButton = ({ priceId, customerEmail, successUrl, cancelUrl }: CheckoutButtonProps) => {
const onClick = useCallback(() => {
// @ts-expect-error global Paddle
const { Paddle } = window as any;
if (!Paddle || typeof Paddle.Checkout !== "object") return;
// For overlay checkout
Paddle.Checkout.open({
items: [{ priceId, quantity: 1 }],
// Optional customer info and URLs
customer: customerEmail ? { email: customerEmail } : undefined,
successUrl,
cancelUrl,
});
}, [priceId, customerEmail, successUrl, cancelUrl]);
return (
<button onClick={onClick} className="px-4 py-2 rounded bg-blue-600 text-white">
Subscribe
</button>
);
};Inline checkout embeds into a container instead of an overlay:
// components/inline-checkout.tsx
"use client";
import { useCallback, useEffect, useRef } from "react";
export const InlineCheckout = ({ priceId, customerEmail }: { priceId: string; customerEmail?: string }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const openInline = useCallback(() => {
// @ts-expect-error global Paddle
const { Paddle } = window as any;
if (!Paddle || !containerRef.current) return;
Paddle.Checkout.open({
items: [{ priceId, quantity: 1 }],
customer: customerEmail ? { email: customerEmail } : undefined,
// Inline frame options
displayMode: "inline",
container: containerRef.current,
frameStyle: "width:100%; min-width: 312px; background: transparent; border: 0;",
frameInitialHeight: 420,
});
}, [priceId, customerEmail]);
useEffect(() => {
openInline();
}, [openInline]);
return <div ref={containerRef} />;
};Notes:
- Use
items: [{ priceId }]for subscriptions created via Prices. - For advanced prefills, see Paddle docs for
customer,billingDetails, andcustomData.
Step 4 - Secure Your Server and Verify Webhooks
Webhooks are the source of truth for provisioning, renewals, and cancellations. Always verify signatures and make idempotent updates.
// app/api/paddle/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
/**
* Verifies Paddle webhook using the shared webhook secret (HMAC SHA256).
* Returns the parsed JSON body if valid; otherwise throws.
*/
const verifyPaddleWebhook = async (req: NextRequest): Promise<any> => {
const secret = process.env.PADDLE_WEBHOOK_SECRET;
if (!secret) throw new Error("Missing PADDLE_WEBHOOK_SECRET");
const signature = req.headers.get("paddle-signature") || req.headers.get("paddle-signature-v2") || "";
const rawBody = await req.text();
const computed = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
if (computed !== signature) {
throw new Error("Invalid Paddle webhook signature");
}
return JSON.parse(rawBody);
};
export const POST = async (req: NextRequest) => {
try {
const payload = await verifyPaddleWebhook(req);
// Idempotency guard (pseudo): ensure event is processed once
// const eventId = payload?.event_id || payload?.id;
// await ensureNotProcessed(eventId);
const type: string | undefined = payload?.event ?? payload?.alert_name;
switch (type) {
case "transaction.completed": {
// Lookup or create user by customer id/email
// Activate subscription access
// Persist subscription id, status, next billing date
break;
}
case "subscription.created": {
// Persist initial subscription record
break;
}
case "subscription.updated": {
// Update plan, status, or billing cycle
break;
}
case "subscription.canceled":
case "subscription.cancelled": {
// Revoke access at period end or immediately based on settings
break;
}
default: {
// Ignore others or log
break;
}
}
return NextResponse.json({ ok: true });
} catch (err) {
return new NextResponse("Webhook Error", { status: 400 });
}
};Implementation tips:
- Read the raw body for HMAC; do not pre‑parse JSON before computing the signature.
- Store
event_idto enforce idempotency. - Tie Paddle
customer_idto youruserIdafter first successful transaction.
Step 5 - Server Utilities (Create Customers, Manage Subs)
If you need server‑initiated operations (e.g., portal links, pause, cancel), use Paddle’s HTTP API with your server API key. Example helper:
// lib/paddle.ts
import type { NextRequest } from "next/server";
const PADDLE_API_BASE = "https://api.paddle.com"; // switch to sandbox base if needed
export interface CreateCustomerArgs {
email: string;
name?: string;
}
export const createPaddleCustomer = async ({ email, name }: CreateCustomerArgs) => {
const res = await fetch(`${PADDLE_API_BASE}/customers`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.PADDLE_API_KEY}`,
},
body: JSON.stringify({
email,
name,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Paddle create customer failed: ${res.status} ${text}`);
}
return res.json();
};Adjust endpoints for Sandbox vs Production per Paddle’s docs.
Step 6 - Testing Checklist
- Use
NEXT_PUBLIC_PADDLE_ENV=sandboxand sandbox token - Test overlay and inline flows on desktop and mobile
- Simulate success, decline, 3DS challenges
- Fire test webhooks and verify signature + idempotency
- Ensure user access toggles match webhook lifecycle events
Step 7 - Go Live Checklist
- Swap to production client token and API key
- Set
NEXT_PUBLIC_PADDLE_ENV=production - Point webhooks to production URL and rotate secret
- Verify domain approval and branding settings
- Run a small real purchase and refund to validate end‑to‑end
Common Pitfalls and How to Avoid Them
- Mismatched signature due to parsing JSON before HMAC - always hash the raw body
- Not storing
event_id- can cause duplicate provisioning on retries - Using product IDs with new checkout that expects
priceIditems - Forgetting to pass
environment: "sandbox"during development - Mixing client token and API key on the wrong side (client vs server)
Important Links
- Paddle Docs - Checkout and Billing:
https://developer.paddle.com/ - Overlay Checkout:
https://developer.paddle.com/build/checkout/build-overlay-checkout - Inline Checkout:
https://developer.paddle.com/build/checkout/build-branded-inline-checkout - Events/Webhooks:
https://developer.paddle.com/build/events/overview - API Reference:
https://developer.paddle.com/api-reference/overview - Testing Sandbox:
https://www.paddle.com/help/start/set-up-paddle/how-do-i-test-my-checkout-integration
Final Thoughts
With a small set of components and a secure webhook, you can ship a global, tax‑compliant subscription flow in days. Start in Sandbox, get signature verification and idempotency right, and wire your entitlements strictly off webhook events for a resilient, auditable system.
