Subscriptions

Subscriptions server API

Check which plan a user holds, and how webhooks, checkout and staff grants change subscriptions.

This page covers the server side: checking a user's plan, what the webhook stores, the checkout rules and how staff manage a membership. It is for app developers and module authors, in server mode. Set up the module first: Overview.

Check a user's plan

Use these helpers to gate a route, a server middleware or another module, such as Events. They are auto-imported in Nitro, and exported 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 counting subscription with the latest currentPeriodEnd. An ended row never counts, and neither does a free membership past its end date.

@frigear-nu/subscriptions/utils also exports:

ExportWhat it does
decideChangeKind(currentRank, targetRank)'upgrade' or 'downgrade' from plan ranks. An unconfigured current plan counts as lowest.
resolvePlanKey(plans, { id, lookupKey? })The configured plan key of a Stripe price, or null.
isWithinDisabledRanges(ranges, now?)Whether a date falls in any disabledRanges window.
DEFAULT_ENTITLED_STATUSES['active', 'trialing'].

Nitro also auto-imports listUserSubscriptions(userId) (the MeResponse), getResolvedPlans() (every plan, cached) and getAvailablePlans(event?) (after availability windows and the subscriptions:plans:filter hook).

Webhook handling

The module keeps each user's subscriptions in step with Stripe. It listens through registerStripeEventHandler to customer.subscription.created, updated, deleted, paused and resumed. Each event updates or inserts one row, by the Stripe subscription id, so a repeat does no harm.

  • The user comes from the Stripe customer id. An event for an unknown customer is logged and ignored, and the webhook still succeeds. Webhooks never create users.
  • planKey comes from the price of the first subscription item, by price id or lookup key, else null.
  • The period start and end come from the items. deleted stores the status canceled and sets endedAt.

Retries follow Stripe.

With Payments installed, invoice events are announced in the subscriptions:invoice hook and stored as subscription invoices.

Checkout

POST /api/subscriptions/me/checkout starts a subscription or changes the plan of an existing one. It needs a session, because it is billing: an API token or access token gets 403 session_required. It takes a 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, so a hidden plan answers 400. For checkout, a user has a subscription when a Stripe row is active, trialing or past_due and not ended. Free grants are ignored here.

SituationResult
No existing subscriptioncheckout: returns { url }.
Same price, set to cancel at period endresume: clears the cancellation and releases a pending schedule. Skips the hook.
Same price, not cancelling409, "already subscribed to this price".
Higher plan rankupgrade: immediate, with always_invoice proration. A pending schedule is released.
Lower or equal rank, including monthly to yearlydowngrade: at the period end, through a subscription schedule.
Set to cancel at period end, any other changeSwitched immediately, which also keeps it from ending.
No items, or a billing phase that cannot be read409.

A price outside the configured plans counts as an upgrade. That is possible only with no plans configured. The webhook updates the local row afterwards. Users cancel in the billing portal.

Staff membership

Staff manage a user's membership through the routes below. Reads need users:read from Users. Changes need subscriptions:manage, which is session only and held by nobody until you grant it to a role. The caller must also hold every permission the target user holds, so a coordinator cannot grant an admin a membership.

A free membership is a manual row: source: 'manual', id manual_<uuid> and no Stripe subscription. It counts as active until it is revoked or its optional endsAt passes.

The routes, their bodies and answers are in Reference.

  • Granting replaces an existing free membership.
  • A payment link is a Stripe Checkout session, valid for 24 hours.
  • With email: true, Notify also mails it. It uses the subscriptions-payment-link template when Email Templates is installed.
  • The plan filter hook does not apply to staff.

Next steps

Copyright © 2026