Modules

Subscriptions

Recurring Stripe subscriptions - host-defined plans, checkout, upgrade/downgrade, billing portal, entitlements and account UI.

Recurring subscriptions on top of Stripe. It handles only recurring subscriptions; one-off payments belong to Payments. "Membership" is just a label a host chooses for its plans.

  • Stripe owns the catalog; the module mirrors per-user state. No products or prices tables. Plans are declared in config and prices are resolved live from Stripe, cached in NuxtHub KV for about five minutes.
  • Host policy through hooks. Seasonal windows, member discounts and similar rules are injected with hooks, not built in.
  • No copy in Stripe. Labels come from config and optional i18n keys. The UI is headless-first (composables) with replaceable components and slots; pages are opt-in.
  • Entitlements, not roles. Other code asks "does this user hold plan X?".
Requires@nuxthub/core (KV on; the build throws without it), @frigear-nu/module-kit, @frigear-nu/auth, @frigear-nu/mcp, @frigear-nu/users and @frigear-nu/stripe (configure it as described there: keys, webhook and KV) and @frigear-nu/ui (the shared components the pages are built from). Optional@frigear-nu/menu, @frigear-nu/widget and @frigear-nu/admin (the admin user tab and card need both), @nuxt/ui (components and pages) and @nuxtjs/i18n (translated labels; English otherwise).

Quick setup

List the module after the ones it builds on:

export default defineNuxtConfig({
  modules: [
    '@nuxthub/core',
    '@nuxt/ui',
    '@frigear-nu/users',
    '@frigear-nu/menu', // optional
    '@frigear-nu/stripe',
    '@frigear-nu/subscriptions',
  ],
  hub: { db: { dialect: 'sqlite', driver: 'libsql' }, kv: true },
  subscriptions: {
    plans: {
      member: { label: 'Member', rank: 1, prices: [{ lookupKey: 'member_yearly' }] },
      patron: { label: 'Patron', rank: 2, prices: [{ lookupKey: 'patron_yearly' }] },
    },
    settings: { pages: { membership: true, success: true, cancel: true } },
  },
})
  1. Set the Stripe environment variables (test keys in development).
  2. Complete the Stripe dashboard setup.
  3. Generate and commit your app's migration (see Database); skipping it is the most common cause of failing webhooks and checkouts.
  4. Verify: sign in, open /account/membership and subscribe with Stripe's test card 4242 4242 4242 4242. After the redirect, GET /api/subscriptions/me returns the subscription once the webhook has landed.

Options

OptionTypeDefaultPurpose
plansRecord<string, PlanConfig>{}Plans keyed by plan key. Empty means "any active subscription" (see below).
plans.<key>.pricesArray<string | { lookupKey }>-Required. Stripe price ids and/or lookup refs; several per plan (monthly, yearly).
plans.<key>.ranknumber-Required. Higher is a higher tier; decides upgrade vs downgrade. Amounts are never compared.
plans.<key>.labelstringthe keyDisplay label; i18n subscriptions.catalog.<key>.label wins when defined.
plans.<key>.descriptionstring-Optional; i18n catalog.<key>.description wins.
plans.<key>.featuresstring[]-Bullet points on the plan card; i18n catalog.<key>.features (array) wins.
plans.<key>.availability.disabledRanges{ from, to }[]-Date windows (inclusive; {fullYear} becomes the current year, e.g. {fullYear}-06-01) in which the plan is hidden from visitors without an active subscription. Subscribers still see it; checkout rejects a hidden plan.
settings.pages.membershipstring | booleanofftrue = /account/membership, a string = custom path.
settings.pages.successstring | booleanofftrue = /account/membership/success.
settings.pages.cancelstring | booleanofftrue = /account/membership/cancel.
settings.allowChangeInPortalbooleanfalseHint exposed as public.subscriptions.allowChangeInPortal. Not enforced; mirror your billing portal.
settings.mcpbooleanfalseExpose the my-subscriptions tool on the MCP endpoint.

Without plans, checkout requires a priceId and accepts any active recurring Stripe price; stored rows get planKey = null. Runtime config exposes public.subscriptions (pages, plans sorted by rank without prices, and allowChangeInPortal) and a private subscriptions.plans. Pages need @nuxt/ui in the app; leave them out to build your own UI with the composables.

Server

Entitlements

Auto-imported in Nitro, or import from @frigear-nu/subscriptions/utils:

getUserSubscription(userId: number, options?: { statuses?: string[] }): Promise<SubscriptionRow | null>
hasActiveSubscription(userId: number, options?: {
  plans?: string[]      // only these plan keys; omit for any plan
  statuses?: string[]   // default DEFAULT_ENTITLED_STATUSES = ['active', 'trialing']
}): Promise<boolean>

