# One-time purchases on Lemon Squeezy, from lifetime deal to refund

> A lifetime deal is a single-payment variant, and order_created fires for subscriptions too. How to tell them apart, find the buyer, show the purchase, and take access back on a refund.

"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:

```ts
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
