Licentra — Signed Token & Consuming-App Contract
Token
Each license has a signed_token: an ES256-signed JWT issued by the server and verifiable offline with the product's public key (GET /v1/licenses/{key}/public-key). It is re-signed whenever the license itself changes — issue, approval, status change (suspend/resume/revoke, the grace/expiry sweep), renewal, plan change or trial conversion — and token_version increments. Seat assignments, releases and role changes do not re-sign it: seats are live server state, read through the seat endpoints below, not token content.
{
"lid": "7f9a…", // license id
"key": "ABCD-EFGH-…", // human-readable license key
"pid": "contoso-docs", // product code
"cid": "…", // client org id
"kind": "trial", // trial | paid | nfr | internal
"model": "self_hosted",
"status": "active", // active | grace | suspended | revoked | expired
"nbf": 1735000000, // starts_at
"exp": 1737600000, // expires_at (absent = perpetual)
"gex": 1738200000, // grace_ends_at
"gmode": "read_only",
"ogd": 7, // offline grace days (self-hosted)
"seats": 25,
"ovf": { "enabled": true, "days": 14, "max": 5 },
"quota": { "calls": 100000, "period": "month" },
"feat": { "export": true, "sso": false, "storage_gb": 50 },
"act": { "max": 2 },
"roles": { // only when the license has role limits
"admin": { "seat": true, "max": 2, "req": true },
"user": { "seat": true, "max": 20, "req": false },
"viewer": { "seat": false, "max": null, "req": false }
},
"ver": 3,
"iat": 1735000000,
"kid": "prod-contoso-docs-2026-01"
}
App-side rule: now < exp → normal; exp ≤ now < gex → apply gmode; now ≥ gex or status in {suspended, revoked, expired} → blocked.
roles is present only for a license whose plan (or, for a trial, policy) sets per-role limits. One entry per role code: seat = the role takes a seat (false = a free seat, counted against that role only); max = the role's pool (0 = role not allowed, null = unlimited, which only a role without a seat may have); req = every plan must allow at least one (Admin). Each role is checked against its own pool — there is no shared pool — and seats stays the sum of the seat roles' limits for older clients. The Go license client exposes it as Status.Roles.
feat holds the plan's feature grants (or the license's overrides of them). A feature granted without a number is true. A feature given a limit on the plan (the plan form's optional "Limit" next to each feature, limit_value) carries that number instead, e.g. "storage_gb": 50, so a plan can cap a countable thing in the consuming app (Accounts reads companies and companies.archived). The Go license client exposes the numbers as Status.Limits and Status.Limit(code) → (limit, limited, granted): granted false = the license doesn't carry the feature; limited false = granted with no limit. Status.Features keeps its meaning (a numeric grant of 0 is not in it).
Per-user validation response
When the app validates a specific end user (POST /v1/licenses/{key}/validate-user, body {subjectId}) the server returns (seatKind and role only when the subject holds a seat, seatExpiresAt only for an overflow seat):
{ "subject": "<subjectId>", "seatKind": "overflow", "role": "user", "seatExpiresAt": "2026-11-02T10:00:00Z", "allowed": true }
so the app can show "9 days remaining" for overflow users without another round trip.
Endpoints (consuming app, authenticated by org API key)
| Method | Path | Purpose |
|---|---|---|
POST | /v1/licenses/{key}/activate | Self-hosted: bind instance fingerprint, returns token |
POST | /v1/licenses/{key}/checkin | Self-hosted: heartbeat, reports active seat count, returns latest token if version changed |
POST | /v1/licenses/{key}/deactivate | Release an activation |
GET | /v1/licenses/{key}/token | Fetch current signed token |
POST | /v1/licenses/{key}/seats | Assign a seat: {subjectId, subjectEmail, subjectName, role?} → seat {subjectId, subjectEmail, subjectName, role, kind, assignedAt, expiresAt} (kind: standard / overflow / free). Idempotent for the same subject + role; another role moves the seat as PATCH does. 409 {"error": "role_limit_reached" | "role_not_in_license" | "seat_capacity_exhausted", "role": "<code>"}. Without role on a license with role limits, the whole seat_capacity pool is checked (older clients) |
PATCH | /v1/licenses/{key}/seats/{subjectId} | Change a seat's role: {role} → seat. The target role is checked as a new assignment (the subject's current seat doesn't count), same 409s |
GET | /v1/licenses/{key}/seats | Every active seat, same seat shape |
GET | /v1/licenses/{key}/roles | Role limits and use: [{code, name, seat, required, max, used, overflow, overLimit}] — used = standard + free seats; empty array = no role limits |
DELETE | /v1/licenses/{key}/seats/{subjectId} | Release a seat; the oldest overflow seat of the same role (of the whole pool without role limits) is promoted |
POST | /v1/licenses/{key}/validate-user | Is this subject allowed right now; returns seat kind, role and overflow expiry |
POST | /v1/licenses/{key}/usage | SaaS: record API call(s); returns allow/deny and remaining quota |
POST | /v1/trials | Self-serve trial request |
POST | /v1/trials/start | App Store "Start trial" for one tenant, by product code + clientTenantId: finds-or-creates the client org and, when the policy is trial_self_serve, issues the trial and returns its license key so the consuming app activates it in the same request (state: installed); otherwise state: pending. 409 already_licensed / trial_already_used. The trial goes through the client's reseller, if any (see below) |
POST | /v1/licenses/request | Self-serve: find-or-create the calling tenant's client org and open a requested-status license, from the integration key's own product. The license records the client's reseller, so that reseller can approve it — see the developer guide |
GET | /v1/licenses/mine | Self-serve: list every license already on file for the calling tenant (including requested), scoped by the caller's own org's licenses |
GET | /v1/products/{code}/plans | List a product's active, purchasable plans — powers a consuming app's own "buy/request a license" picker |
POST | /v1/organizations/register | Lighter self-serve variant: find-or-create the calling tenant's client org only, no license request |
Reseller channel. A client org created through a reseller's integration key gets reseller_id = that reseller. Its license requests and trials are attributed to that reseller while it stays authorised for the product (LicenseService.ChannelReseller), and POST /v1/admin/licenses with a resellerId only accepts that reseller's own clients (403 client_not_in_reseller_channel). Full flow: overview and roles.
SaaS hot path
UPDATE usage_counters
SET api_calls = api_calls + :n, updated_at = now()
WHERE license_id = :lid AND period_start = :window
RETURNING api_calls, quota;
One atomic statement decides allow/deny (hard_limit); the usage_events row is written asynchronously with the caller's request_id for idempotency.
Management endpoints (by role)
Everything below sits under a single /v1/admin/... prefix. It is for human callers with an Entra bearer token, as opposed to the org-API-key surface above. Role checks beyond a valid bearer token happen per handler.
| Area | Path prefix | Roles |
|---|---|---|
| Organisations, roles, API keys, audit, notifications | /v1/admin/organizations... | super_admin (create any org type); every role reads/manages within its own visibility scope |
| Products, plans, features, signing key | /v1/admin/products... | app_owner_admin (own products) |
| Reseller authorisation & ceilings, reseller-invite links | /v1/admin/products/{id}/resellers..., /v1/admin/products/{id}/reseller-invites..., /v1/admin/reseller-invites/{token}/accept | app_owner_admin (own products); invite-accept needs no role, the token is the access control |
| Policies | /v1/admin/products/{id}/policies | app_owner_admin (direct), reseller_admin (own channel) |
| Licenses: issue / approve / suspend / resume / revoke / renew / convert | /v1/admin/licenses... | app_owner_admin, reseller_admin (per the data-visibility rules); client_admin/viewer can read (list/get) their own org's licenses only, never mutate |
| Trial approvals | /v1/admin/trials... | app_owner_admin, reseller_admin (own channel) |
| Caller's own organization: App Store catalog | GET /v1/admin/me/catalog?products=… | Any signed-in user — reads the state per product (installed/pending/expired/suspended/available), plans and trial offer of the client org the caller acts for: their tenant's (tid) for a work account, or suite:<Suite Core org id> for a personal organization (a personal account's own, or the one X-Suite-Org names, checked against Suite Core). Never returns license keys |
Personal accounts (the suite's Entra External ID tenant, accepted when AzureAd__ExternalTenantId/AzureAd__ExternalClientId are set):
- Users: each is its own
usersrow keyed byexternal_idp_id(issuer + oid), never matched to a work account by email. - Platform admin: the platform-admin bootstrap ignores them.
- App owners and reseller invites: they can onboard as an app owner (
onboard-app-owner, or the store trialPOST /api/admin/license/trial) and accept a reseller invite. Both orgs are keyedsuite:<id>(their personal organization), like a client org, and are named after the person. - Organizations: consuming apps register a personal organization's client org with
clientTenantId = "suite:<id>".
Seats also have a bearer-authenticated surface, for the admin portal's Seats tab: GET/POST /v1/admin/licenses/{key}/seats, PATCH/DELETE /v1/admin/licenses/{key}/seats/{subjectId} (body
{role} for a role change, role optional on assign) and /v1/admin/licenses/{key}/group-assignments...
(attach takes an optional roleCode given to the group's members) — app_owner_admin / reseller_admin
for writes, readers of the license for reads; these return the PascalCase LicenseSeat/GroupAssignment
objects plus RoleCode. Activation and usage recording stay on the org-API-key surface only. Day to day,
a client's seats are still managed through the consuming app's own UI, which holds the org's API key
and calls the surface above on the tenant's behalf — see the developer guide.
Roles (also bearer, app_owner_admin of the product to write, product readers to read):
GET/POST /v1/admin/products/{id}/roles, PUT/DELETE /v1/admin/products/{id}/roles/{roleId} (DELETE
deactivates; 409 role_in_use while an active seat holds the role). Plan create/update take
roleLimits: [{roleCode, maxUsers}] (PATCH: omitted = unchanged, [] = back to one pool) and plan reads
return roleLimits plus missingRoles; policies take and return trialRoleLimits; GET /v1/admin/licenses/{key} returns roleLimits (the /roles usage shape).
Security notes
- Private signing keys live in Key Vault / KMS; the database stores only
signing_key_idand the public key. - Rotate by issuing a new
kid; the app keeps the previous public key for the overlap window. license_keyis a lookup handle. All authority comes from the signature.- API keys are hashed for lookup (
key_hash); scopes restrict a key to validate / usage / seat-management as needed. - Enable row-level security on scoped tables and set
app.role/app.org_idper request.