Subscriptions

Subscriptions reference

The hooks, HTTP routes and DTOs, the subscriptions table and the translation keys of Subscriptions.

This page lists the hooks, routes, table and translation keys of Subscriptions. It is for app developers, in server mode. Set up the module first: Overview.

Hooks

Your app answers these Nitro hooks from a server plugin (server/plugins/*.ts).

HookWhenPayload
subscriptions:checkout:beforeBefore a new checkout or a plan change, after validation. Throw to deny. Not run for resume.CheckoutBeforeContext
subscriptions:plans:filterWhenever plans are listed or checked, so it also gates checkout. Change ctx.plans.PlansFilterContext
subscriptions:invoiceA subscription invoice was paid, failed or voided. Fired only with Payments installed.{ invoice: InvoicePaymentEvent }
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 hold 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 after the hook.

This plugin closes sign-up out of season:

server/plugins/subscriptions.ts
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

Routes the module registers, and who may call them.

MethodPathAuthStatusPurpose
GET/api/subscriptions/planspublic200{ plans: ResolvedPlan[] }, after the plans:filter hook.
GET/api/subscriptions/mesubscriptions:read200MeResponse: all subscriptions and activePlan.
POST/api/subscriptions/me/checkoutsession only200A new checkout or a plan change. See Checkout.
POST/api/subscriptions/me/portalsession only200{ url } of a billing portal session. Body { returnUrl? }, same origin.
GET/api/admin/users/:id/membershipusers:read200The current membership and the plans staff can grant.
GET/api/admin/users/:id/membership/historyusers:read200The full history, ended rows included, as MeResponse.
POST/api/admin/users/:id/membershipsubscriptions:manage201Grants a free membership. Body { planKey, endsAt? }. Answers a SubscriptionDto.
DELETE/api/admin/users/:id/membershipsubscriptions:manage204Revokes a free membership. 404 not_found when there is none.
POST/api/admin/users/:id/membership/payment-linksubscriptions:manage200Creates a checkout link, and emails it on request: { url, emailed }.
  • Checkout and the portal are billing, so they need a session. An API token or access token gets 403 session_required, whatever its scopes.
  • A token with the subscriptions:read permission can read me. subscriptions:manage is session only, so a token gets 403 session_required on the staff change routes.
  • A returnUrl on another origin answers 400 bad_request.
  • Granting answers 400 for an unknown plan or a past endsAt, and 409 when the user has a paid subscription.
  • Path builders are subscriptionsApi from @frigear-nu/subscriptions/api.
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 memberships granted by staff
}

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

type AdminMembershipResponse = {      // GET /api/admin/users/:id/membership
  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 .../membership/payment-link takes { planKey?, priceId?, email? }
type AdminPaymentLinkResponse = { url: string, emailed: boolean }

Database

Tables the module adds. Generate and commit a migration after installing.

The module adds one subscriptions table. The same migration must also cover the users.stripe_customer_id column of Stripe.

ColumnTypeNotes
idtext, primary keyThe Stripe subscription id (sub_...), or manual_<uuid> for a free membership.
userIdintegerIndexed, no foreign key. The webhook finds it through users.stripeCustomerId.
planKeytext, nullablenull when the price is in no configured plan, so config changes lose no data.
priceIdtextEmpty for a free membership.
statustextThe raw Stripe status.
cancelAtPeriodEndboolean
currentPeriodStart, currentPeriodEnd, endedAttimestamps, nullableOn a free membership, currentPeriodEnd is its optional end date.
sourcetextstripe (default) or manual.
rawItemsJSON, nullableItem id, price id, lookup key and quantity.
createdAt, updatedAttimestamps

Rows are never deleted. An ended subscription gets endedAt.

Translations

Override any string in your app's i18n/locales/<code>.json.

The module registers da and en files with Module Kit. Components read them through useModuleT('subscriptions.<group>'), and parameters use {name}.

GroupKeys
plans.*loading, empty, current, subscribe, upgrade, downgrade, trial ({days}), confirmTitle, confirmBody ({plan}), confirm, cancel, select, resubscribe and the confirmation texts.
interval.*day, week, month, year, their plurals, and quarter.
status.*loading, title, none, renews ({date}), cancels ({date}), manage, resubscribe, the status names, emptyTitle, emptyDescription and the card texts.
success.*, cancel.*The page texts: title, body and back ("Back to membership").
errors.*checkout, portal.
menu.*, admin.*, overview.*The account menu item, the admin tab, card and panel, and the account overview summary.
catalog.<planKey>.*label, description and features (an array) per plan. They fall back to the plan config.

Next steps

Copyright © 2026