One-Time Payment vs Subscription: What the Webhook Handler Has to Know
Our entitlement engine is 75 lines with two status states; our LemonSqueezy webhook recognizes two event names. That's not a shortcut — it's the real cost gap between the two pricing models.
One-time payment trades recurring revenue for a simpler system: no renewal state machine, no dunning, no cancellation flow, and a webhook handler that only needs to recognize a couple of event names instead of a dozen lifecycle transitions. Subscription trades that simplicity for predictable revenue and ongoing customer contact. This storefront runs one-time payment for every tier, and the architecture difference is measurable in this repo's own code, not just in the pricing-page copy.
The decision in one table
| Axis | One-time payment | Subscription |
|---|---|---|
| Revenue shape | Lump sum per sale, no forecast beyond new sales | Recurring, forecastable — the reason SaaS investors like it |
| Entitlement model | Grant once, active forever (or until refunded) | Active only while payment continues; must be checked continuously |
| Webhook surface | A handful of events: created, refunded | A dozen+: created, renewed, payment failed, past due, paused, resumed, cancelled, trial ending, plan changed |
| Failure handling | None ongoing — the sale either happened or it didn't | Dunning: retry logic, grace periods, involuntary churn recovery |
| Customer lifecycle ops | None — no renewal to manage | Ongoing — upgrades, downgrades, proration, cancellation flows |
| Infra to build | Order table, entitlement grant, refund window | All of the above, plus a subscription-status table kept in sync with the processor |
| Natural fit | A finished digital good — a template, a license, a download | Ongoing service delivery — hosting, support, continuously updated data |
If you read one row: webhook surface. It's the row with a real number behind it in this codebase, below.
What "grant once" looks like in code
This storefront's entire entitlement engine is 75 lines, and the type it centers on has no expiry field at all:
// src/lib/entitlements.ts
export type SlotKind = "template_single" | "all_templates" | "all_access";
export interface Slot {
kind: SlotKind;
productSlug: string | null;
framework: string | null;
status: "active" | "revoked";
}
status has two states, not a subscription's usual four-or-more (trialing, active, past_due, canceled, sometimes paused and unpaid on top). A slot goes active once, at purchase, and only ever moves to revoked — via a refund, the one state transition this system supports. There's no renewal to track, so there's nothing to poll, no cron job reconciling processor state against a local cache, and no "entitlement drifted out of sync with billing" class of bug, because there's no ongoing billing to drift out of sync with. Downloads stay authorized indefinitely off that one active flag — authorizeDownload() just checks whether any owned slot covers the request, forever.
A subscription-shaped version of this same file would need a currentPeriodEnd, a background job (or a webhook-driven cache invalidation) to flip active to past_due when a renewal fails, and a grace-period policy for what a lapsed-but-not-yet-cancelled customer can still access. None of that exists here, and its absence is the whole architectural savings.
The webhook handler is two events, by design
This site's LemonSqueezy webhook dispatches on event_name with an explicit fallback that quietly ignores anything else:
// src/lib/lemonsqueezy/webhook.ts
if (eventName === "order_created") {
return await handleOrderCreated(lsOrderId, meta, attributes, payload, deps);
}
if (eventName === "order_refunded") {
const refunded = await deps.db.refundOrder(lsOrderId);
if (!refunded) deps.onOrphanRefund?.({ lsOrderId });
return { status: 200, body: "ok" };
}
return { status: 202, body: "ignored" };
order_created and order_refunded are the entire event vocabulary this store needs, because LemonSqueezy's order_* events are exactly the one-time-purchase lifecycle. A subscription product on the same platform emits subscription_created, subscription_updated, subscription_payment_success, subscription_payment_failed, subscription_cancelled, subscription_resumed, subscription_expired, and more — every one of which would hit that final return { status: 202, body: "ignored" } on this codebase today, silently, because the handler was never written to expect them. That's not a bug; it's the honest boundary of what a one-time-payment store has to handle. Standing this same webhook up for a subscription product means writing five to eight more branches, each with its own effect on the entitlement table above, and testing each one's interaction with a payment retry.
The refund window is the only "lifecycle" this model needs
The closest thing this architecture has to subscription-style time-boxing is the refund window, and it's a constant, not a state machine:
// src/lib/refundEligibility.ts
export const REFUND_WINDOW_DAYS = 14;
Eligibility, download-coverage checks, and the idempotency guard against a duplicate refund webhook are covered in full in Digital product refund policy — worth reading in full if you're building this same layer, because the interesting part is a real bypass this codebase's own code comment flags: per-order download attribution opens a download-then-refund loophole when a buyer owns the same product through two separate orders. That post is the deep dive; this one just notes that a 14-day window and a refund event are the entire "customer lifecycle" a one-time-payment store has to model, versus a subscription's ongoing renewal, dunning and cancellation surface.
What the pricing page already says, and why it's true
This site's own FAQ copy states the model plainly: "Every tier is a single one-time purchase with lifetime updates — no subscription, no renewal." That line isn't just marketing — it's an accurate description of the code above. "Lifetime updates" is cheap to promise here specifically because updates don't require a renewed subscription to unlock: latestVersion on a catalog entry is what every existing owner's download resolves to, unconditionally, per this repo's own release procedure. A subscription store selling the same promise would need to gate "still gets updates" behind "subscription still active," which reintroduces exactly the state-tracking this architecture avoids.
Where subscription is the right call, not a worse version of this
- The product is a service, not a finished good. Hosting, ongoing support, a continuously updated dataset, access to new content as it ships — anything whose value is delivered over time, not at the moment of sale, is what subscription pricing actually prices correctly.
- You want predictable revenue for planning, not just cash today. A one-time-payment business's revenue is exactly as good as its next sale; a subscription business can forecast next quarter from this quarter's retention.
- Customer lifetime value materially exceeds the first purchase. If most of the value a customer gets happens after month one, one-time payment leaves that value uncaptured.
- You're already paying for the infrastructure a subscription needs. If your billing processor, support tooling and infra already assume recurring billing (usage-based SaaS, seats, tiers that change over time), fighting that to bolt on one-time pricing is often more work than the entitlement model above saves.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
| Building a subscription-shaped entitlement table for a one-time product | Unused currentPeriodEnd/status states nobody ever transitions | Model the actual lifecycle — two states, not five |
| Ignoring unexpected webhook events silently, without logging | A misconfigured product (e.g. accidentally enabling subscriptions) fails invisibly | Log ignored events even when the 202 response is correct, so a config mistake is visible |
| No idempotency guard on the refund/cancellation webhook | A processor's at-least-once delivery double-refunds or double-revokes | Key the mutation on the processor's own event/order ID, and make it a no-op on replay |
| Treating "lifetime updates" as free to promise | It's only cheap if the release/versioning model doesn't gate updates behind ongoing payment | Verify your update-delivery path before making the promise in copy |
| Assuming one-time payment means no lifecycle at all | Refunds, chargebacks and re-purchases are still a lifecycle, just a shorter one | Build the refund window and idempotency guard anyway — see the refund-policy post above |
Frequently asked questions
Can a single storefront mix one-time and subscription pricing? Yes — the two aren't mutually exclusive at the platform level. LemonSqueezy, Stripe and most processors support both product types in one account. The architectural cost is additive, not a replacement: you'd keep this entitlement model for the one-time products and add a separate subscription-status table (and the extra webhook branches) for the recurring ones, rather than trying to force one model to cover both.
Is one-time payment worse for revenue? Not inherently — it depends on repeat-purchase rate and price point. A marketplace selling many distinct products (like this one, 111 of them) can generate recurring revenue from repeat customers buying different products, without needing recurring billing on any single sale. Subscription revenue predictability is a real advantage, but it's not the only path to predictable revenue.
Does "lifetime updates" cost more to support over time? Some — every update to a shipped product is support you're giving away rather than re-selling. This repo's answer is that updates are cheap to ship (a version bump and a re-upload, per the release procedure) and expensive re-purchases would be worse for trust, so the tradeoff favors giving updates away over gating them.
What happens to entitlements if the payment processor itself changes?
In this model, nothing — once a slot is active, it doesn't reference the processor again. A subscription model would need to re-verify status against whichever processor currently holds the billing relationship, which is one more reason a one-time model is simpler to migrate between processors if that ever comes up.
Templates that already run this pricing model
ASoc Bumble is a baby-clothing ecommerce template, ASoc Spark sells smart-home gadgets, and ASoc Anvil is built for a construction-tools storefront — all three are one-time-purchase templates themselves, and all three ship the checkout, cart and order UI a one-time-payment product needs, the same shape this post's entitlement model backs.
Browse the full set of Next.js shop templates, or the Tailwind shop templates. For the checkout and webhook layer this post assumes, see Selling a digital product with LemonSqueezy; for what happens after a sale goes wrong, see Digital product refund policy.
