Skip to content

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.

StripePolarLemon SqueezyCreem
What a ref isprice id, price_…product id, a UUIDvariant id, a numberproduct id, prod_…
Sets the trial length at checkoutyesyesnono
Merchant of record (handles sales tax)noyesyesyes
Can hide its own coupon boxyesyesyesno

How a purchase works

  1. The buyer presses a PaymentsCheckoutButton on /pricing or on an organization's billing page. It posts the plan id, the interval and the organization to POST /api/payments/checkout.
  2. 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.
  3. After payment the provider calls POST /webhooks/payments/:provider. The adapter checks the signature and turns the payload into a normalized event, stored once in payment_events.
  4. The event becomes a row in entitlements on the organization's root. That row is the grant every paid check reads.
  5. payments:settled fires 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 },
},
  • amountMinor is the price the pricing page shows, in minor units, for the whole interval: 4990 is $49.90 a year.
  • intervals takes month, year or both. Each is a separate price at the provider.
  • trialDays applies 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

  1. Add the entry to payments.plans.
  2. Give it an entry in access.planPermissions and orgs.seatLimits. Both are typed over every plan id, so typecheck fails until you do.
  3. Add its copy to every locale: pricing.plans.<id>.title, .description and .feature1, up to .feature8. test/unit/plan-copy.test.ts(Licensees only) fails on a missing key.
  4. 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

  1. Add the provider to payments.providers. The first entry is the default when a checkout names none.
  2. Set NUXT_PAYMENTS_<PROVIDER>_SECRET_KEY and NUXT_PAYMENTS_<PROVIDER>_WEBHOOK_SECRET.
  3. 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.
  4. Set a ref for every slot.
  5. In the provider's portal settings, list every plan and interval a customer may switch to.
ProviderSubscribe the endpoint to
Stripecheckout.session.completed, checkout.session.async_payment_succeeded, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, charge.refunded, charge.dispute.closed
Polarorder.paid, order.refunded, subscription.created, subscription.active, subscription.updated, subscription.canceled, subscription.revoked
Lemon Squeezyorder_created, order_refunded, subscription_created, subscription_updated, subscription_resumed, subscription_cancelled, subscription_expired, subscription_payment_refunded
Creemcheckout.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_ENVIRONMENT to sandbox or production. Unset means production. Give the webhook endpoint API version 2026-10, the version the adapter calls with. Polar's dashboard also offers the next version, which Polar can still change. The access token needs checkouts:write to open checkouts, webhooks:read and orders:read for the repair when a webhook never arrives, customer_sessions:write for the portal, customers:write to keep the billing email in step, subscriptions:write to cancel on account deletion, discounts:read for coupons and refunds:write for staff refunds.
  • Lemon Squeezy: set NUXT_PAYMENTS_LEMONSQUEEZY_STORE_ID to the numeric store id.
  • Creem: set NUXT_PAYMENTS_CREEM_ENVIRONMENT to sandbox or production. Unset means the test API. Subscribe to the whole list above, which is longer than Creem's own guide: without subscription.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) after requireOrgScope in each product route. It answers 402 with data.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, PlanGate wraps the page slot in app/layouts/dashboard.vue(Licensees only) and shows a lock that links to pricing. A page that must stay reachable opts out with definePageMeta({ 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:settled fires once for every provider event the kit applies: purchases, subscription starts and changes, cancellations and refunds. Filter on normalized.type. A one-time purchase is payment.succeeded, a new subscription subscription.created.
  • eventRowId names one payment_events row per provider event, so a unique constraint on it survives redelivery.
  • userId is null for a purchase the kit did not start, such as one made from the provider's dashboard.
  • payments:refunded fires 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

KeyDefaultWhat it does
payments.enabledtrueOff 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.couponFieldtrueWhether the hosted checkout shows its own code box. false fails the build with Creem.
payments.pastDueGraceDays14Days 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.planspro, promax, lifetimeThe catalogue.
payments.adaptersnoneCapabilities and credentials of an adapter you add in server/payments/adapters/custom/.
access.planPermissionsinsights:read on every paid planPermissions each plan sells. free is the tier with no grant.
orgs.seatLimitsfree 3, pro 10, promax unlimited, lifetime 10Members each plan admits.
VariableWhat it does
NUXT_PAYMENTS_<PROVIDER>_SECRET_KEYAPI key.
NUXT_PAYMENTS_<PROVIDER>_WEBHOOK_SECRETWebhook signing secret.
NUXT_PAYMENTS_<PROVIDER>_REFS_<SLOT>The provider's id for one ref slot, such as REFS_PRO_MONTH.
NUXT_PAYMENTS_<PROVIDER>_REFSThe whole ref map as JSON, keyed by slot.
NUXT_PAYMENTS_POLAR_ENVIRONMENT, NUXT_PAYMENTS_CREEM_ENVIRONMENTExactly sandbox or production. Unset, Polar is live and Creem is test.
NUXT_PAYMENTS_LEMONSQUEEZY_STORE_IDNumeric store id.
NUXT_PAYMENTS_STRIPE_TAX_ENABLEDtrue turns on Stripe Tax. Off otherwise.
NUXT_PAYMENTS_STRIPE_TAX_ID_COLLECTIONtrue asks for a VAT or GST number at checkout. Off otherwise.

Good to know

  • amountMinor is 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.