We recently shipped credit top-ups for Test-Lab.ai and chose Paddle as our payment provider. Overall, we really like Paddle. The API is clean, the docs are solid, and the overlay checkout just works. But there were a handful of rough edges that cost us hours - the kind of stuff that doesn't show up in tutorials.
This is every issue we ran into, how we fixed each one, and what we'd do differently.
Why Paddle
Quick context on why we picked Paddle over Stripe:
- Merchant of Record - Paddle handles sales tax, VAT, and invoicing globally. We don't want to think about tax compliance in 40+ countries.
- No Stripe Atlas needed - Paddle pays you. You don't need a US business entity to accept payments worldwide.
- Overlay checkout - No redirect. The checkout opens as an overlay on your page, which keeps the user flow tight.
- Simple API - Paddle Billing (v2) is a well-designed REST API. Creating transactions, verifying webhooks, handling events - it's all straightforward.
For a small SaaS team, the "we handle tax" part alone is worth it.
The Setup
Our stack: Next.js (App Router) deployed on Cloudflare Workers, with D1 (SQLite) for the database. Paddle handles payments, and we use webhooks to confirm transactions.
The flow:
- User clicks "Add Credits"
- We create a Paddle transaction via the API (server-side)
- Open the Paddle overlay checkout with the transaction ID (client-side)
- Paddle sends a webhook when the payment completes
- We verify the webhook signature and credit the account
Simple enough on paper.
Gotcha #1: transaction_default_checkout_url_not_set
This was our first production error. Everything worked perfectly in sandbox. Deployed to production, clicked checkout, got:
{
"error": {
"type": "request_error",
"code": "transaction_default_checkout_url_not_set",
"detail": "A Default Payment Link has not yet been defined within the Paddle Dashboard"
}
}The fix: Go to your Paddle Dashboard, then Checkout > Checkout Settings, and set the Default Payment Link URL (e.g. https://yourapp.com). This is required in production but not in sandbox, which is why testing never caught it.
If you're Googling transaction_default_checkout_url_not_set - that's it. Two-minute fix, but it'll confuse you because sandbox works without it.
Gotcha #2: Sandbox vs Production - Two Completely Separate Environments
This feels obvious in hindsight, but Paddle sandbox and production are entirely separate systems. Different API keys, different client tokens, different webhook secrets, different product IDs, different webhook endpoints. You need to create everything from scratch in production.
Checklist when going from sandbox to production:
- New API key (Developer Tools > Authentication)
- New client token (same page,
live_prefix instead oftest_) - Recreate your products in production (Catalog > Products)
- Create a new webhook endpoint (Developer Tools > Notifications)
- Get the new webhook secret
- Set the Default Payment Link (Checkout > Checkout Settings)
We built our app to read PADDLE_ENVIRONMENT from env vars so we could switch between them:
const apiUrl =
environment === "production"
? "https://api.paddle.com"
: "https://sandbox-api.paddle.com"For client-side Paddle.js initialization:
Paddle.Environment.set(
process.env.NEXT_PUBLIC_PADDLE_ENVIRONMENT === "production"
? "production"
: "sandbox"
)Don't hardcode "sandbox" during development and forget about it. Ask us how we know.
Gotcha #3: Inline Pricing vs Pre-created Prices
Paddle supports two approaches:
- Pre-created prices - Create fixed price IDs in the dashboard (
pri_01xxx), reference them when creating transactions - Inline pricing - Pass the price details directly in the transaction creation request
We went with inline pricing because our users can top up any custom amount ($1 to $10,000). Creating a price ID for every possible dollar amount doesn't make sense.
With inline pricing, you pass a product_id (which you do need to create in the dashboard) and define the price on the fly:
const payload = {
items: [
{
price: {
description: "Credits - $10.00",
product_id: "pro_01xxxxx",
unit_price: {
amount: "1000", // cents, as a string
currency_code: "USD",
},
tax_mode: "external",
billing_cycle: null, // one-time purchase
},
quantity: 1,
},
],
custom_data: {
user_id: "123",
transaction_id: "456",
},
}Key details that tripped us up:
amountis a string, not a numberamountis in cents (1000 = $10.00)billing_cycle: nullmakes it a one-time charge (not a subscription)tax_mode: "external"if you're handling tax yourself (Paddle as MoR handles it, but you still need to set this)
Gotcha #4: Webhook Signature Verification on Cloudflare Workers
Paddle signs webhooks with HMAC SHA256. The signature comes in the paddle-signature header in a specific format:
ts=1234567890;h1=abc123def456...In a standard Node.js environment, you'd use the crypto module:
const crypto = require("crypto")
const hmac = crypto.createHmac("sha256", webhookSecret)
hmac.update(`${timestamp}:${rawBody}`)
const expected = hmac.digest("hex")On Cloudflare Workers, the Node.js crypto module isn't available by default. You need to either:
- Enable the
nodejs_compatcompatibility flag in your wrangler config - Use the Web Crypto API instead
We went with nodejs_compat since we're running Next.js on Cloudflare via OpenNext, which already requires it. If you're seeing crypto is not defined or require is not a function errors in your Cloudflare Worker - check your compatibility flags.
Gotcha #5: The Overlay Checkout Timing Problem
Paddle's overlay checkout fires events you can listen to: checkout.completed and checkout.closed. The natural instinct is to refresh the user's balance when checkout.completed fires. Don't do that.
The problem: checkout.completed fires on the client before your webhook has been processed on the server. If you refresh immediately, the balance hasn't been updated yet.
Our solution:
Paddle.Initialize({
token: clientToken,
eventCallback: (data) => {
if (data.name === "checkout.completed") {
window._paddleCheckoutSuccess = true
}
if (data.name === "checkout.closed") {
if (window._paddleCheckoutSuccess) {
window._paddleCheckoutSuccess = false
// Wait for webhook to process
setTimeout(() => {
refreshBalance()
}, 5000)
}
}
},
})We wait for the user to close the checkout overlay, then delay 5 seconds before refreshing. It's not elegant, but it works reliably. You could also poll an endpoint until the transaction shows as completed, but for our use case the delay approach is simpler.
Gotcha #6: Handling Duplicate Webhooks
Paddle can send the same webhook multiple times (retries, network issues). Both transaction.completed and transaction.paid fire for successful payments, and you might receive them in any order.
We store the paddle_transaction_id in our database and check for duplicates before processing:
// Check if we already processed this transaction
const existing = await db
.prepare("SELECT id FROM credit_transactions WHERE paddle_transaction_id = ?")
.bind(transactionId)
.first()
if (existing) {
console.log("Already processed transaction:", transactionId)
return new Response("OK", { status: 200 })
}Always return 200 OK for duplicates. If you return an error, Paddle will keep retrying.
Gotcha #7: custom_data Is Your Best Friend
When creating a transaction, you can attach arbitrary metadata via custom_data. This data comes back in the webhook payload, which is how you link a Paddle transaction to your internal records.
We pass our internal user_id, account_id, credits_cents, and transaction_id:
custom_data: {
user_id: "123",
account_id: "456",
credits_cents: "1000",
transaction_id: "789",
}All values must be strings. Paddle will reject the request if you pass numbers or nested objects.
Gotcha #8: Environment Variables in Next.js for Paddle
If you're using Next.js, remember the NEXT_PUBLIC_ prefix rule:
- Server-side only (API routes, webhook handlers): your API key, webhook secret, environment flag, product IDs
- Client-side (Paddle.js initialization): your client-side token and environment setting
For example:
# Server-side only (never exposed to browser)
MY_PADDLE_KEY=pdl_live_...
MY_PADDLE_WH_SECRET=pdl_ntfset_...
# Client-side (baked into JS bundle at build time)
NEXT_PUBLIC_MY_PADDLE_TOKEN=live_...
NEXT_PUBLIC_MY_PADDLE_ENV=productionNEXT_PUBLIC_ vars get baked into the JavaScript bundle at build time. Never put your API key or webhook secret behind NEXT_PUBLIC_ - they'll be visible in the browser.
If you're deploying to Cloudflare Workers, the server-side vars need to be set as Worker secrets (via wrangler secret put), not just in your .env file. The .env is only for local development.
The Full Architecture
Here's how all the pieces fit together:
User clicks "Add Credits"
↓
POST /api/credits/checkout (your server)
↓
Creates pending transaction in DB
↓
POST https://api.paddle.com/transactions (Paddle API)
↓
Returns transaction_id
↓
Client opens Paddle.Checkout.open({ transactionId })
↓
User completes payment in overlay
↓
Paddle sends webhook to POST /api/webhooks/paddle
↓
Verify signature, parse event, credit account
↓
Client refreshes balance (after delay)Would We Choose Paddle Again?
Yes. Despite the gotchas, Paddle is genuinely nice to work with. The API is well-designed, the sandbox is useful for testing, and the overlay checkout is a better UX than redirecting users to a payment page.
The Merchant of Record model means we don't deal with Stripe Tax, tax registration in every EU country, or sending invoices. For a small team building a SaaS product, that's a significant amount of complexity removed.
The rough edges we hit are mostly "production vs sandbox differences" and "things the docs assume you already know." Hopefully this post saves you a few hours.
