· paddle / billing / payments

Cách thiết lập Paddle Billing cho SaaS: hướng dẫn từng bước

Hướng dẫn từng bước: tích hợp Paddle Billing vào SaaS Node.js/TypeScript. Gồm product catalog, Paddle.js v2 checkout, xác thực webhook, và customer portal.

Bởi

2.170 từ · 11 phút đọc

Nếu bạn đang bán SaaS ra thị trường quốc tế, cấu hình mặc định của Stripe để lại toàn bộ nghĩa vụ khai thuế trên tay bạn. Mỗi quốc gia EU, Australia, Canada, và ngày càng nhiều tiểu bang Mỹ đều có ngưỡng VAT/GST, yêu cầu đăng ký, và thời hạn nộp thuế riêng. Paddle giải quyết tất cả điều đó: với tư cách là Merchant of Record, Paddle là người bán hợp pháp trong mọi giao dịch, nên họ tự tính, thu và nộp thuế tại hơn 200 quốc gia. Bạn nhận tiền; Paddle lo phần sổ sách thuế. Phí là 5% + $0.50 mỗi giao dịch, không có phí hàng tháng.

Bài hướng dẫn này thiết lập Paddle Billing từ đầu đến cuối — danh mục sản phẩm, overlay checkout, xử lý webhook, và customer portal — dùng @paddle/paddle-node-sdk v3.x và Paddle.js v2.

Dành cho ai

Developer đang xây dựng SaaS với Node.js/TypeScript và muốn nhận subscription mà không phải đụng tay vào vấn đề thuế quốc tế. Nếu bạn đã vượt $50k–$100k MRR và có đội kế toán xử lý VAT, hãy đọc phần so sánh ở cuối trước — ở quy mô đó, Stripe có thể rẻ hơn.

Trước khi bắt đầu

Trước khi viết code, hãy tạo một tài khoản Paddle sandbox tại sandbox-vendors.paddle.com. Sandbox là tài khoản hoàn toàn tách biệt khỏi môi trường live — không bao giờ dùng chung credentials.

Từ dashboard sandbox, lấy ba thứ sau:

  1. API key — Developer Tools → Authentication → API Keys. Key sandbox có chứa _sdbx trong tên. Lưu vào PADDLE_API_KEY.
  2. Client-side token — cùng trang Authentication, tab khác. Token sandbox bắt đầu bằng test_. Lưu vào PADDLE_CLIENT_TOKEN.
  3. Webhook secret — bạn sẽ tạo cái này ở Bước 3. Lưu vào PADDLE_WEBHOOK_SECRET.

Cài SDK:

npm install @paddle/paddle-node-sdk
# hoặc
yarn add @paddle/paddle-node-sdk
# hoặc
pnpm add @paddle/paddle-node-sdk

Bước 1: Tạo danh mục sản phẩm

Mô hình dữ liệu của Paddle: products là thứ bạn bán (ví dụ “Pro Plan”), prices xác định giá và chu kỳ thanh toán. Bạn cần cả hai trước khi mở checkout.

Tạo product

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

const paddle = new Paddle(process.env.PADDLE_API_KEY!, {
  environment: Environment.sandbox, // bỏ dòng này khi lên production
})

const product = await paddle.products.create({
  name: 'My SaaS Pro',
  taxCategory: 'saas',   // dùng 'saas' cho phần mềm — ảnh hưởng đến mức VAT EU/UK
  description: 'All features, unlimited usage.',
})
// product.id → "pro_01..."

Dùng taxCategory: 'saas' cho sản phẩm phần mềm. Sai danh mục sẽ làm sai thuế VAT hiển thị với khách EU và UK — đây không phải trường trang trí.

Tạo prices

// Subscription tháng — $29/tháng với 14 ngày dùng thử
const monthlyPrice = await paddle.prices.create({
  productId: product.id,
  description: 'Pro Monthly',
  unitPrice: { amount: '2900', currencyCode: 'USD' }, // số tiền tính bằng cent
  billingCycle: { interval: 'month', frequency: 1 },
  trialPeriod: { interval: 'day', frequency: 14 },
})
// monthlyPrice.id → "pri_01..."

// Subscription năm — $290/năm (miễn phí hai tháng)
const annualPrice = await paddle.prices.create({
  productId: product.id,
  description: 'Pro Annual',
  unitPrice: { amount: '29000', currencyCode: 'USD' },
  billingCycle: { interval: 'year', frequency: 1 },
})

Một ràng buộc bắt buộc: tất cả subscription trong một checkout phải cùng billing interval. Bạn không thể kết hợp gói tháng và gói năm trong một phiên. Hãy ngăn điều này từ phía pricing page — đừng để người dùng bấm “Mua” với interval không khớp, vì Paddle sẽ từ chối checkout với thông báo lỗi rõ ràng nhưng không hữu ích.

