Skip to main content
Paddle is a Merchant of Record (MoR) — they sell to your customer, you sell to Paddle. The upside: they collect and remit EU VAT, US sales tax, GST, etc. on your behalf. You ship a single price; Paddle handles regional gross-ups and filings. The downside: ~5% + 50¢ per transaction (vs Stripe’s 2.9% + 30¢) and slightly more friction in onboarding. Pick Paddle if you sell software globally and don’t want to spend 2 months incorporating, registering for tax IDs, and shipping monthly returns.

Prerequisites

  • A Paddle account (paddle.com). Approval can take a few business days — they’ll ask for company info and a website URL.
  • A sandbox account (sandbox-vendors.paddle.com) — gets you going while production review is pending.
  • Postgres up, schema pushed.

1. Set the active provider

In src/config/site.ts:
payment: {
  provider: 'paddle' as 'stripe' | 'paddle' | 'lemonsqueezy' | 'creem',
  currency: 'usd',
},

2. Create products + prices in Paddle

In the Paddle dashboard (or sandbox dashboard): Catalog → Products → New product. Each product can have multiple prices (one-time vs recurring, different currencies, etc.). Copy the pri_… ID for each price you’ll expose. For the vibestrap scaffold itself you need a promo and standard price (both one-time).

3. Get your API key, client token, and webhook secret

  • API key: Developer Tools → Authentication → API keys. Use a server-side key (secret).
  • Client token: Developer Tools → Authentication → Client-side tokens. Public, used by Paddle.js in the browser.
  • Webhook secret: Developer Tools → Notifications → New destination → the Notification key for that destination. This is not your API key — see pitfalls below.

4. Set env vars

In .env.local (variable names match src/env.ts):
PADDLE_API_KEY=pdl_...
PADDLE_WEBHOOK_SECRET=pdl_ntfset_...
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_...
NEXT_PUBLIC_PADDLE_ENV=sandbox            # or 'production'

# vibestrap product price IDs
PADDLE_PRICE_VIBESTRAP_PROMO=pri_...
PADDLE_PRICE_VIBESTRAP_STANDARD=pri_...
NEXT_PUBLIC_PADDLE_ENV controls both server-side SDK env (Environment.sandbox vs Environment.production) and the client-side checkout endpoint. Keeping them in lockstep is mandatory.

5. Configure the webhook destination

Paddle calls webhook destinations “Notifications”. Add one:
  • URL: https://your-domain.com/api/webhooks/paddle
  • Events (minimum): transaction.completed, subscription.activated, subscription.updated, subscription.canceled.
  • Copy the destination’s Notification key — this is your PADDLE_WEBHOOK_SECRET.
For local testing, expose localhost:3000 via ngrok or cloudflared and use that URL.

Verify it works

  1. Set NEXT_PUBLIC_PADDLE_ENV=sandbox and use sandbox keys.
  2. pnpm dev, open /pricing, click checkout — Paddle’s hosted checkout should open.
  3. Pay with a Paddle test card (e.g. 4242 4242 4242 4242 with the sandbox environment, any future expiry, CVC 100).
  4. Watch your terminal — the webhook route should log a transaction.completed event being processed.
  5. Check the database:
    select id, provider, scene, status, amount from payment
    where provider = 'paddle'
    order by created_at desc limit 1;
    

Common pitfalls

  • Notification key vs API key confusion — these are completely separate credentials. The API key authenticates your server calls; the Notification key signs incoming webhooks. Mixing them up means signatures never verify.
  • Sandbox / production env mismatch — if NEXT_PUBLIC_PADDLE_ENV=production but you used a sandbox API key (or vice versa), the SDK silently talks to the wrong API and checkout creation fails with a vague auth error.
  • Async webhook unmarshal — Paddle’s webhooks.unmarshal is async (it does an HMAC compare under the hood). Don’t await it inside a synchronous callback or you’ll lose the event.
  • Price IDs vary per environment — sandbox pri_… IDs don’t exist in production. You’ll need a separate set of env vars per environment, or a mapping table.
  • No hosted return URL on portalpaddle.customerPortalSessions.create doesn’t accept a returnUrl; the customer has to navigate back via your app’s chrome.

Official docs