Subscriptions API
The Subscriptions API manages a Tenant's annual credit subscription. Customers
purchase credits that are valid for a full year and draw down from a shared
credit pool as the platform is used. Subscriptions, credit
packs, and billing settings live in the License Manager; Informer proxies those
operations and keeps a local entitlement cache in sync. All routes are prefixed
with /api.
This API covers buying and managing credits. For how credits are governed (usage profiles, model tiers, per-actor caps) and reported, see Usage & Credits.
Every route here proxies the License Manager. The handler calls the License
Manager and returns its response.data; the money-touching routes refresh the
local entitlement cache afterward and append a cacheUpdated boolean. The one
exception to "pure proxy" is GET /api/usage/subscription, which degrades to a
locally cached read when the License Manager is unavailable (see below).
Authentication
All endpoints require permission.billing.manage (the route delegates to the
billingManage global permission). The billing gate is not AI-gated: the AI
module does not need to be enabled on the Tenant to manage a subscription.
The money-touching endpoints (_cancel, _reactivate, _purchase,
_convert-dollars) refresh the local AiEntitlementCache after the License
Manager call and return a cacheUpdated boolean. cacheUpdated: false means
the License Manager operation succeeded but the local cache is momentarily
stale, so the client should trigger its own refresh rather than show an
unconditional success.
GET /api/usage/subscription
Get the active subscription for the current license. Proxies the License Manager
GET platform/subscription.
Authentication: permission.billing.manage
Response:
The License Manager body is nested under subscription (the payload is
{ subscription: ... }, or { subscription: null } when there is no active
subscription), not a flat subscription object.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/subscription"
}
},
"subscription": {
"id": "sub-1",
"status": "active",
"billingCycle": "monthly",
"startedAt": "2026-01-15T16:20:00.000Z",
"renewsAt": "2026-01-15T16:20:00.000Z",
"graceEndsAt": null,
"creditPackId": "00000000-0000-4000-8000-000000000001",
"creditPackQuantity": 1,
"pendingCancel": false,
"pack": {
"name": "Starter",
"slug": "starter-annual",
"credits": 2400,
"price": 300,
"type": "subscription"
},
"pendingPack": null
}
}
When the License Manager is unreachable or answers 503 (error data.code of
lm_unreachable or lm_unavailable), this endpoint still responds 200,
serving the last-known subscription cached locally on every platform/_sync:
{ "subscription": ... }, or { "subscription": null } when nothing has been
cached yet. The body alone cannot distinguish "no subscription" from "License
Manager down and nothing cached". Any other License Manager error propagates
unchanged, and the mutating routes (_cancel, _reactivate, _purchase,
_convert-dollars) have no fallback and fail loudly.
Subscription properties:
| Field | Type | Description |
|---|---|---|
id | string | Subscription id |
status | string | Subscription status (e.g. active) |
billingCycle | string | Billing cycle (e.g. monthly) |
startedAt | date | When the subscription began |
renewsAt | date | Next renewal date |
graceEndsAt | date | null | End of the grace period when past due |
creditPackId | string (UUID) | Id of the subscribed credit pack |
creditPackQuantity | integer | Number of packs on the subscription |
pendingCancel | boolean | true when cancellation is scheduled at term end |
pack | object | Embedded credit pack (name, slug, credits, price, type) |
pendingPack | object | null | Pending pack change, when one is queued |
There is no term, creditsIncluded, cardLast4, or top-level
cancelAtPeriodEnd field. The scheduled-cancellation signal is
subscription.pendingCancel.
POST /api/usage/subscription/_cancel
Schedule cancellation of the subscription at the next renewal date. Credits
remain spendable until the end of the current annual term. Proxies the License
Manager POST platform/subscription/_cancel, then refreshes the local
AiEntitlementCache.
Authentication: permission.billing.manage
Payload: empty
Response:
Responds 200 with { cancelled, subscription, balance, cacheUpdated }. The
pending-cancel flag is subscription.pendingCancel: true (not a top-level
cancelAtPeriodEnd). The balance still reflects the live pool because credits
remain spendable until the term ends.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/subscription/_cancel"
}
},
"cancelled": true,
"subscription": {
"id": "sub-1",
"status": "active",
"renewsAt": "2026-01-15T16:20:00.000Z",
"pendingCancel": true
},
"balance": {
"included": 5000,
"used": 1250,
"remaining": 3750,
"slush": 250,
"dollarBalance": 50
},
"cacheUpdated": true
}
POST /api/usage/subscription/_reactivate
Undo a pending cancellation. Proxies the License Manager POST platform/subscription/_reactivate, then refreshes the cache.
Authentication: permission.billing.manage
Payload: empty
Response:
Responds 200 with { reactivated, subscription, cacheUpdated }. The response
sets subscription.pendingCancel: false.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/subscription/_reactivate"
}
},
"reactivated": true,
"subscription": {
"id": "sub-1",
"status": "active",
"renewsAt": "2026-01-15T16:20:00.000Z",
"pendingCancel": false
},
"cacheUpdated": true
}
GET /api/usage/subscription/proration-preview
Preview the prorated charge for changing to a different credit pack mid-term.
Read-only proxy to the License Manager GET platform/subscription/proration-preview. There is no cache refresh and no
cacheUpdated. Both this preview and the eventual purchase route through the
same License Manager calculation, so the figures shown match what the customer
is billed.
Authentication: permission.billing.manage
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
packSlug | string | Yes | Slug of the target credit pack |
packSlug is a required query param. Omitting it answers 400.
Response:
The License Manager body carries dollar math, credit math, and direction/state flags, well beyond a single proration figure.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/subscription/proration-preview"
}
},
"daysRemaining": 364,
"dailyRate": 49.32,
"prorationAmount": 17952.48,
"annualDifference": 18000,
"creditDifference": 200000,
"proratedCredits": 199452,
"isUpgrade": true,
"isDowngrade": false,
"pastDue": false,
"withinGrace": false,
"hasActiveSubscription": true,
"currentPackSlug": "starter-annual",
"targetPackSlug": "pro-annual",
"renewsAt": "2026-01-15"
}
| Field | Type | Description |
|---|---|---|
daysRemaining | integer | Days left in the current term |
dailyRate | number | Daily rate used for the proration |
prorationAmount | number | Dollar amount charged for the change |
annualDifference | number | Annual price difference between packs |
creditDifference | integer | Annual credit difference between packs |
proratedCredits | integer | Credits granted for the remainder of the term |
isUpgrade / isDowngrade | boolean | Direction of the change |
pastDue | boolean | Whether the subscription is past due |
withinGrace | boolean | Whether the subscription is within its grace period |
hasActiveSubscription | boolean | Whether an active subscription exists |
currentPackSlug / targetPackSlug | string | Current and target pack slugs |
renewsAt | date | Next renewal date |
GET /api/usage/credit-packs
List the credit packs available for purchase. Proxies the License Manager GET platform/credit-packs.
Authentication: permission.billing.manage
Response:
The response is a top-level array of pack objects, not a
{ creditPacks: [...] } wrapper, so it carries no _links envelope.
[
{
"id": "00000000-0000-4000-8000-000000000001",
"slug": "starter-annual",
"name": "Starter",
"credits": 2400,
"type": "subscription",
"productId": "qb-item-starter",
"purchasable": true,
"pricePerCredit": 0.125,
"price": 300
},
{
"id": "00000000-0000-4000-8000-000000000002",
"slug": "pro-annual",
"name": "Pro",
"credits": 12000,
"type": "subscription",
"productId": "qb-item-pro",
"purchasable": true,
"pricePerCredit": 0.1,
"price": 1200
},
{
"id": "00000000-0000-4000-8000-000000000003",
"slug": "business-annual",
"name": "Business",
"credits": 60000,
"type": "subscription",
"productId": "qb-item-business",
"purchasable": true,
"pricePerCredit": 0.1,
"price": 6000
},
"… 1 more items (4 total) — omitted from docs"
]
Credit pack properties:
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Pack id |
slug | string | URL-safe pack slug |
name | string | Display name |
credits | integer | Annual credits per unit |
type | string | subscription or one-off |
productId | string | External billing product id |
purchasable | boolean | Whether the pack can be bought |
pricePerCredit | number | Price per credit |
price | number | Annual price per unit |
credits and price are annual per unit. Per-month consumers divide by 12.
POST /api/usage/_purchase
Purchase a credit pack. Proxies the License Manager POST platform/_purchase,
then writes the authoritative balance straight into the entitlement cache so
the credits are immediately visible.
Authentication: permission.billing.manage
Request Body:
| Field | Type | Required | Description |
|---|---|---|---|
packSlug | string | Yes | Credit pack slug |
cardId | string | Yes | Payment card token |
quantity | integer | No | Number of packs (min 1, default 1) |
idempotencyKey | string | No | Forwarded to the License Manager so a double-submit cannot charge the card twice |
Example Request:
{ "packSlug": "starter-annual", "cardId": "qb-card-1", "quantity": 1 }
Response:
Responds 200 with the License Manager purchase body (credit, subscription,
prorationCharge, balance) plus cacheUpdated. The balance object is
{ included, used, remaining, slush, dollarBalance }.
{
"packSlug": "starter-annual",
"cardId": "qb-card-1",
"quantity": 1
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/_purchase"
}
},
"credit": {
"id": "credit-1",
"credits": 5000,
"paymentType": "subscription"
},
"subscription": {
"id": "sub-1",
"billingCycle": "monthly",
"renewsAt": "2026-01-15T16:20:00.000Z"
},
"prorationCharge": null,
"balance": {
"included": 5000,
"used": 0,
"remaining": 5000,
"slush": 250,
"dollarBalance": 50
},
"cacheUpdated": true
}
POST /api/usage/_convert-dollars
Promote legacy dollar-unit credits (from pre-3.0.0 balances) into the spendable
credit pool. One-way migration. Proxies the License Manager POST platform/_convert-dollars, refreshes the cache, and patches legacyDollars: 0
so the "Convert" prompt disappears immediately.
Authentication: permission.billing.manage
Payload: empty. The License Manager identifies the installation by its JWT.
Response:
Responds 200 with
{ promoted, dollarsPromoted, creditsPromoted, balance, cacheUpdated }.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/_convert-dollars"
}
},
"promoted": 3,
"dollarsPromoted": 50,
"creditsPromoted": 1000,
"balance": {
"included": 1000,
"used": 0,
"remaining": 1000,
"slush": 0,
"dollarBalance": 0
},
"cacheUpdated": true
}
GET /api/usage/billing-config
Get the auto-replenish and alert settings. Proxies the License Manager GET platform/billing-config.
Authentication: permission.billing.manage
Response:
The auto-replenish amount is paygDollars (dollars per refill), with packSlug
kept nullable for back-compat. The GET additionally returns monthlySpent and
monthlyRemaining (the running auto-refill count this month). The
alerts.thresholds keys are 80, 90, 95, and exhausted.
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/billing-config"
}
},
"replenish": {
"enabled": true,
"paygDollars": 30,
"packSlug": null,
"cardId": "qb-card-1",
"monthlyCap": 3,
"monthlySpent": 1,
"monthlyRemaining": 2
},
"alerts": {
"recipients": [
"ops@example.com",
"billing@example.com"
],
"thresholds": {
"80": true,
"90": true,
"95": false,
"exhausted": true
}
}
}
PUT /api/usage/billing-config
Update auto-replenish and alert settings. Proxies the License Manager PUT platform/billing-config; the License Manager echoes the saved config.
Authentication: permission.billing.manage
Request Body:
| Field | Type | Description |
|---|---|---|
replenish.enabled | boolean | Enable pay-as-you-go auto-replenish |
replenish.paygDollars | integer | null | Dollars to spend each time auto-refill fires (20-10000) |
replenish.packSlug | string | null | Pack slug, kept nullable for back-compat |
replenish.cardId | string | null | Payment card token |
replenish.monthlyCap | integer | Max auto-refills per month (1-10) |
alerts.recipients | string[] | Email recipients for usage alerts |
alerts.thresholds | object | Toggle alerts at 80, 90, 95 percent, and exhausted |
Example Request:
{
"replenish": { "enabled": true, "paygDollars": 100, "cardId": "qb-card-1", "monthlyCap": 5 },
"alerts": { "recipients": ["admin@example.com"], "thresholds": { "80": true, "90": true, "95": true, "exhausted": true } }
}
Response:
Responds 200 with the saved billing configuration (same shape as the GET).
The saved alert settings are also mirrored into this server's local entitlement
cache, so a recipient added here is used by the very next alert rather than
waiting for the next License Manager sync.
{
"replenish": {
"enabled": true,
"paygDollars": 100,
"cardId": "qb-card-1",
"monthlyCap": 5
},
"alerts": {
"recipients": [
"admin@example.com"
],
"thresholds": {
"80": true,
"90": true,
"95": true,
"exhausted": true
}
}
}
{
"_links": {
"self": {
"href": "https://informer.example.com/api/usage/billing-config"
}
},
"replenish": {
"enabled": true,
"paygDollars": 100,
"packSlug": null,
"cardId": "qb-card-1",
"monthlyCap": 5,
"monthlySpent": 0,
"monthlyRemaining": 5
},
"alerts": {
"recipients": [
"admin@example.com"
],
"thresholds": {
"80": true,
"90": true,
"95": true,
"exhausted": true
}
}
}
Invalid email recipients or an out-of-range monthlyCap answer 400.
POST /api/usage/billing-config/_test-alert
Send a test usage alert to the configured alerts.recipients through the
tenant's email server, so a billing admin can confirm delivery before a real
threshold fires. The message uses the same sender, template, and deep links as
the real alerts. HAL rel: inf:billing-config-test-alert.
Authentication: permission.billing.manage
Request Body: none.
Response:
Responds 200 with { "sent": <number of recipients> }.
400 with "No alert recipients are configured" when the recipient list is
empty, and 400 with "Test email could not be sent: <transport error>" when
the email server rejects the message. The transport reason is deliberately
surfaced rather than masked as a 500, so the admin can act on it.
Subscription lifecycle
- Purchase buy a credit pack; credits are valid for the annual term
- Active credits draw down from the pool as the platform is used (see Usage & Credits)
- Auto-replenish (optional) pay-as-you-go top-ups fire when the pool runs low, subject to the monthly cap
- Cancellation scheduled at period end via
pendingCancel; credits remain spendable until the term expires - Reactivation undo a pending cancellation before the term ends
Related APIs
- Usage & Credits usage profiles, entity budgets, and consumption reporting
- Teams assign usage profiles to teams