Lưu lại các price ID — bạn sẽ truyền chúng cho frontend checkout ở Bước 2.

Bước 2: Nhúng checkout

Paddle dùng overlay checkout: một modal được host trên server của Paddle và mở chồng lên trang của bạn. Phía bạn không phải lo về phạm vi PCI; Paddle xử lý toàn bộ dữ liệu thẻ.

Thêm Paddle.js v2 vào <head> của trang. Server phải inject PADDLE_CLIENT_TOKEN vào trang trước khi Paddle.js chạy — process.env là Node.js API và không tồn tại trong trình duyệt. Client token an toàn để public: đây là định danh chỉ đọc, không phải API key bí mật.

<!-- Server inject client token (an toàn để public — đây là định danh public) -->
<script>window.PADDLE_CLIENT_TOKEN = '<%= process.env.PADDLE_CLIENT_TOKEN %>';</script>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>

<%= ... %> ở trên là cú pháp EJS; hãy thay bằng cú pháp template server của bạn ({{ }} với Jinja/Nunjucks, ${...} với tagged template, data attribute với JSX, v.v.).

Khởi tạo một lần khi trang load:

// Sandbox mode — bỏ dòng này khi lên production
Paddle.Environment.set("sandbox");

Paddle.Initialize({
  token: window.PADDLE_CLIENT_TOKEN, // được inject từ server phía trên
  eventCallback: function(data) {
    if (data.name === "checkout.completed") {
      window.location.href = "/dashboard?welcome=1";
    }
  }
});

Mở checkout khi người dùng bấm nút:

function openCheckout(priceId, userEmail, userId) {
  Paddle.Checkout.open({
    items: [{ priceId: priceId, quantity: 1 }],
    customer: {
      email: userEmail,        // điền sẵn trường email
    },
    customData: {
      userId: userId,          // được truyền qua mọi webhook để tra cứu trong DB
    },
  });
}
<button onclick="openCheckout('pri_01...', currentUser.email, currentUser.id)">
  Dùng thử Pro
</button>

Truyền customer.email giúp bỏ qua màn hình đầu tiên của checkout — khách hàng sẽ vào thẳng màn hình nhập thông tin thanh toán. Nếu bạn cũng truyền customer.address.countryCode, Paddle có thể tính thuế trước khi khách nhập bất cứ điều gì. Cả hai đều nên truyền.

Trường customData.userId là mắt xích quan trọng để liên kết webhook event với database của bạn. Không có nó, bạn phải query API Paddle để biết user nào vừa đăng ký. Đặt ở đây; đọc lại ở Bước 3.

Bước 3: Xử lý webhook

Webhook là cách Paddle thông báo cho bạn về thay đổi trạng thái subscription, thanh toán thất bại, và gia hạn. Đây là nơi subscription được ghi vào database.

Cấu hình notification destination

Trong dashboard sandbox Paddle: Developer Tools → Notifications → New destination. Nhập webhook URL (dùng ngrok để phát triển local) và chọn tối thiểu các event sau:

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

Sao chép endpoint secret key (bắt đầu bằng pdl_ntfset_) vào PADDLE_WEBHOOK_SECRET. Mỗi destination có key riêng — đừng dùng chung một key giữa staging và production.

Webhook handler với 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,
})

// Đăng ký raw body middleware TRƯỚC bất kỳ JSON parser nào
// Xác thực chữ ký cần đúng byte thô — bất kỳ biến đổi nào cũng làm hỏng
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')
  }
)

Yêu cầu raw body là lý do phổ biến nhất khiến xác thực webhook thất bại. Nếu framework của bạn áp dụng JSON body parser toàn cục (thường gặp trong NestJS, JSON codec mặc định của Fastify), bạn cần đăng ký raw handler trước khi parser chạy — không phải sau. Kiểm tra thứ tự middleware trước khi kết luận SDK bị lỗi.

Lưu paddleCustomerId cùng với paddleSubscriptionId khi event subscription.created kích hoạt. Bạn cần customer ID để tạo portal session ở Bước 4.

Bước 4: Customer portal

Portal do Paddle host cho phép người dùng cập nhật phương thức thanh toán, xem hóa đơn và hủy subscription — mà không cần bạn tự xây dựng UI.

Tạo portal session

Portal session là URL tạm thời có chữ ký. Tạo chúng phía server, redirect ngay, không bao giờ lưu lại.

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 — trang chủ portal
  // session.urls.subscriptions[0].cancelSubscription — link hủy trực tiếp
  // session.urls.subscriptions[0].updateSubscriptionPaymentMethod — link cập nhật thẻ

  let portalUrl = session.urls.general.overview

  // Lưu ý với sandbox: URL portal luôn chứa customer.paddle.com bất kể môi trường
  // Rewrite sang sandbox domain khi chạy ở sandbox mode
  if (process.env.PADDLE_ENV === 'sandbox') {
    portalUrl = portalUrl.replace(
      'customer.paddle.com',
      'sandbox-customer.paddle.com'
    )
  }

  res.redirect(portalUrl)
})

