Subscriptions
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?".
@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 } },
},
})
- Set the Stripe environment variables (test keys in development).
- Complete the Stripe dashboard setup.
- Generate and commit your app's migration (see Database); skipping it is the most common cause of failing webhooks and checkouts.
- Verify: sign in, open
/account/membershipand subscribe with Stripe's test card4242 4242 4242 4242. After the redirect,GET /api/subscriptions/mereturns the subscription once the webhook has landed.
Options
| Option | Type | Default | Purpose |
|---|---|---|---|
plans | Record<string, PlanConfig> | {} | Plans keyed by plan key. Empty means "any active subscription" (see below). |
plans.<key>.prices | Array<string | { lookupKey }> | - | Required. Stripe price ids and/or lookup refs; several per plan (monthly, yearly). |
plans.<key>.rank | number | - | Required. Higher is a higher tier; decides upgrade vs downgrade. Amounts are never compared. |
plans.<key>.label | string | the key | Display label; i18n subscriptions.catalog.<key>.label wins when defined. |
plans.<key>.description | string | - | Optional; i18n catalog.<key>.description wins. |
plans.<key>.features | string[] | - | 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.membership | string | boolean | off | true = /account/membership, a string = custom path. |
settings.pages.success | string | boolean | off | true = /account/membership/success. |
settings.pages.cancel | string | boolean | off | true = /account/membership/cancel. |
settings.allowChangeInPortal | boolean | false | Hint exposed as public.subscriptions.allowChangeInPortal. Not enforced; mirror your billing portal. |
settings.mcp | boolean | false | Expose 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:
| Export | Description |
|---|---|
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.
planKeycomes from the first subscription item's price (price id or lookup key), elsenull.- Period start and end are derived from the items;
deletedstores statuscanceledand setsendedAt.
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.
| Situation | Result |
|---|---|
| No existing subscription | kind: 'checkout': returns { url }. |
| Same price, set to cancel at period end | resume: releases a pending schedule and clears the cancellation. Skips the hook. |
| Same price, not cancelling | 409 ("already subscribed to this price"). |
| Higher plan rank | upgrade: 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 change | Switched immediately, which also keeps it from ending. |
| Subscription has no items, or its billing phase cannot be read | 409. |
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 prop | Type | Default | Purpose |
|---|---|---|---|
locale | string | - | Formatting locale. |
currency | string | price's | Currency override for formatting. |
interval | 'day' | 'week' | 'month' | 'year' | first price | Which price to show per plan. |
plans | string[] | all | Only show these plan keys. |
confirmChange | boolean | false | Ask for confirmation before changing an existing subscription. |
mode | 'auto' | 'list' | 'tabs' | 'auto' | auto = tabs on small screens, side-by-side cards from sm up. |
highlight | string | current plan | Plan 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>.
| Component | Slot | Slot props |
|---|---|---|
SubscriptionPlans | loading, 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 } | |
SubscriptionStatus | header, 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.
| Hook | When | Payload |
|---|---|---|
subscriptions:checkout:before | After validation and the kind decision, for new checkouts and plan changes. Throw to deny. Not run for resume. | CheckoutBeforeContext |
subscriptions:plans:filter | Whenever 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
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /api/subscriptions/plans | public | { plans: ResolvedPlan[] }, after the plans:filter hook. |
| GET | /api/subscriptions/me | requireUser, scope subscriptions:read | MeResponse: all subscriptions plus activePlan. |
| POST | /api/subscriptions/checkout | requireUser, scope subscriptions:write | New checkout or plan change, see Checkout. |
| POST | /api/subscriptions/portal | session only | Body { returnUrl? } (same origin, else 400). Returns { url } of a billing portal session. |
| GET | /api/admin/users/:id/subscription | users:admin:authorize (view) | Current membership and plans. |
| GET | /api/admin/users/:id/subscriptions | users:admin:authorize (view) | Full history including ended rows, as MeResponse. |
| POST | /api/admin/users/:id/subscription | users:admin:authorize (update) | Grant a free membership. |
| DELETE | /api/admin/users/:id/subscription | users:admin:authorize (update) | Revoke a free membership. |
| POST | /api/admin/users/:id/subscription/payment-link | users: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
- API keys and webhook: configure Stripe and subscribe the endpoint to
customer.subscription.created,.updated,.deleted,.pausedand.resumed. - 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.
- 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.
| Column | Type | Notes |
|---|---|---|
id | text, primary key | Stripe subscription id (sub_...), or manual_<uuid> for free grants. |
userId | integer | Indexed; no foreign key. The webhook resolves it through users.stripeCustomerId. |
planKey | text, nullable | null when the price is in no configured plan, so config changes never lose data. |
priceId | text | Empty for manual grants. |
status | text | Raw Stripe status. |
cancelAtPeriodEnd | boolean | |
currentPeriodStart, currentPeriodEnd, endedAt | timestamps, nullable | currentPeriodEnd on a manual grant is its optional end date. |
source | text | stripe (default) or manual. |
rawItems | JSON, nullable | Item id, price id, lookup key, quantity. |
createdAt, updatedAt | timestamps |
Rows are never deleted; ended subscriptions get endedAt.
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}.
| Group | Keys |
|---|---|
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. |