Skip to main content

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 is a pure License Manager proxy. 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.

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.

Cache sync contract

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.

Get the active subscriptionGET /api/usage/subscription
Proxies LM `GET platform/subscription`. The body is NESTED under `subscription` (`{ subscription: {...} }`, or `{ subscription: null }` when there is no active subscription) — NOT a flat object. The sub carries `pendingCancel` (boolean), `billingCycle`, `creditPackId`, `creditPackQuantity`, `startedAt`, `renewsAt`, `graceEndsAt`, and an embedded `pack`. There is no `term`, `creditsIncluded`, `cardLast4`, or top-level `cancelAtPeriodEnd`. Requires permission.billing.manage.
Response · 200
{
"_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
}
}
Captured from the API examples test suite; ids and timestamps are normalized.

Subscription properties:

FieldTypeDescription
idstringSubscription id
statusstringSubscription status (e.g. active)
billingCyclestringBilling cycle (e.g. monthly)
startedAtdateWhen the subscription began
renewsAtdateNext renewal date
graceEndsAtdate | nullEnd of the grace period when past due
creditPackIdstring (UUID)Id of the subscribed credit pack
creditPackQuantityintegerNumber of packs on the subscription
pendingCancelbooleantrue when cancellation is scheduled at term end
packobjectEmbedded credit pack (name, slug, credits, price, type)
pendingPackobject | nullPending 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.

Cancel the subscriptionPOST /api/usage/subscription/_cancel
Proxies LM `POST platform/subscription/_cancel`, then refreshes the local AiEntitlementCache and appends `cacheUpdated`. The pending-cancel signal is `subscription.pendingCancel: true` (NOT a top-level `cancelAtPeriodEnd`). Credits remain spendable until term end (`balance` still reflects the live pool). `cacheUpdated: false` means LM succeeded but the local cache is momentarily stale — the client should refresh rather than show unconditional success.
Response · 200
{
"_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
}
Captured from the API examples test suite; ids and timestamps are normalized.

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.

Reactivate the subscriptionPOST /api/usage/subscription/_reactivate
Proxies LM `POST platform/subscription/_reactivate` to undo a pending cancellation, then refreshes the cache and appends `cacheUpdated`. The response sets `subscription.pendingCancel: false`.
Response · 200
{
"_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
}
Captured from the API examples test suite; ids and timestamps are normalized.

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:

ParameterTypeRequiredDescription
packSlugstringYesSlug of the target credit pack
packSlug is required

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.

Preview a plan-change prorationGET /api/usage/subscription/proration-preview?packSlug=pro-annual
Read-only proxy to LM `GET platform/subscription/proration-preview?packSlug=...`. No cache refresh, no `cacheUpdated`. `packSlug` is a REQUIRED query param (400 if omitted). Both this preview and the eventual `_purchase` route through the same LM calculation, so the figures match what the customer is billed. The LM body carries dollar math (daysRemaining, dailyRate, prorationAmount, annualDifference), credit math (creditDifference, proratedCredits), and direction/state flags — well beyond the three fields the prose showed.
Response · 200
{
"_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"
}
Captured from the API examples test suite; ids and timestamps are normalized.
FieldTypeDescription
daysRemainingintegerDays left in the current term
dailyRatenumberDaily rate used for the proration
prorationAmountnumberDollar amount charged for the change
annualDifferencenumberAnnual price difference between packs
creditDifferenceintegerAnnual credit difference between packs
proratedCreditsintegerCredits granted for the remainder of the term
isUpgrade / isDowngradebooleanDirection of the change
pastDuebooleanWhether the subscription is past due
withinGracebooleanWhether the subscription is within its grace period
hasActiveSubscriptionbooleanWhether an active subscription exists
currentPackSlug / targetPackSlugstringCurrent and target pack slugs
renewsAtdateNext 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.

