AverageDevs
Billing

Paddle Payment Gateway Integration for SaaS (Step‑by‑Step with TypeScript/Next.js)

A complete, practical guide to integrating Paddle Billing into a modern SaaS app using TypeScript and Next.js - products, checkout, webhooks, and subscriptions, with code examples and links.

Paddle Payment Gateway Integration for SaaS (Step‑by‑Step with TypeScript/Next.js)

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

  1. Create a Paddle account and enable Sandbox.
  2. In Dashboard: add your SaaS product in Catalog → Products, then create Prices (e.g., monthly, annual). Note or copy their price_ids.
  3. In Developer Tools → Authentication:
    • Generate an API Key (server only)
    • Generate a Client‑side Token (used to initialize the JS SDK)
  4. 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
  5. 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_sandbox
  • NEXT_PUBLIC_PADDLE_CLIENT_TOKEN is safe for client init.
  • Keep PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET server‑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, and customData.

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_id to enforce idempotency.
  • Tie Paddle customer_id to your userId after 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=sandbox and 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 priceId items
  • Forgetting to pass environment: "sandbox" during development
  • Mixing client token and API key on the wrong side (client vs server)
  • 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.