Skip to main content

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)​

MethodPathPurpose
POST/v1/licenses/{key}/activateSelf-hosted: bind instance fingerprint, returns token
POST/v1/licenses/{key}/checkinSelf-hosted: heartbeat, reports active seat count, returns latest token if version changed
POST/v1/licenses/{key}/deactivateRelease an activation
GET/v1/licenses/{key}/tokenFetch current signed token
POST/v1/licenses/{key}/seatsAssign 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}/seatsEvery active seat, same seat shape
GET/v1/licenses/{key}/rolesRole 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-userIs this subject allowed right now; returns seat kind, role and overflow expiry
POST/v1/licenses/{key}/usageSaaS: record API call(s); returns allow/deny and remaining quota
POST/v1/trialsSelf-serve trial request
POST/v1/trials/startApp 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/requestSelf-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/mineSelf-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}/plansList a product's active, purchasable plans — powers a consuming app's own "buy/request a license" picker
POST/v1/organizations/registerLighter 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.

AreaPath prefixRoles
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}/acceptapp_owner_admin (own products); invite-accept needs no role, the token is the access control
Policies/v1/admin/products/{id}/policiesapp_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 catalogGET /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 users row keyed by external_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 trial POST /api/admin/license/trial) and accept a reseller invite. Both orgs are keyed suite:<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_id and the public key.
  • Rotate by issuing a new kid; the app keeps the previous public key for the overlap window.
  • license_key is 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_id per request.