Payments
Payments
The kit sells your plans through Stripe, Polar, Lemon Squeezy or Creem, using
each provider's hosted checkout and billing portal. You work here when you
change what you sell, connect a provider, or put a feature behind a plan. It is
all switched from the payments block in
starter.config.ts(Licensees only).
What is built
Subscriptions with monthly and yearly prices, one-time and repeatable purchases, trials, coupon links, a grace period for failed renewals, guest checkout, refunds and disputes, staff grants, permissions sold per plan, and an optional plan gate that locks an organization's pages until it pays.
Only the providers listed in payments.providers are bundled into the Worker.
The adapters in server/payments/adapters/(Licensees only)
call each provider's REST API with fetch and verify webhooks with Web Crypto,
with no vendor SDK.
| Stripe | Polar | Lemon Squeezy | Creem | |
|---|---|---|---|---|
| What a ref is | price id, price_… | product id, a UUID | variant id, a number | product id, prod_… |
| Sets the trial length at checkout | yes | yes | no | no |
| Merchant of record (handles sales tax) | no | yes | yes | yes |
| Can hide its own coupon box | yes | yes | yes | no |
How a purchase works
- The buyer presses a
PaymentsCheckoutButtonon/pricingor on an organization's billing page. It posts the plan id, the interval and the organization toPOST /api/payments/checkout. - The route reads the provider's price id from your environment, refuses a duplicate purchase, applies any coupon and trial, and returns the hosted checkout URL. The browser never names a price.
- After payment the provider calls
POST /webhooks/payments/:provider. The adapter checks the signature and turns the payload into a normalized event, stored once inpayment_events. - The event becomes a row in
entitlementson the organization's root. That row is the grant every paid check reads. payments:settledfires for your listeners. The page the buyer returns to polls until the grant shows.
A plan belongs to an organization (Tenancy), and the
organization is fixed at checkout. With organizations off, it lands on the
account's own organization, created on the first purchase. An organization
holds one live subscription per provider, so plan and billing-cycle changes go
through the provider's portal (the Change plan button,
POST /api/payments/portal). The next subscription event names the new price,
and the kit maps it back to a plan through your refs.
Define your plans
The catalogue is payments.plans. The kit ships pro, promax and
lifetime; two of them look like this:
plans: {
pro: {
mode: 'subscription',
currency: 'usd',
trialDays: 14,
intervals: {
month: { amountMinor: 499 },
year: { amountMinor: 4990 },
},
},
lifetime: { mode: 'one-time', currency: 'usd', amountMinor: 39900 },
},
amountMinoris the price the pricing page shows, in minor units, for the whole interval:4990is $49.90 a year.intervalstakesmonth,yearor both. Each is a separate price at the provider.trialDaysapplies to every interval of the plan.- The free tier is not in the catalogue. A free account is one with no grant.
The build refuses a plan id outside /^[a-z][a-z0-9]*$/ (promax, never
proMax), plans in more than one currency, a year listed cheaper than a month,
and a plan whose mode is missing from payments.modes.
Add a plan
- Add the entry to
payments.plans. - Give it an entry in
access.planPermissionsandorgs.seatLimits. Both are typed over every plan id, so typecheck fails until you do. - Add its copy to every locale:
pricing.plans.<id>.title,.descriptionand.feature1, up to.feature8.test/unit/plan-copy.test.ts(Licensees only) fails on a missing key. - Create the price at each provider and set its refs.
The pricing page builds its cards from the catalogue in declaration order, so there is no page to edit. The monthly/yearly toggle appears once some plan declares both intervals.
Set the refs
Every price has a ref slot: <plan>_<interval> for a subscription and the bare
plan id for a one-time plan. Each slot takes one variable per enabled provider.
The shipped catalogue has five:
NUXT_PAYMENTS_STRIPE_REFS_PRO_MONTH=price_...
NUXT_PAYMENTS_STRIPE_REFS_PRO_YEAR=price_...
NUXT_PAYMENTS_STRIPE_REFS_PROMAX_MONTH=price_...
NUXT_PAYMENTS_STRIPE_REFS_PROMAX_YEAR=price_...
NUXT_PAYMENTS_STRIPE_REFS_LIFETIME=price_...
Or the whole map in one variable:
NUXT_PAYMENTS_STRIPE_REFS='{"pro_month":"price_1","pro_year":"price_2","promax_month":"price_3","promax_year":"price_4","lifetime":"price_5"}'
Set every declared interval, including one a customer only reaches by switching
in the portal. A subscription that moves onto a price no slot holds records no
plan. Each slot needs its own id: checkout refuses two slots sharing one and
names both variables. Outside pnpm dev, the Worker logs every unset ref at
boot.
Sell something repeatable
A one-time plan with repeatable: true can be bought again and again:
credits: {
mode: 'one-time',
currency: 'usd',
amountMinor: 1900,
repeatable: true,
},
It writes no grant, so it unlocks no permission and never opens the plan gate.
Each purchase is still recorded and fires payments:settled, which is where
you add credits to a balance of your own
(React to a purchase). For volume discounts, declare
one repeatable plan per pack size, each with its own price.
Connect a provider
- Add the provider to
payments.providers. The first entry is the default when a checkout names none. - Set
NUXT_PAYMENTS_<PROVIDER>_SECRET_KEYandNUXT_PAYMENTS_<PROVIDER>_WEBHOOK_SECRET. - Register
https://<your-domain>/webhooks/payments/<provider>with the provider and subscribe it to the events below. That endpoint's signing secret is the webhook secret. - Set a ref for every slot.
- In the provider's portal settings, list every plan and interval a customer may switch to.
| Provider | Subscribe the endpoint to |
|---|---|
| Stripe | checkout.session.completed, checkout.session.async_payment_succeeded, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, charge.refunded, charge.dispute.closed |
| Polar | order.paid, order.refunded, subscription.created, subscription.active, subscription.updated, subscription.canceled, subscription.revoked |
| Lemon Squeezy | order_created, order_refunded, subscription_created, subscription_updated, subscription_resumed, subscription_cancelled, subscription_expired, subscription_payment_refunded |
| Creem | checkout.completed, subscription.active, subscription.trialing, subscription.paid, subscription.update, subscription.scheduled_cancel, subscription.paused, subscription.unpaid, subscription.past_due, subscription.expired, subscription.canceled, refund.created, dispute.created |
What each provider needs on top:
- Stripe: pin the webhook endpoint's API version to
2025-03-31.basil, the version the adapter calls with, or payload fields move under it. A restricted key also needs read access to promotion codes and prices and write access to refunds. - Polar: set
NUXT_PAYMENTS_POLAR_ENVIRONMENTtosandboxorproduction. Unset means production. Give the webhook endpoint API version2026-10, the version the adapter calls with. Polar's dashboard also offers the next version, which Polar can still change. The access token needscheckouts:writeto open checkouts,webhooks:readandorders:readfor the repair when a webhook never arrives,customer_sessions:writefor the portal,customers:writeto keep the billing email in step,subscriptions:writeto cancel on account deletion,discounts:readfor coupons andrefunds:writefor staff refunds. - Lemon Squeezy: set
NUXT_PAYMENTS_LEMONSQUEEZY_STORE_IDto the numeric store id. - Creem: set
NUXT_PAYMENTS_CREEM_ENVIRONMENTtosandboxorproduction. Unset means the test API. Subscribe to the whole list above, which is longer than Creem's own guide: withoutsubscription.active, a customer who resubscribes never gets access back.
Test locally
Payments ship on with Stripe, and checkout answers 500 until the secret key and
webhook secret are set. Use Stripe's test keys, sandbox on Polar and Creem,
and Lemon Squeezy's test mode.
Stripe's CLI forwards webhooks to the dev server:
stripe listen --forward-to localhost:3000/webhooks/payments/stripe
Put the whsec_… it prints in NUXT_PAYMENTS_STRIPE_WEBHOOK_SECRET, restart
pnpm dev, and pay with the test card 4242 4242 4242 4242.
The other providers need a public URL. pnpm tunnel opens a
*.trycloudflare.com tunnel to port 3000, which the dev server already accepts.
Register https://<tunnel-host>/webhooks/payments/<provider> in the provider's
sandbox. A tunnel on a host of your own needs NUXT_DEV_TUNNEL_HOSTS.
If a webhook never arrives, the page the buyer returned to asks the provider directly when its confirmation poll runs out, and writes the grant if the checkout was paid. That repair covers signed-in checkouts on Stripe and Polar, and one-time purchases on Creem. Lemon Squeezy can't be repaired this way.
Gate a feature on a plan
Sell a permission, then check the permission. access.planPermissions maps
each plan to the permissions it sells; the shipped config sells
insights:read on pro, promax and lifetime, and nothing on free. The
route checks the permission against the organization it acts on. This is
server/api/example-paid.get.ts(Licensees only):
export default defineEventHandler(async (event) => {
const org = getQuery(event).org
const scope =
starter.orgs.enabled && typeof org === 'string' && org.length > 0
? await requireOrgScope(event, org)
: undefined
await requirePlanGate(event, scope)
const session = await requirePermission(
event,
'insights:read',
scope?.rootOrgId,
)
return { email: session.user.email }
})
requirePermission answers 402 with data.reason: 'upgrade-required' when no
live grant on that organization includes the permission. A tier you add later
that includes insights:read unlocks the route without touching it.
requireEntitlement(event, ['pro'], rootOrgId) checks plan ids instead, and
requireEntitlement(event, undefined, rootOrgId) accepts any live grant. With
organizations on, pass the organization id: without it these throw a 500. With
payments off, every paid check passes. Roles and permissions are covered in
Permissions.
In the browser, useEntitlement(org) returns active, hasPlan, pending
and failed for rendering. Wait for pending before you show an upsell, and
check failed before you tell a customer they are on the free plan.
PaidFeatureCard.vue(Licensees only) and the route
above are the worked example; point them at something real or delete them.
Lock the whole product behind a plan
Set payments.gate: 'plan' and an organization's dashboard pages, plus every
route that calls requirePlanGate, open only while its root holds a live
grant.
- On the server, call
requirePlanGate(event, scope)afterrequireOrgScopein each product route. It answers 402 withdata.reason: 'plan-required'. Under'none'it only checks the session, so you can add it now and turn the gate on later. - In the browser,
PlanGatewraps the page slot inapp/layouts/dashboard.vue(Licensees only) and shows a lock that links to pricing. A page that must stay reachable opts out withdefinePageMeta({ planGate: false }). Billing, members, general, audit, account settings, the invitation page and the staff pages already do.
A live grant is an active or trialing subscription, a failed one inside its grace, a cancelled one with paid time left, an unrefunded one-time purchase, or a staff grant. Staff are gated in their own organizations like anyone else.
React to a purchase
Add a listener file to server/listeners/(Licensees only) (the bus is
covered in Events). It runs after the grant is committed.
server/listeners/grant-credits.ts:
import { defineListener } from '#server/events/types'
export default defineListener({
event: 'payments:settled',
async handle({ userId, eventRowId, normalized }, { event }) {
if (userId === null || eventRowId === undefined) return
if (normalized.type !== 'payment.succeeded') return
await useDrizzle(event)
.insert(tables.creditGrants)
.values({ userId, eventRowId, amount: 500 })
.onConflictDoNothing()
},
})
creditGrants is your own table in server/database/custom/schema.ts, with a
unique eventRowId.
payments:settledfires once for every provider event the kit applies: purchases, subscription starts and changes, cancellations and refunds. Filter onnormalized.type. A one-time purchase ispayment.succeeded, a new subscriptionsubscription.created.eventRowIdnames onepayment_eventsrow per provider event, so a unique constraint on it survives redelivery.userIdis null for a purchase the kit did not start, such as one made from the provider's dashboard.payments:refundedfires when money goes back. Take back there whatever you granted.- A listener that throws is logged and never retried. Work that must not be
lost goes on the queue with
enqueue()(Background jobs).
A guest, or a signed-in buyer of a one-time plan who named no organization,
returns to /checkout/complete. That page polls the purchase and shows what a
listener recorded with recordDelivery from #server/payments/settled: url
becomes a button, title and message are shown, and any other key is listed.
await recordDelivery(event, eventRowId, {
title: 'Invitation sent',
message: 'Check your inbox for the GitHub invitation.',
})
Trials
Set trialDays on a subscription plan. Stripe and Polar take the length at
checkout. Creem and Lemon Squeezy can only run a trial set on the product, so
the build refuses trialDays while either is enabled. The provider takes the
card up front and charges it when the trial ends.
Each buyer gets one trial. Checkout drops it when the payer, or the organization being billed, has ever held a subscription on any provider. On Polar, also turn on Prevent trial abuse in the dashboard to catch the same person on a second account.
Coupons
Create codes in the provider's dashboard; on Stripe that is a promotion code on
a coupon. Link to any page with ?coupon=CODE. The kit keeps the code in a
coupon cookie for seven days, shows it on the pricing page and sends it with
the next checkout. Checkout asks the provider whether the code applies before it
opens a session, and answers 422 with coupon-unknown, coupon-not-for-plan
or coupon-not-for-buyer when it does not. Checkouts carrying a code are
limited to 20 an hour per account, or per IP for a guest.
payments.couponField decides whether the hosted page also offers its own code
box.
Failed renewals, refunds and disputes
A subscription whose renewal charge fails goes past_due and keeps access for
payments.pastDueGraceDays, counted from the first failed charge, while the
provider retries. Everyone who can bill the organization sees a banner linking
to the portal. Match the grace to your provider's retry window: 14 days is
Stripe's and Lemon Squeezy's default, Polar retries for 21. The kit sends no
dunning email of its own, so turn on Stripe's emails for failed card payments.
A full refund of a one-time purchase revokes that purchase's grant, and a partial refund revokes nothing. A refunded subscription loses access at once, but the provider keeps billing it until you cancel it there. A lost Stripe dispute revokes, a Creem dispute revokes as soon as it opens, and Polar and Lemon Squeezy send no dispute events.
Tax
Polar, Lemon Squeezy and Creem are the merchant of record and handle sales tax.
On Stripe you are the seller. NUXT_PAYMENTS_STRIPE_TAX_ENABLED=true turns on
Stripe Tax and collects a billing address. Tax is added on top only when each
Price has tax_behavior: exclusive, which you set in Stripe and cannot change
later. Without tax registrations Stripe calculates zero and reports no error.
Guest checkout
payments.identity: 'guest' lets someone without an account buy a one-time
plan. They give an email and pass a Turnstile check. When the webhook lands,
the kit creates an unverified account for the address the provider recorded
and grants the purchase to it. Subscriptions still need a signed-in, verified
account.
A new account takes the name the payer gave the provider, and its first organization is named after it. Invisible and control characters are stripped, and a name that is then blank, longer than 100 characters or holds no letter or digit is dropped, which leaves the address's local part as before. An account that already exists is never renamed.
An app that signs the paying browser in to that account holds a session nobody
has proven the address for. Under auth.primary: 'passwordless' the kit holds
it back until a sign-in by email link, which signs that session out
(Auth).
Grant a plan or refund from the customers page
Staff holding users:manage get two row actions on /dashboard/customers:
- Grant a plan gives one of the customer's organizations a plan without
payment, forever or until a date. It opens everything the paid plan opens and
is written to the organization's audit log. Take back removes it. A grant
fires no
payments:settled, so listeners that hand out credits give none. - Refund a purchase asks the provider to return what is left of a one-time purchase. Access ends when the refund webhook arrives, the same as a refund made in the provider's dashboard. Refund subscriptions in the provider's dashboard.
You can't change the plan of an organization that you or another administrator
belongs to, and you can't refund your own or another administrator's purchase.
The routes are POST /api/admin/billing/grant, /revoke and /refund.
Turn payments off
Set payments.enabled: false. No adapter is bundled, /pricing and the
checkout and webhook routes answer 404, and every paid check passes:
requirePermission grants purchasable permissions to everyone. The build
refuses payments.gate: 'plan' with payments off.
Configuration
| Key | Default | What it does |
|---|---|---|
payments.enabled | true | Off bundles no adapter and lets every paid check pass. |
payments.identity | 'account' | 'guest' allows one-time purchases without an account. |
payments.modes | ['one-time', 'subscription'] | Purchase shapes the app sells. A plan in any other mode fails the build. |
payments.providers | ['stripe'] | Providers to bundle. The first is the default. |
payments.couponField | true | Whether the hosted checkout shows its own code box. false fails the build with Creem. |
payments.pastDueGraceDays | 14 | Days a failed subscription keeps access after its first failed charge. false ends access at once. |
payments.gate | 'none' | 'plan' locks an organization's pages until it holds a live grant. |
payments.plans | pro, promax, lifetime | The catalogue. |
payments.adapters | none | Capabilities and credentials of an adapter you add in server/payments/adapters/custom/. |
access.planPermissions | insights:read on every paid plan | Permissions each plan sells. free is the tier with no grant. |
orgs.seatLimits | free 3, pro 10, promax unlimited, lifetime 10 | Members each plan admits. |
| Variable | What it does |
|---|---|
NUXT_PAYMENTS_<PROVIDER>_SECRET_KEY | API key. |
NUXT_PAYMENTS_<PROVIDER>_WEBHOOK_SECRET | Webhook signing secret. |
NUXT_PAYMENTS_<PROVIDER>_REFS_<SLOT> | The provider's id for one ref slot, such as REFS_PRO_MONTH. |
NUXT_PAYMENTS_<PROVIDER>_REFS | The whole ref map as JSON, keyed by slot. |
NUXT_PAYMENTS_POLAR_ENVIRONMENT, NUXT_PAYMENTS_CREEM_ENVIRONMENT | Exactly sandbox or production. Unset, Polar is live and Creem is test. |
NUXT_PAYMENTS_LEMONSQUEEZY_STORE_ID | Numeric store id. |
NUXT_PAYMENTS_STRIPE_TAX_ENABLED | true turns on Stripe Tax. Off otherwise. |
NUXT_PAYMENTS_STRIPE_TAX_ID_COLLECTION | true asks for a VAT or GST number at checkout. Off otherwise. |
Good to know
amountMinoris only shown. The ref is what charges the card, and nothing checks that the two agree, so change them together.- An empty line such as
NUXT_PAYMENTS_STRIPE_REFS_PRO_MONTH=overrides the default with an empty string. Comment unused lines out. - Deleting an interval from a plan stops its ref variable being read, and
subscribers still on that price lose their plan on the next event. To stop
selling it, delete it and keep its id in the JSON form, for example
NUXT_PAYMENTS_STRIPE_REFS='{"pro_year":"price_…"}'. - A purchase can't be moved to another organization afterwards. The pricing page names the organization being billed before the buyer pays.
- A subscription whose period end has passed grants nothing until the renewal webhook moves the period on. A broken webhook endpoint locks paying customers out at their next renewal, and with the plan gate on that is the whole product.
- The Polar adapter pins API version
2026-10. By Polar's published schedule, Polar stops serving it at its April 2027 release. From then on the adapter repeats each refused request on Polar's current version, which it was never checked against. Once per Worker isolate it logs the refusal and reports it to your error trackers. Update the kit before then. - Polar signs with either the secret's own bytes or its base64 decoding, the Standard Webhooks key. Secrets Polar creates from 8 September 2026 use the second, and so did secrets set through the API until 11 September. The adapter tries both, so paste the secret exactly as Polar shows it.
A link with a lock opens the kit’s private repository, which every licensee can read.