List credit packsGET /api/usage/credit-packs
Proxies LM `GET platform/credit-packs`. The response is a TOP-LEVEL ARRAY of pack objects, NOT a `{ creditPacks: [...] }` wrapper (so it has no `_links` envelope). Each pack: { id, slug, name, credits, type (`subscription` | `one-off`), productId, purchasable, pricePerCredit, price }. `credits` and `price` are ANNUAL per unit; per-month consumers divide by 12.
Response · 200
[
{
"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"
]
Captured from the API examples test suite; ids and timestamps are normalized.

Credit pack properties:

FieldTypeDescription
idstring (UUID)Pack id
slugstringURL-safe pack slug
namestringDisplay name
creditsintegerAnnual credits per unit
typestringsubscription or one-off
productIdstringExternal billing product id
purchasablebooleanWhether the pack can be bought
pricePerCreditnumberPrice per credit
pricenumberAnnual 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:

FieldTypeRequiredDescription
packSlugstringYesCredit pack slug
cardIdstringYesPayment card token
quantityintegerNoNumber of packs (min 1, default 1)
idempotencyKeystringNoForwarded 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 }.

Purchase a credit packPOST /api/usage/_purchase
Proxies LM `POST platform/_purchase`, then writes the authoritative `balance` straight into AiEntitlementCache and appends `cacheUpdated`. Payload is { packSlug (required), cardId (required), quantity? (integer, min 1, default 1), idempotencyKey? }; the idempotency key is forwarded to LM so a double-submit cannot charge the card twice. The `balance` object is { included, used, remaining, slush, dollarBalance } — richer than the prose showed.
Request body
{
"packSlug": "starter-annual",
"cardId": "qb-card-1",
"quantity": 1
}
Response · 200
{
"_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
}
Captured from the API examples test suite; ids and timestamps are normalized.

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 }.

Convert legacy dollar creditsPOST /api/usage/_convert-dollars
Proxies LM `POST platform/_convert-dollars` to promote pre-3.0.0 dollar-unit credits into the spendable pool (one-way migration). Takes NO payload — LM identifies the installation by its JWT. The route refreshes the cache, patches `legacyDollars: 0` so the "Convert" prompt disappears immediately, and appends `cacheUpdated`. Response: { promoted, dollarsPromoted, creditsPromoted, balance, cacheUpdated }.
Response · 200
{
"_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
}
Captured from the API examples test suite; ids and timestamps are normalized.

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.

Get billing configGET /api/usage/billing-config
Proxies LM `GET platform/billing-config`. The auto-replenish amount is `paygDollars` (dollars per refill), with `packSlug` kept nullable for back-compat. GET additionally returns `monthlySpent` and `monthlyRemaining` (the running auto-refill count this month), which the .md omits. alerts.thresholds keys are `80`/`90`/`95`/`exhausted`.
Response · 200
{
"_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
}
}
}
Captured from the API examples test suite; ids and timestamps are normalized.

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:

FieldTypeDescription
replenish.enabledbooleanEnable pay-as-you-go auto-replenish
replenish.paygDollarsinteger | nullDollars to spend each time auto-refill fires (20-10000)
replenish.packSlugstring | nullPack slug, kept nullable for back-compat
replenish.cardIdstring | nullPayment card token
replenish.monthlyCapintegerMax auto-refills per month (1-10)
alerts.recipientsstring[]Email recipients for usage alerts
alerts.thresholdsobjectToggle 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).

Update billing configPUT /api/usage/billing-config
Proxies LM `PUT platform/billing-config`. Payload validates a nested `replenish` ({ enabled?, paygDollars? (integer 20–10000 | null), packSlug? (string | null, back-compat), cardId? (string | null), monthlyCap? (integer 1–10) }) and `alerts` ({ recipients? (string[] of emails), thresholds? toggling `80`/`90`/`95`/`exhausted` }). Returns the saved config (same shape as GET). Invalid emails or an out-of-range monthlyCap yield 400.
Request body
{
"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 · 200
{
"_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
}
}
}
Captured from the API examples test suite; ids and timestamps are normalized.
Validation

Invalid email recipients or an out-of-range monthlyCap answer 400.


Subscription lifecycle

  1. Purchase buy a credit pack; credits are valid for the annual term
  2. Active credits draw down from the pool as the platform is used (see Usage & Credits)
  3. Auto-replenish (optional) pay-as-you-go top-ups fire when the pool runs low, subject to the monthly cap
  4. Cancellation scheduled at period end via pendingCancel; credits remain spendable until the term expires
  5. Reactivation undo a pending cancellation before the term ends
  • Usage & Credits usage profiles, entity budgets, and consumption reporting
  • Teams assign usage profiles to teams