Sell with Paddle

You need: payments composed, and a Paddle account.

Time: about forty-five minutes, and you will do it twice.

Paddle Billing, not Paddle Classic

Selling with Lemon Squeezy is the other merchant-of-record option, selling a subscription on the web is the shared walkthrough, and pricing in the reader’s currency is what a merchant of record buys you.

Different product, different API, different everything. The version header is pinned on every request.

What it adds over a redirect-only rail

Paddle is the merchant of record, like Lemon Squeezy and unlike Stripe: the seller on your customer’s statement, calculating and remitting sales tax and VAT worldwide, issuing invoices, running dunning and absorbing chargebacks.

What it adds that a redirect-only rail cannot is Paddle.js. Checkout opens as an overlay over your own page, or inline inside it — so the buyer never leaves. And the customer portal hands back authenticated links to a specific subscription’s cancel and payment-method screens, rather than a general portal they have to navigate.

1. Two accounts, and they are genuinely separate

Paddle Billing partitions sandbox from live by account, not by a flag on an object.

Separate host. Separate API key. Separate client token. Separate notification destinations. Separate catalog.

So a price id from your sandbox does not exist in live, and there is no field on a transaction that could tell you which one you are looking at.

That is why the environment is config, and why it decides both which host the rail reaches and which environment every purchase row is recorded under. A sandbox-configured deployment cannot write a production row, and a sandbox purchase never grants a production entitlement.

Plan on doing every step below twice.

2. Products and prices

Under Catalog → Products, create a product and give it a price. The price carries the amount, the interval and the trial, so the price id goes in your config where Stripe’s does:

payments({
  billingSubject: "user",
  rails: { paddle: true },
  paddle: {
    clientToken: "test_1234567890abcdef",
    environment: "sandbox",
    checkout: "overlay",
    successUrl: `${PUBLIC_ORIGIN}/thanks`,
  },
  products: {
    pro_monthly: {
      type: "subscription",
      name: "Pro",
      entitlements: ["pro"],
      paddle: { priceId: "pri_01hv8w…" },
    },
  },
}),

3. The client token is publishable, and the API key is not

The client token belongs in config. It is what Paddle.js initializes with in the browser, so it is publishable by design and putting it in a secret would be putting a public value in a vault.

The API key never reaches a browser and lives in the secrets store. So does the notification signing secret.

Getting those two the wrong way round is the mistake to avoid, and the naming does not help — one is a token and one is a key, and only the token is public.

4. Choose overlay or inline

checkout: "overlay" opens over your page. "inline" embeds it in an element you place.

Overlay is the default for a reason: it needs no layout work and it handles its own responsive behavior. Inline is worth it when checkout is a step inside a longer flow you already designed.

5. The notification destination

Point one at your Worker’s Paddle webhook path, per environment, and take its signing secret — chosen once and shared with exactly one deployment.

Because the accounts are separate, the sandbox destination and the live one are configured in different places entirely.

6. Store the credentials

The API key and the notification signing secret, inside one typed secret:

pithy secrets create payments-provider-credentials --env prod

Not the client token — that is config.

The events sweep

Paddle is the one rail with an events sweep as well as webhooks: a way to ask for events you may have missed rather than waiting for redelivery.

That is what the reconciliation pass uses here, and it is a better repair path than most rails offer — but it does not remove the need for the pass, because a webhook you never received is a purchase you do not know to ask about.

Refunds arrive with no local write before them

As with any merchant of record. A refund event can arrive for a purchase where nothing on your side initiated anything — a chargeback, a support decision, a tax correction.

The projection handles it as a state like any other. A support workflow that assumes a refund follows a request will not hold.

Check it worked

  • The overlay opens on your own page and completes in sandbox
  • The entitlement appears immediately
  • Notifications show successful deliveries
  • A sandbox purchase grants nothing in production
  • pithy payments reconcile --env staging --rail paddle --dry-run reports no drift
ESC