Administration

Managing plans

Create per-application subscription plans with feature flags and optional Stripe billing.

Each application can have one or more subscription plans. A plan carries the features it includes as plain values (features), injected into the ID token as the features claim (together with a plan claim holding the plan name) when the features scope is requested. A user whose subscription has expired gets features: {} and no plan claim. What a plan charges, and the features billed by usage, are its prices.

All endpoints require an admin or superadmin session.

Plans

When the application has LiteLLM support, each plan also maps to a gateway team (<applicationSlug>:<planSlug>) carrying an empty access group: creating a plan creates the team, renaming it replaces the team, and deleting it blocks the team. Independently of LiteLLM, a plan whose name slugifies to the same slug as another plan of the application (for example Pro and pro!) is refused with 400 APP_001. See Subscription plans.

List plans

GET /api/admin/applications/:appId/plans

Returns { "plans": [...] }, each plan with its prices array.

Create a plan

POST /api/admin/applications/:appId/plans
Content-Type: application/json

{
  "name": "Pro",
  "description": "Full access",
  "features": {
    "exportEnabled": true,
    "seats": 10,
    "tier": "gold"
  },
  "isDefault": false
}
FieldTypeDefaultDescription
namestringrequiredPlan name (1–64 chars, unique per application, and not slug-equal to another plan)
descriptionstring—Optional description (max 500 chars)
featuresobject{}Included features: each value is a string, a number or a boolean
isDefaultbooleanfalseAuto-assign to new users granted access to the app

Returns 201 { "plan": { ..., "prices": [] } }. The Stripe Product of the plan is created by auth-service; its id is never supplied by the caller. A request that carries stripeProductId, or a feature declared as an object (a metered feature belongs in the prices, see below), is refused with 400 APP_001: unknown fields are refused, not ignored. Only one plan per application is the default: marking a plan as default demotes the previous one.

Update a plan

PATCH /api/admin/applications/:appId/plans/:planId
Content-Type: application/json

{ "features": { "maxProjects": 100 }, "isDefault": false }

Accepts the create fields, with the same validation (name, description, features, isDefault). Every field is optional and an omitted one is left unchanged; unknown fields are refused. features replaces the whole object; it is not merged. Renaming a plan also renames its Stripe Product.

Delete a plan

DELETE /api/admin/applications/:appId/plans/:planId

Returns 204. Returns 400 SUB_003 if any users currently hold an active subscription to this plan. Inactive subscriptions to the plan are removed with it, and its Stripe Product and Prices are archived, not deleted.

Plan prices (Stripe billing)

A plan has two kinds of price rows, both mapped to Stripe Price objects that auth-service creates and archives:

  • a fixed price: what the plan charges (monthly, yearly or once);
  • a metered price: the price of one unit of a feature, billed by usage, with a cap per billing period.

In the administration interface, Prices opens both kinds for a plan. A metered feature is never declared in the plan's features: the price is the only declaration, so the cap and the bill cannot disagree.

Add a fixed price

POST /api/admin/applications/:appId/plans/:planId/prices
Content-Type: application/json

{
  "name": "Monthly",
  "amount": 999,
  "currency": "usd",
  "interval": "month"
}
FieldTypeDefaultDescription
namestringrequiredDisplay name (1–64 chars)
amountintegerrequiredAmount in the smallest currency unit (e.g. cents), 0 or more
currencystring"usd"3-letter currency code
interval"month" | "year" | "one_time""month"Billing cycle

Returns 201 { "price": { ... } }. featureKey, unit and usageLimit are refused on a fixed price. A plan counts as paid when it has a recurring (month or year) fixed price above zero.

Add a metered price

POST /api/admin/applications/:appId/plans/:planId/prices
Content-Type: application/json

{
  "name": "API calls",
  "amount": 2,
  "currency": "eur",
  "billingType": "metered",
  "featureKey": "api.calls",
  "unit": "call",
  "usageLimit": 10000
}
FieldTypeDescription
billingType"metered"Selects a metered price
featureKeystringRequired. The consumption key the application reports (letters, digits and dots, 64 chars at most). One metered price per key and plan (400 BIL_002 otherwise)
amountintegerThe price of one unit, in the smallest currency unit
unitstringRequired. Label of a unit, for display and invoices (1–32 chars)
usageLimitintegerRequired. The cap of the billing period, in units. -1 means unlimited. Usage past the cap is refused (403 BIL_003) and not billed
interval"month" | "year"Defaults to "month". "one_time" is refused on a metered price

