"Pay once, keep it for good" sounds simpler than a subscription. On Lemon Squeezy it is: no renewals, no dunning, no portal. It also has three traps, and each one either gives the product away or loses a sale.
Sell it as a single-payment variant
In the dashboard, a variant's pricing is Single payment or
Subscription. A lifetime deal is a single-payment variant, usually on the
same product as the plan's monthly and yearly variants (or its own product,
Pro Lifetime).
Checkout is the same call as a subscription, with one variant enabled:
const { data, error } = await createCheckout(storeId, variantId, {
checkoutData: {
email: user.email,
custom: { userId: user.id, planSlug: "pro", priceId: "pro-lifetime" },
},
productOptions: {
redirectUrl: `${origin}/billing?checkout=success`,
enabledVariants: [Number(variantId)],
},
testMode,
});
enabledVariants matters more here than anywhere: without it, the hosted page
lets the buyer switch to any variant of the product, including the cheap
monthly one.
Trap 1: order_created fires for subscriptions too
Every checkout creates an order, including a subscription's first payment. If "an order was paid" means "grant lifetime access", a $19 monthly subscriber gets the lifetime plan.
Decide from your catalogue: look up the order's
first_order_item.variant_id in the map of variant ids to catalogue prices,
and treat the order as a purchase only when that price is one-time. Leave
every other order to the subscription events.
Do not decide from the payload's custom data. Custom data can be set by
anyone who builds a buy link (?checkout[custom][priceId]=pro-lifetime), and
it would happily ride along on a $1 product. The variant that was paid for is
the one fact a buyer cannot choose freely. Use custom data for one thing: to
find which of your users bought.
Trap 2: the buyer comes back before the webhook
After payment Lemon Squeezy redirects to your redirectUrl, with no order or
checkout id on it. There is nothing to look up, so the page cannot converge on
its own. Show "Payment received, setting up your plan", refresh every couple
of seconds, and stop after half a minute with "access can take a minute". The
webhook usually lands within seconds.
Do not try to guess the order from the buyer's email with listOrders: a
second tab, a shared inbox or a gift purchase makes that the wrong order.
Trap 3: there is no portal for a one-time buyer
The customer portal URL comes from a subscription (or from the customer, and
Lemon Squeezy returns null there for someone with no subscription). A buyer
who only ever paid once has nothing to manage. Hide the "Manage billing"
button for them and show the purchase itself instead: the order's
urls.receipt is a pre-signed link to their order page, with the tax invoice
Lemon Squeezy issued as the seller.
Record it as a purchase, not a subscription
A one-time purchase is a row keyed on the order id, with the variant, the
plan it grants, the amount (total, in cents, tax included), the currency,
the receipt link and a status. Access is "a purchase in paid or
partially_refunded", checked beside the subscription rule, with the higher
plan winning.
Two edge cases worth handling:
- Lifetime bought while subscribed. Both are live and the subscription is now paying for nothing. Say so on the billing page and link the portal. Never cancel their subscription for them.
- Buying again. A buyer who already owns the plan should hear "you already own this", not reach a second checkout.
Refunds take it back
order_refunded carries the order with refunded_amount as a running total.
A full refund (refunded_amount >= total) marks the purchase refunded and the
plan goes away on the next request. A partial one keeps it. The write must
only move forward, so a late order_created retry cannot turn a refunded
order back into a paid one.
Test it without a tunnel
Order events carry everything you need in the signed body, so the purchase
path can be tested on localhost: take a real order_created body, set your
store id, your variant id and your user id, sign it with your webhook secret
(HMAC-SHA256, hex, in X-Signature) and post it. Post it twice: the second
answer should be a duplicate. Then post the refund.