getUserSubscription returns the entitled row with the latest currentPeriodEnd. Rows with endedAt, and manual grants past their end date, never count. These are meant for other modules (for example Events), server middleware and gating.

Also exported from @frigear-nu/subscriptions/utils:

ExportDescription
decideChangeKind(currentRank, targetRank)'upgrade' or 'downgrade' from plan ranks; an unconfigured current plan counts as lowest.
resolvePlanKey(plans, { id, lookupKey? })Maps a Stripe price to the configured plan key, or null.
isWithinDisabledRanges(ranges, now?)Whether a date falls in any disabledRanges window.
DEFAULT_ENTITLED_STATUSES['active', 'trialing'].

Further server helpers are auto-imported in Nitro: listUserSubscriptions(userId) (the MeResponse), getResolvedPlans() (unfiltered, cached) and getAvailablePlans(event?) (after availability windows and the subscriptions:plans:filter hook).

Webhook handling

The module registers with registerStripeEventHandler for customer.subscription.created, updated, deleted, paused and resumed. Each event is an idempotent upsert keyed by the Stripe subscription id:

  • The user is resolved from the Stripe customer id. For an unknown customer the event is logged and ignored (the webhook still succeeds); users are never created from webhooks.
  • planKey comes from the first subscription item's price (price id or lookup key), else null.
  • Period start and end are derived from the items; deleted stores status canceled and sets endedAt.

Retries and idempotency follow Stripe. When @frigear-nu/payments is installed, invoice.paid, invoice.payment_failed, invoice.voided and invoice.marked_uncollectible for subscription invoices are also handled: they are announced through the subscriptions:invoice hook and stored by payments as membership payments. Without payments no invoice event is handled.

Checkout

POST /api/subscriptions/checkout takes CheckoutBody:

type CheckoutBody = {
  planKey?: string      // planKey or priceId is required (400 otherwise)
  priceId?: string      // required when the plan has several prices, and when no plans are configured
  successUrl?: string   // relative path or same-origin URL, else 400; defaults to the success page, membership page or '/'
  cancelUrl?: string
}

type CheckoutResponse =
  | { url: string }                                           // new Stripe Checkout Session: redirect to it
  | { updated: true, kind: 'upgrade' | 'downgrade' | 'resume' } // an existing subscription was changed in place

The plan must be in the filtered plan list (a hidden plan is 400). A user "has a subscription" here when a Stripe-synced row is active, trialing or past_due and not ended; manual grants are ignored.

SituationResult
No existing subscriptionkind: 'checkout': returns { url }.
Same price, set to cancel at period endresume: releases a pending schedule and clears the cancellation. Skips the hook.
Same price, not cancelling409 ("already subscribed to this price").
Higher plan rankupgrade: immediate, always_invoice proration; a pending schedule is released.
Lower or equal rank (including a monthly to yearly change)downgrade: deferred to the period end through a subscription schedule.
Existing subscription set to cancel at period end, any changeSwitched immediately, which also keeps it from ending.
Subscription has no items, or its billing phase cannot be read409.

A target price outside the configured plans (only possible with no plans configured) counts as an upgrade. The local row is updated by the webhook afterwards; cancellation is done in the billing portal.

Staff membership

Staff manage a user's membership through the admin routes below, authorized by the users:admin:authorize hook of Users (view for reads, update for changes). A free grant is a manual row (source: 'manual', id manual_<uuid>, no Stripe subscription) that counts as active until revoked or its optional endsAt passes.

// GET /api/admin/users/:id/subscription
type AdminMembershipResponse = {
  plans: Array<{ key: string, label: string, rank: number }>
  current: {
    id: string
    planKey: string | null
    status: string
    source: 'stripe' | 'manual'
    currentPeriodEnd: string | null
    cancelAtPeriodEnd: boolean
  } | null
}

// POST /api/admin/users/:id/subscription        body { planKey, endsAt? }   400 unknown plan or past date, 409 paid subscription exists
// DELETE /api/admin/users/:id/subscription      404 when there is no free membership
// POST .../subscription/payment-link            body { planKey?, priceId?, email? }
type AdminPaymentLinkResponse = { url: string, emailed: boolean }

A payment link is a Stripe Checkout session valid for 24 hours; email: true also mails it through Notify (rendered from the subscriptions-payment-link template when Email Templates is installed). The plan filter hook is not applied for staff. Granting replaces an existing manual grant.

App

Auto-imported, and also available from @frigear-nu/subscriptions/composables.

