Subscriptions server API
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:
| Export | What 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.
planKeycomes from the price of the first subscription item, by price id or lookup key, elsenull.- The period start and end come from the items.
deletedstores the statuscanceledand setsendedAt.
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.
| Situation | Result |
|---|---|
| No existing subscription | checkout: returns { url }. |
| Same price, set to cancel at period end | resume: clears the cancellation and releases a pending schedule. Skips the hook. |
| Same price, not cancelling | 409, "already subscribed to this price". |
| Higher plan rank | upgrade: immediate, with always_invoice proration. A pending schedule is released. |
| Lower or equal rank, including monthly to yearly | downgrade: at the period end, through a subscription schedule. |
| Set to cancel at period end, any other change | Switched immediately, which also keeps it from ending. |
| No items, or a billing phase that cannot be read | 409. |
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 thesubscriptions-payment-linktemplate when Email Templates is installed. - The plan filter hook does not apply to staff.
Next steps
- Subscriptions reference – the hooks, routes and table behind these helpers.
- Subscriptions app – call checkout and the portal from the app.