Payments
Next.js boilerplate with Lemon Squeezy
Merchant of record with license keys built in. VAT and sales tax are its problem.
Subscriptions and one-time purchases on Lemon Squeezy, the merchant of record: hosted checkout for monthly, yearly and lifetime prices, the signed customer portal, refunds that revoke access, and X-Signature verified webhooks made idempotent without a delivery id. Plans live in src/lib/pricing.ts and /pricing renders with no keys.
What Lemon Squeezy adds to the agent layer: 2 rules · 2 skills · 8 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Lemon Squeezy?
Pick it if
Solo founders and small teams selling SaaS, desktop apps or digital downloads worldwide. You get checkout, tax, license keys and a customer portal on day one, with no VAT registration anywhere. Strong fit for software sold with license keys, because Lemon Squeezy issues and validates them itself.
Watch out for
- Lemon Squeezy is the seller on the statement and the invoice. Tax liability moves to it, which is the point. Your brand is not what the customer sees on their card line.
- The base rate, 5% + 50c, is above Dodo's 4% + 40c and matches Polar's free plan. Run the numbers against both before you launch.
Show 3 moreShow fewer
- Owned by Stripe since 2024. In January 2026 it said its goal is an easy migration to Stripe Managed Payments. Read that plan before you build on it.
- The API cannot create products or prices. You create the variants in the dashboard;
billing:sync-planschecks them against src/lib/pricing.ts and finds their ids for you. - Webhooks carry no delivery id and retry only three times (5s, 25s, 125s). A handler that is down for three minutes loses events, so a nightly reconcile from the API ships with it.
What it costs
5% + 50c per transaction, no monthly fee. Non-US payments and PayPal each add 1.5%, and subscriptions add 0.5%. Tax calculation, filing and remittance are included.
Prices change. Check with Lemon Squeezy before you commit.
registry/tested.yaml
Tested with Lemon Squeezy
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Lemon Squeezy adds to the repo
Read straight from the lemonsqueezy manifest, so it is exactly what lands in your repo.
Environment variables
LEMONSQUEEZY_API_KEYRequired
API key from Settings -> API. A key created while the store is in test mode only ever sees test-mode data, and a live key only live data. Server-side only: it can refund orders and cancel subscriptions. Until it is set, /pricing still renders and every buy button explains that billing is not set up.
- Placeholder
- replace_me_with_a_test_mode_api_key
LEMONSQUEEZY_STORE_IDRequired
Numeric id of the store that sells your plans (Settings -> Stores). Every checkout, variant check and webhook is scoped to it, so an account with several stores only ever acts on this one.
- Where to get it
- https://docs.lemonsqueezy.com/api/stores
- Placeholder
- replace_me_with_the_numeric_store_id
LEMONSQUEEZY_WEBHOOK_SECRETRequired
The signing secret you typed when creating the webhook (Settings -> Webhooks), 6 to 40 characters. Lemon Squeezy sends an X-Signature header holding the hex HMAC-SHA256 of the raw body with this value. Use a different secret for the test-mode and live-mode webhooks.
- Where to get it
- https://docs.lemonsqueezy.com/help/webhooks/signing-requests
- Placeholder
- replace_me_6_to_40_chars
LEMONSQUEEZY_MODEOptional
"test" or "live". Defaults to test so a fresh clone cannot take a real payment by accident. Every checkout is created in this mode, webhooks from the other mode are dropped, and
bun run verifyfails when the API key's mode disagrees.- Where to get it
- https://docs.lemonsqueezy.com/help/getting-started/test-mode
- Placeholder
- test
BILLING_PRICE_PRO_MONTHLYOptional
The Lemon Squeezy variant id (digits) for the catalogue price
pro-monthly: a monthly subscription variant.bun run billing:sync-plansfinds it and prints this line. Test and live mode have different ids.- Where to get it
- https://docs.lemonsqueezy.com/help/products/variants
- Placeholder
BILLING_PRICE_PRO_YEARLYOptional
The Lemon Squeezy variant id for
pro-yearly, a yearly subscription variant.- Where to get it
- https://docs.lemonsqueezy.com/help/products/variants
- Placeholder
BILLING_PRICE_PRO_LIFETIMEOptional
The Lemon Squeezy variant id for
pro-lifetime, a single-payment variant. One payment grants the Pro plan for good; a full refund takes it away.- Where to get it
- https://docs.lemonsqueezy.com/help/products/variants
- Placeholder
BILLING_PRICE_TEAM_MONTHLYOptional
The Lemon Squeezy variant id for
team-monthly.- Where to get it
- https://docs.lemonsqueezy.com/help/products/variants
- Placeholder
BILLING_PRICE_TEAM_YEARLYOptional
The Lemon Squeezy variant id for
team-yearly.- Where to get it
- https://docs.lemonsqueezy.com/help/products/variants
- Placeholder
Dependencies
- @lemonsqueezy/lemonsqueezy.js^4.0.0
- server-only^0.0.1
Scripts
- bun run billing:prune-events
bun --conditions=react-server scripts/billing/prune-events.ts
- bun run billing:reconcile
bun --conditions=react-server scripts/billing/reconcile.ts
- bun run billing:sync-plans
bun --conditions=react-server scripts/billing/sync-plans.ts
- bun run billing:test-webhook
bun scripts/billing/test-webhook.ts
Files it writes
13 files, at these exact paths.
scripts/4 files
billing/4 files
- prune-events.ts
- reconcile.ts
- sync-plans.ts
- test-webhook.ts
src/8 files
app/1 file
api/1 file
webhooks/1 file
lemonsqueezy/1 file
- route.ts
lib/7 files
billing/7 files
- lemonsqueezy-fixtures.ts
- lemonsqueezy-objects.test.ts
- lemonsqueezy-objects.ts
- lemonsqueezy-provider.test.ts
- lemonsqueezy-signature.ts
- lemonsqueezy.ts
- provider.ts
tests/1 file
e2e/1 file
- billing-webhook.spec.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot env-required
- @slot legal-processors
- @slot verify-checks
The differentiator
What Lemon Squeezy teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Lemon Squeezy translates, the shared billing core writes
Loads onsrc/lib/billing/**scripts/billing/**.claude/rules/lemonsqueezy-billing-discipline.md
Billing here is one shared layer plus one adapter. This battery ships only the adapter:
| File | Job |
|---|---|
src/lib/billing/lemonsqueezy.ts | lazy SDK setup, unwrap(), the mode and the store id |
src/lib/billing/provider.ts | billingProvider, the BillingProvider contract from ./types: checkout, portal, webhook verify, translate |
src/lib/billing/lemonsqueezy-objects.ts | pure reads of Lemon Squeezy objects (payload schemas, statuses, orders, invoices, variant drift), tested |
src/lib/billing/lemonsqueezy-signature.ts | the X-Signature HMAC check |
src/lib/billing/lemonsqueezy-fixtures.ts | real webhook bodies for tests and billing:test-webhook. Never imported by the app |
src/app/api/webhooks/lemonsqueezy/route.ts | three lines into the shared processWebhook |
scripts/billing/*.ts | sync-plans, reconcile, prune-events, test-webhook |
Everything else under src/lib/billing, plus /pricing, /billing and
src/components/billing, is shared code that works the same for Stripe, Polar
and Dodo. Hold the line between the two:
- The adapter never writes billing state and never sends mail.
translatereturnsBillingEvent[];applyBillingEventsinwebhook.tsis the only writer. The adapter's store calls are reads:getUserIdForCustomerandfindPurchaseByPaymentId. - Nothing outside the adapter imports
@lemonsqueezy/lemonsqueezy.js. A feature that needs Lemon Squeezy data gets a method onBillingProvideror a field on aBillingEvent. Never a branch onbillingProvider.idin shared code. - Set the SDK up lazily.
ensureLemonSqueezy()runslemonSqueezySetupon first use.next buildimports modules with no secrets set, so a module-scope setup breaks every preview build. - The SDK never throws on an HTTP error. It returns
{ data, error }. Every call goes throughunwrap(), or checkserrorandstatusCodeitself (a 404 on a re-read is a fallback case, not an outage).
Plans live in pricing.ts, variant ids live in env
The catalogue is src/lib/pricing.ts. Each price is sold as one Lemon Squeezy
variant, named by an env var: pro-monthly is BILLING_PRICE_PRO_MONTHLY,
read literally in price-refs.ts. isValidPriceRef accepts digits only.
- No variant, product or store id in code. Test and live mode have different ids.
- The catalogue decides one-time versus subscription. A
one_timeprice must point at a single-payment variant,monthandyearat subscription variants.billing:sync-plansandverifycheck the kind, amount, currency, interval, trial, store and mode of every set id withvariantDrift. - Lemon Squeezy's API cannot create products. The dashboard is where they
are made;
billing:sync-plansfinds matching variants and prints the env lines. Never "fix" drift by editing the check.
Checkout sells one variant, priced on the server
createCheckout is called by the shared startCheckout with a catalogue
price that was already validated.
productOptions.enabledVariantsis always[variantId]. Without it the hosted page lets the buyer switch to any variant of the product.- Never pass
customPricefrom anything a user sent. It overrides the price of every renewal, not only the first charge. checkoutData.customcarries{ userId, planSlug, priceId }. Lemon Squeezy echoes it asmeta.custom_dataon every order and subscription webhook. It names the user; it never names the plan (a buy link can carry any custom data, the variant that was paid for cannot be faked).- Trials are a variant setting. A recurring price without
trialDayssendsskipTrial: true, so the catalogue stays the promise. testModecomes fromLEMONSQUEEZY_MODE(defaulttest) on every checkout, andverifyfails when the API key's own mode disagrees.
The portal URL is a credential
urls.customer_portal is pre-signed and valid for 24 hours. createPortal
fetches it fresh from the subscription (or the customer) on every click.
Never store it, log it or put it in an email. A buyer with no subscription
has no portal: Lemon Squeezy returns null and so does createPortal.
Scripts
Scripts that read the store or the adapter run with bun --conditions=react-server. verify
and billing:test-webhook run with bun and import only pure files
(catalog, price-refs, format, types, lemonsqueezy-objects,
lemonsqueezy-signature, lemonsqueezy-fixtures) and the SDK. Importing
lemonsqueezy.ts or provider.ts there throws on server-only.
The contract itself (types, entitlement rules, the store and its tables) is in the shared layer. Its rule is "Billing is one shared layer with one provider adapter".
Verify X-Signature on the raw body, and key every delivery without a delivery id
Loads onsrc/app/api/webhooks/lemonsqueezy/**src/lib/billing/provider.tssrc/lib/billing/lemonsqueezy-objects.tssrc/lib/billing/lemonsqueezy-signature.tssrc/lib/billing/webhook.ts.claude/rules/lemonsqueezy-webhook-integrity.md
The webhook route is a public URL with no session. The signature is the only thing between a Lemon Squeezy event and a forged POST that grants a lifetime plan.
The route is three lines: processWebhook(billingProvider, request). The
pipeline in src/lib/billing/webhook.ts is shared by every provider; the
Lemon Squeezy parts are verifyWebhook and translate in provider.ts. Keep
it that way: nothing Lemon Squeezy specific in the route.
Verify first, on the raw body
processWebhookreadsawait request.text()and hands those exact bytes toverifyWebhook. Never parse, re-stringify or add a body parser in front.isValidSignatureinlemonsqueezy-signature.tscompares the hex HMAC-SHA256 in constant time. Use it; do not write a second one.- A missing or placeholder
LEMONSQUEEZY_WEBHOOK_SECRETthrowsWebhookConfigError(500, Lemon Squeezy retries). A missing or wrongX-SignaturethrowsWebhookSignatureError(400). The body is parsed only after the signature matched. - No "local dev" bypass.
bun run billing:test-webhooksigns fixtures with your real secret.
The idempotency key is built, not received
Lemon Squeezy sends no delivery id, and meta.webhook_id names the webhook
configuration, not the delivery. webhookEventKey builds one from what a
retry keeps and a real change moves:
<event_name>:<data.type>:<data.id>:<data.attributes.updated_at>
The shared pipeline claims lemonsqueezy:<key> before any side effect. A
duplicate answers 200 duplicate; a throw releases the claim and answers 500.
Never drop the event name from the key (subscription_updated and
subscription_cancelled share one updated_at) and never change its shape
without a plan for in-flight retries.
What translate may trust
- Orders come from the signed payload. An order only moves forward (paid, then refunded), and the shared purchase upsert never moves a row backwards, so the order deliveries land in cannot matter.
- Subscriptions and invoices are re-read with
getSubscriptionandgetSubscriptionInvoice. They move both ways, andsubscription_updatedfires beside almost every other event. The payload is only a fallback when the API answers 404. - A purchase is a one-time price by the catalogue, never by the payload.
order_createdfires for every order, including a subscription's first payment. Only an order whose variant maps to aone_timecatalogue price (or that already has a purchase row) becomes a purchase. - Custom data names the user, not the plan. The plan comes from the variant. A subscription's plan comes from its current variant only.
- Drop other-mode and other-store events.
translatereturns no events whenmeta.test_modedisagrees withLEMONSQUEEZY_MODEorstore_idis notLEMONSQUEEZY_STORE_ID. A 4242 test purchase must never entitle anyone on production.
Never answer 200 for money you did not record
A paid one-time order that no local user can be matched to throws
UnmatchedOrderError: 500, red in the dashboard, retried. A quiet 200 would
mark it done and make that money unrecordable through the webhook. Money
fields are validated with zod (Number.isInteger, no ?? 0, no ?? "usd").
Mail is an event
translate returns payment.receipt (paid one-time orders, paid subscription
invoices) and payment.failed (declined renewals whose invoice is still
unpaid). The core sends them inside the claim and logs a failure instead of
throwing. Never send mail from the adapter.
The handled events are a list you keep in sync
HANDLED_EVENTS in provider.ts is the source of truth. The events ticked
on the webhook in the dashboard must match it, for both the test-mode and
live-mode webhooks. Anything else answers 200 ignored and writes nothing.
Three retries is a short fuse
Lemon Squeezy retries a failed delivery three more times (about 5s, 25s,
125s) and then stops. bun run billing:reconcile rebuilds subscriptions
and recent orders from the API; schedule it nightly. billing:prune-events
trims completed claims and reports stuck ones. Never delete a stuck claim to
"clean up".
Skills (2)
Invoked by name.
- /add-plan
Add or change a plan or price on Lemon Squeezy (monthly, yearly or one-time lifetime). Edit src/lib/pricing.ts, create the variant, wire its env var, and prove checkout and the webhook end to end.
.claude/skills/add-plan/SKILL.md
- /test-webhook
Exercise the Lemon Squeezy webhook endpoint. Signed one-time purchases and refunds on localhost, forged requests, duplicates, mode and store guards, subscriptions and failed renewals.
.claude/skills/test-webhook/SKILL.md
Solution docs (8)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Idempotent Lemon Squeezy webhooks when there is no delivery idLemon Squeezy signs the raw body with X-Signature, sends no event id, and retries three times. Build your own idempotency key, verify in constant time, and know what to re-read.docs/solutions/lemonsqueezy/idempotent-webhooks-without-a-delivery-id.md
- Lemon Squeezy license keys for desktop apps, CLIs and pluginsTurn on license keys per variant, activate and validate from the client with the public License API, and tie key expiry to the subscription. What to cache and what never to ship.docs/solutions/lemonsqueezy/license-keys.md
- What "merchant of record" means for your taxes on Lemon SqueezyLemon Squeezy sells to your customer and you sell to Lemon Squeezy. What that moves off your plate, what it does not, and how to show prices honestly.docs/solutions/lemonsqueezy/merchant-of-record-tax.md
- One-time purchases on Lemon Squeezy, from lifetime deal to refundA 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.docs/solutions/lemonsqueezy/one-time-purchases-with-lemon-squeezy.md
- Lemon Squeezy orders, invoices and partial refunds, and what each does to accessThe first payment arrives as an order and an invoice, refunded_amount is a running total, and chargebacks send no event. How to record each once and revoke access on the right one.docs/solutions/lemonsqueezy/orders-invoices-and-partial-refunds.md
Show all 8Show fewer
- Recovering from missed Lemon Squeezy webhooksThree retries over about two and a half minutes, then the event is gone. A nightly reconcile from the API, what it can and cannot rebuild, and how to fix the rows it cannot.docs/solutions/lemonsqueezy/recovering-from-missed-webhooks.md
- The Lemon Squeezy subscription state machine, and who keeps accesson_trial, active, past_due, unpaid, paused, cancelled, expired. Which unlock your product, how to normalize them, and the two states people get wrong.docs/solutions/lemonsqueezy/subscription-states-and-access.md
- Moving a Lemon Squeezy integration from test mode to live modeTest and live are separate worlds with separate keys, webhooks and variant ids. The checklist, and the silent mismatch that sells nothing.docs/solutions/lemonsqueezy/test-mode-to-live-mode.md
How it fits
What Lemon Squeezy needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
- A database battery. The resolver adds the default one for you and tells you why.
- An auth battery. The resolver adds the default one for you and tells you why.
Pairs well with
- An email battery. Suggested, never added for you.
Compared with the alternatives
Build a repo with Lemon Squeezy
Free and MIT. The builder opens with Lemon Squeezy picked. You download the zip right away, and we email you the link too.