Portal đã bao gồm luồng hủy subscription kể từ tháng 1 năm 2026 — khảo sát, đề nghị tạm dừng, và ưu đãi giảm giá trước khi khách thực sự hủy. Paddle xử lý toàn bộ UX offboarding; bạn nhận webhook subscription.canceled hoặc subscription.updated (status: paused) sau đó.

Kiểm thử

Sandbox là môi trường riêng biệt — tạo tài khoản sandbox ngay cả khi bạn đã có tài khoản live. Credentials không bao giờ dùng chung.

Thẻ test

Tình huốngSố thẻ
Thanh toán thành công (không 3DS)4242 4242 4242 4242
Thanh toán thành công (có 3DS)4000 0038 0000 0446
Bị từ chối4000 0000 0000 0002
Thẻ debit hợp lệ4000 0566 5566 5556

Dùng bất kỳ ngày hết hạn nào trong tương lai, tên tùy ý, CVV 100.

Webhook simulator

Dashboard → Developer Tools → Notifications → your destination → Simulate. Bạn có thể tùy chỉnh event payload với ID của mình — không cần hoàn thành checkout thật để test webhook handler. Chạy qua subscription.created, subscription.canceled, và transaction.payment_failed trước khi đưa lên production.

Webhook sandbox thử lại 3 lần trong 15 phút (production thử lại 60 lần trong 3 ngày), nên bạn sẽ thấy lỗi nhanh mà không cần chờ lâu.

Những điểm cần lưu ý

1. Raw body phải đứng trước JSON parser. Đã đề cập ở trên, nhưng đáng nhắc lại vì đây là nguyên nhân hàng đầu gây lỗi 401 từ SDK. Kiểm tra thứ tự middleware.

2. Billing interval không khớp. Kết hợp gói tháng và gói năm trong một checkout sẽ trả về lỗi API rõ ràng. Ngăn từ phía UI — đừng trông chờ bắt lỗi sau khi submit.

3. Mỗi user chỉ nên có một subscription active. Paddle không ngăn user đăng ký hai lần. Handler subscription.created của bạn phải kiểm tra xem user đã có subscription active chưa trước khi tạo bản ghi mới. Nếu có, hãy cập nhật bản ghi hiện tại hoặc hủy subscription trùng.

4. Domain của customer portal trong sandbox. URL của portal session luôn chứa customer.paddle.com — không tự chuyển dựa theo API key bạn dùng. Hãy rewrite domain thành sandbox-customer.paddle.com trong redirect handler khi chạy ở sandbox mode. Bỏ qua bước này và khách sẽ gặp trang 404.

5. Nhiều webhook secret. Nếu bạn thêm webhook destination cho staging sau này, nó sẽ có key pdl_ntfset_ khác. Lưu key theo từng môi trường trong config, không dùng chung một hằng số.

6. Giới hạn tính phí subscription. Charge trực tiếp vào subscription bị giới hạn 20 lần/giờ và 100 lần/ngày mỗi subscription. Nếu bạn đang chạy thao tác hàng loạt (ví dụ: migration script), hãy thêm delay.

Paddle so với Stripe Tax

Paddle BillingStripe + Stripe Tax
Vai trò pháp lý của bạnPaddle là người bánBạn là người bán
Tính thuếĐã bao gồm+0.5% mỗi giao dịch
Khai và nộp thuếPaddle loTrách nhiệm của bạn
Đăng ký VATKhông cầnCần theo từng quốc gia khi vượt ngưỡng
Phí cơ bản5% + $0.502.9% + $0.30
Điểm hòa vốn~$50k–$100k MRRDưới mức đó, Paddle thường rẻ hơn gộp lại

Nhìn vào con số: ngưỡng EU VAT OSS là €10,000/năm; hầu hết các tiểu bang Mỹ có ngưỡng nexus ở $100,000/năm. Cho đến khi bạn thực sự tốn tiền cho việc tuân thủ thuế quốc tế, phí phần trăm cao hơn của Paddle gần như chắc chắn rẻ hơn so với tự lo. Khi vượt $50k–$100k MRR, hãy thuê đội kế toán và tính lại. Để so sánh chi tiết hơn, xem bài Stripe vs Paddle hoặc payment processor phí thấp nhất năm 2026.

Giá Paddle Billing và bước tiếp theo

Paddle tính 5% + $0.50 mỗi giao dịch checkout. Không phí hàng tháng, không phí setup. Hơn 200 quốc gia, hơn 30 loại tiền tệ, 18 ngôn ngữ checkout — tất cả đã bao gồm.

Đăng ký tài khoản sandbox tại paddle.com, chạy qua các bước này trên sandbox, sau đó thay Environment.sandbox thành Environment.production cùng với credentials live để đưa vào production.