Managing plans
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
}
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Plan name (1–64 chars, unique per application, and not slug-equal to another plan) |
description | string | — | Optional description (max 500 chars) |
features | object | {} | Included features: each value is a string, a number or a boolean |
isDefault | boolean | false | Auto-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"
}
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Display name (1–64 chars) |
amount | integer | required | Amount in the smallest currency unit (e.g. cents), 0 or more |
currency | string | "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
}
| Field | Type | Description |
|---|---|---|
billingType | "metered" | Selects a metered price |
featureKey | string | Required. 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) |
amount | integer | The price of one unit, in the smallest currency unit |
unit | string | Required. Label of a unit, for display and invoices (1–32 chars) |
usageLimit | integer | Required. 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.