Usage is reported with POST /api/consumption (see the API reference). See Consumption tracking and the Stripe integration guide for how usage reaches the Stripe invoice.

Update a price

PATCH /api/admin/applications/:appId/plans/:planId/prices/:priceId
Content-Type: application/json

{ "name": "API calls (EU)", "unit": "request", "usageLimit": 20000 }

The name can always change (it is also set as the Stripe Price nickname). On a metered price the unit and the cap can change as well; unit and usageLimit on a fixed price are refused with 400 BIL_002. The amount, currency, interval and feature key are fixed once the Stripe Price exists: changing them means deleting the price and creating another. Any other field, Stripe ids included, is refused (400 APP_001). The response is { "price": { ... } }.

Delete a price tier

DELETE /api/admin/applications/:appId/plans/:planId/prices/:priceId

Returns 204. Deleting a price archives its Stripe Price. For a metered price, the Stripe meter is deactivated when no other price uses it.

Assign or change a subscription

A user's subscription is part of their application access record. Set or change it with a PATCH on that record — user_subscriptions is the source of truth read by the token features claim, and the access record's subscriptionPlanId is kept in sync automatically. A manual plan change must carry a reason, which is written to the subscription journal (see below).

PATCH /api/admin/applications/:id/users/:userId
Content-Type: application/json

{
  "subscriptionPlanId": "<plan-uuid>",
  "reason": "Customer upgraded after a sales call"
}

Expiry. The optional expiresAt (ISO-8601 date-time) sets or clears the subscription expiry; pass null to clear it. When omitted the current expiry is left untouched. An expiresAt-only payload (no subscriptionPlanId) keeps the current plan and still requires a reason. A payload with neither field needs no reason.

Dedicated endpoints also exist: POST /api/admin/applications/:appId/users/:userId/subscription with { "planId": "<uuid>", "expiresAt": "<date-time>" | null, "reason": "..." } assigns a plan to a user who has no active subscription (409 SUB_002 otherwise; reason is optional there), and DELETE on the same path removes the subscription (404 SUB_001 when the user has none).

Revoke a subscription

Set subscriptionPlanId to null on the same access record. A reason is still required because the removal is audited:

PATCH /api/admin/applications/:id/users/:userId
Content-Type: application/json

{
  "subscriptionPlanId": null,
  "reason": "Subscription cancelled"
}

Plan at grant time

POST /api/admin/applications/:id/users accepts an optional subscriptionPlanId. When provided that plan is assigned; otherwise the application's default plan is used. The bulk endpoint (POST /api/admin/applications/:id/users/bulk) accepts the same subscriptionPlanId (and optional reason) and applies it to every granted user. An unknown or foreign plan is rejected before anything is written.

Subscription journal

Every effective plan change writes an entry to user_subscription_events (source, action, from_plan_id, to_plan_id, the acting actor_user_id, and the reason) and, except for the automatic default-plan assignment (source default), publishes a subscription.changed event on the live-event channel so open admin views refresh. The source is one of admin, self, default or stripe, and action is assigned, changed or removed. A change to the expiry, or a reactivation of a cancelled subscription, is recorded even when the plan id is unchanged; a call that leaves the subscription exactly as it was is a no-op and is not recorded. subscription.changed frames are coalesced (at most one per second), so a bulk grant produces a single refresh.

Self-service plan changes

On the profile Applications tab a user can change their own plan, and only when Stripe is configured (without STRIPE_SECRET_KEY the selector is hidden and the endpoint refuses with 403 SUB_005; without a payment provider, only administrators change a user's plan). The selector lists every plan with its price:

  • a free plan applies at once, and its Stripe subscription follows;
  • a paid plan is never granted before the payment: a user with a live paid subscription has it changed in place (Stripe prorates it), any other user is sent to Stripe Checkout, and the plan is granted when the checkout completes. A checkout never creates a second paid subscription (409 SUB_007).

Administrators can also stop a user's Stripe subscription at the end of its period, and resume it, from the user's page (see the Stripe integration guide).

Default plan auto-assignment

When you mark a plan as isDefault: true, it is assigned to a user who is granted access to the application without an explicit plan (via POST /api/admin/applications/:id/users, the bulk endpoint, or when a user's access record is re-activated), provided the user holds no subscription for that application yet. Only one default plan per application is supported; marking another plan as default does not change existing subscribers — it only affects future grants.

Copyright © 2026