You need: payments composed, and a Lemon Squeezy store.
Time: about forty minutes.
Why you would choose this over Stripe
Selling with Paddle is the other merchant-of-record option, selling a subscription on the web is the shared walkthrough, and designing your catalog is what you list.
Lemon Squeezy is the merchant of record, and that is the entire reason.
It owns the tax registration, the EU VAT, the invoice, the dunning and the chargebacks. It sells to your buyer and pays you, rather than processing a payment on your behalf.
The trade is a higher percentage, and what you buy with it is not having a VAT threshold conversation with an accountant in a country you have never visited.
Like Stripe here, it is hosted only: it presents the payment page and takes the card, and Pithy sends a browser there and hears the outcome on a webhook. No card fields in your app, no plan-change logic, and no tax settings of ours to get wrong.
1. The store is the legal seller
You need one before anything else. Lemon Squeezy sells on your behalf, so the store is the party doing the selling and it is what your credentials name.
2. Products and variants
Under Products, create a product and give it a variant.
A variant is the price equivalent — it carries the amount, the interval and the trial. So the variant id is what goes in your config, exactly where Stripe’s price id sits:
payments({
billingSubject: "user",
rails: { lemonSqueezy: true },
lemonSqueezy: {
successUrl: `${PUBLIC_ORIGIN}/thanks`,
},
products: {
pro_monthly: {
type: "subscription",
name: "Pro",
entitlements: ["pro"],
lemonSqueezy: { variantId: "123456" },
},
},
}),Not the product id. The catalog is the only place a variant id appears, and gating code names pro.
3. The success URL is required
The rail is on, so the block is required — a config that turns the rail on without it fails to parse at deploy rather than shipping and selling nothing.
It is config rather than request input, which is the security part: a client that could name where hosted checkout returns to could send a paying customer to a page it controls.
Note it is written as the derived origin constant rather than a literal, for the same reason every other origin in the kit is.
4. The webhook
Point one at your Worker’s Lemon Squeezy webhook path, one per environment, and choose a signing secret.
That secret is shared with exactly one deployment — which is why it is per environment like everything else here.
5. Store the credentials
The API key and the signing secret, inside one typed secret alongside any other rail’s block:
pithy secrets create payments-provider-credentials --env prodA rail’s block is present in full or absent entirely, and the schema is checked before the write lands — so half a credential is a refusal in your terminal rather than a signature check that silently never passes.
Refunds arrive with no local write before them
This is the one behavioral difference from the non-merchant rails, and it is worth knowing.
A merchant of record issues refunds on its own — a chargeback, a support decision, a tax correction. So a refund event can arrive for a purchase where nothing on your side initiated anything.
The projection handles it the same way it handles every other state, because it projects a state rather than applying a diff. But if you have a support workflow that assumes a refund follows a request, that assumption does not hold here.
What it does not change
The entitlement model is identical. Products grant keys, keys are what your code gates on, and a customer who bought here is entitled exactly as one who bought on any other rail.
The reconciliation pass runs the same. Webhook-only systems rot silently regardless of who the merchant is.
Your catalog is still your catalog, in your config, in git.
Check it worked
- A test checkout completes and the entitlement appears
- The webhook shows successful deliveries
- A refund revokes the entitlement
pithy payments reconcile --env staging --dry-runreports no drift