usePlans(): { plans, status, error, refresh }            // plans sorted by rank
useSubscription(): {
  subscription, plan, status, isActive, cancelsAt,
  has(planKey: string): boolean,
  pending, error, refresh,
  subscribe(planKeyOrPriceId: string, options?: { successUrl?: string, cancelUrl?: string }): Promise<CheckoutResponse>,
  openPortal(returnUrl?: string): Promise<void>,
}

subscribe sends strings starting with price_ as priceId, anything else as planKey; it redirects to Stripe Checkout, or refreshes when an existing subscription was changed. openPortal redirects to the billing portal and defaults returnUrl to the current page.

Middleware

definePageMeta({
  middleware: 'subscription',
  subscription: { plans: ['member'], redirectTo: '/join' }, // both optional
})

Redirects to redirectTo, else to the membership page, with the original route in ?redirect=; without either target it does nothing. Anonymous users and users without an active plan are redirected, and a subscription on a price outside every configured plan has no activePlan and is not let through. This guards navigation only; enforce access in API routes with hasActiveSubscription.

Components

SubscriptionPlans and SubscriptionStatus are built on @nuxt/ui and expose scoped slots.

SubscriptionPlans propTypeDefaultPurpose
localestring-Formatting locale.
currencystringprice'sCurrency override for formatting.
interval'day' | 'week' | 'month' | 'year'first priceWhich price to show per plan.
plansstring[]allOnly show these plan keys.
confirmChangebooleanfalseAsk for confirmation before changing an existing subscription.
mode'auto' | 'list' | 'tabs''auto'auto = tabs on small screens, side-by-side cards from sm up.
highlightstringcurrent planPlan key to emphasise.

Emits select(plan, price). Each plan has a state: 'current' | 'upgrade' | 'downgrade' | 'subscribe'. Visitors who are not signed in are sent to the Users register page with ?redirect=<membership page>?plan=<key>.

ComponentSlotSlot props
SubscriptionPlansloading, empty-
header, badge{ plan, state }
price{ plan, price, formatPrice, intervalLabel }
features{ plan }
action{ plan, state, pending, select }
confirm{ plan, price }
confirm-actions{ plan, price, confirm }
SubscriptionStatusheader, loading, empty- (prop locale)
plan{ plan, label, subscription }
badge{ subscription, badge }
dates{ subscription, cancelsAt, dateText }
actions{ manage, resubscribe, cancelling, pending }

UsersAdminMembership (prop user, emits refresh) is the staff grant and revoke panel used by the admin user tab.

Pages and menu

Each enabled page in settings.pages is added at its path (skipped if the path already exists): the membership page (a SectionCard with the status when subscribed, then the plans with confirmation), a success page (polls me up to five times, two seconds apart, while the webhook lands) and a cancel page. The pages sit under /account/**, so they render no container or page heading of their own (the account layout provides both); success and cancel are empty states with one action, Back to membership. With Menu and the membership page enabled, a "Subscription" item (order 15) is added to the account menu (the account tile and left navigation). With Widget installed, a membership summary is added to the account.overview widget area. With Widget and Admin installed, the admin user page gains a Subscription tab (order 10, built from SectionCard, DescriptionList and a history table) and a Current subscription card (admin.users tab area, admin.users.overview widget area). Revoking a free membership asks for confirmation first.

Hooks

Register with nitro.hooks.hook(...) in a Nitro plugin. The module contains no seasonal or pricing rules.

HookWhenPayload
subscriptions:checkout:beforeAfter validation and the kind decision, for new checkouts and plan changes. Throw to deny. Not run for resume.CheckoutBeforeContext
subscriptions:plans:filterWhenever plans are listed or checked (also gates checkout). Mutate or reassign ctx.plans.PlansFilterContext
type CheckoutBeforeContext = {
  event: H3Event
  userId: number
  planKey: string | null
  priceId: string
  kind: 'checkout' | 'upgrade' | 'downgrade'
  params: Stripe.Checkout.SessionCreateParams   // only used when kind === 'checkout'
}

type PlansFilterContext = {
  event: H3Event | undefined   // undefined outside a request
  plans: ResolvedPlan[]        // a copy of the cached plans
}

type ResolvedPlan = { key: string, label: string, description?: string, rank: number, features?: string[], prices: PlanPrice[] }
type PlanPrice = {
  id: string
  amount: number | null           // minor units
  currency: string
  interval: 'day' | 'week' | 'month' | 'year'
  intervalCount: number
  trialPeriodDays: number | null
  lookupKey: string | null
}

The default params are mode: 'subscription', the line item, success_url, cancel_url, client_reference_id (the user id) and subscription_data.metadata (userId, planKey); the Stripe customer is added afterwards.

export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('subscriptions:checkout:before', (ctx) => {
    if (!isSignupSeason(new Date())) throw createError({ statusCode: 403, statusMessage: 'Sign-up is closed' })
    if (ctx.kind === 'checkout') ctx.params.metadata = { ...ctx.params.metadata, source: 'website' }
  })
})

Routes

MethodPathAuthPurpose
GET/api/subscriptions/planspublic{ plans: ResolvedPlan[] }, after the plans:filter hook.
GET/api/subscriptions/merequireUser, scope subscriptions:readMeResponse: all subscriptions plus activePlan.
POST/api/subscriptions/checkoutrequireUser, scope subscriptions:writeNew checkout or plan change, see Checkout.
POST/api/subscriptions/portalsession onlyBody { returnUrl? } (same origin, else 400). Returns { url } of a billing portal session.
GET/api/admin/users/:id/subscriptionusers:admin:authorize (view)Current membership and plans.
GET/api/admin/users/:id/subscriptionsusers:admin:authorize (view)Full history including ended rows, as MeResponse.
POST/api/admin/users/:id/subscriptionusers:admin:authorize (update)Grant a free membership.
DELETE/api/admin/users/:id/subscriptionusers:admin:authorize (update)Revoke a free membership.
POST/api/admin/users/:id/subscription/payment-linkusers:admin:authorize (update)Create (and optionally email) a checkout link.

The scoped user routes also accept bearer and OAuth tokens that carry the scope. The scopes are registered with Auth as subscriptions:read ("Read your subscription and plans") and subscriptions:write ("Start a subscription checkout").

type SubscriptionDto = {
  id: string
  planKey: string | null
  priceId: string
  status: string                      // raw Stripe status
  cancelAtPeriodEnd: boolean
  currentPeriodStart: string | null   // ISO
  currentPeriodEnd: string | null
  endedAt: string | null
  source?: 'stripe' | 'manual'        // 'manual' for free grants made by staff
}

type MeResponse = {
  subscriptions: SubscriptionDto[]
  activePlan: string | null           // plan of the entitled subscription with the latest period end
}

Stripe dashboard setup

  1. API keys and webhook: configure Stripe and subscribe the endpoint to customer.subscription.created, .updated, .deleted, .paused and .resumed.
  2. Products and prices: create recurring prices. Use lookup keys to keep config environment-independent (test and live have different price ids). No metadata or copy is needed in Stripe.
  3. Billing portal (Settings > Billing > Customer portal): enable cancellation and payment method updates. Enable subscription updates only with allowChangeInPortal: true, and list the prices the portal may switch between.

Database

One subscriptions table.

ColumnTypeNotes
idtext, primary keyStripe subscription id (sub_...), or manual_<uuid> for free grants.
userIdintegerIndexed; no foreign key. The webhook resolves it through users.stripeCustomerId.
planKeytext, nullablenull when the price is in no configured plan, so config changes never lose data.
priceIdtextEmpty for manual grants.
statustextRaw Stripe status.
cancelAtPeriodEndboolean
currentPeriodStart, currentPeriodEnd, endedAttimestamps, nullablecurrentPeriodEnd on a manual grant is its optional end date.
sourcetextstripe (default) or manual.
rawItemsJSON, nullableItem id, price id, lookup key, quantity.
createdAt, updatedAttimestamps

Rows are never deleted; ended subscriptions get endedAt.

The module ships no migration: generate and commit one in every host app and fixture, covering this table and the users.stripe_customer_id column from Stripe. See Module Kit.

Translations

da and en locale files are registered with Module Kit; components read them through useModuleT('subscriptions.<group>'). Override any key in your app's i18n/locales/<code>.json (see overriding module strings). Parameters use {name}.

GroupKeys
plans.*loading, empty, current, subscribe, upgrade, downgrade, trial ({days}), confirmTitle, confirmBody ({plan}), confirm, cancel, select, resubscribe and the checkout, upgrade and downgrade confirmation texts.
interval.*day, week, month, year, their plurals, and quarter.
status.*loading, title, none, renews ({date}), cancels ({date}), manage, resubscribe, active, trialing, pastDue, canceled, other, plus the status card texts.
success.*, cancel.*Page texts: title, body, back ("Back to membership").
errors.*checkout, portal.
menu.*, admin.*, overview.*Account menu item, the admin tab, card and membership panel, and the account overview summary.
catalog.<planKey>.*label, description and features (array) per plan; host-defined and falling back to the plan config.
Copyright © 2026