Skip to main content

Developer guide

Integrate Licentra into your app using plain HTTPS and JSON. No SDK is required: any language that can make an HTTP request and verify an ES256 signature will work.

Contents​

  1. How it works
  2. Before you start
  3. Authentication
  4. Quick start
  5. The signed license token
  6. Self-hosted apps: activate and check in
  7. Seats and users
  8. SaaS apps: usage metering
  9. Self-serve: plans, requests, trials
  10. License states and what your app should do
  11. Errors
  12. Integration checklist
  13. Embeddable UI components
  14. Sell a plan from a link
  15. Onboard your app in the portal
  16. Integrate from Go with the shared client
  17. Admin screens in your React app

1. How it works​

You (the app owner) register your app, define plans, and get one integration key. Your app's backend uses that key for every call. Your customers each get a license key that says what they are entitled to.

Your customer's admin ──license key──► Your app ──integration key──► Licentra
▲ │
└────── signed token ◄─────────┘
TermMeaning
ProductYour app, identified by a permanent product code (for example acme-docs).
PlanA package you sell: seats, API quota, activations, features, duration.
LicenseA customer's entitlement to a plan, identified by a license key.
Integration keyYour app's credential for Licentra (it starts with sk_). One key covers all your calls.
SubjectAn end user of your app, identified by an id you choose (subjectId).
Instance fingerprintA stable id for one installation of your app (self-hosted only).

Two licensing models are supported. Pick one per product when you register it.

ModelMetered byWorks offline
self_hostedSeats and instance activationsYes. The signed token is verified locally, with periodic check-ins.
saasSeats and API-call quota per periodNo. Your app calls Licentra while running.

2. Before you start​

In the Licentra portal:

  1. Create or join an app owner organization.
  2. Add a new app: name, a product code, and the licensing model. The code never changes once shipped.
  3. Define at least one plan.
  4. Create an integration key. It is shown once; store it as a secret.

You now have three values. Keep them in your app's configuration, never in front-end code:

SettingExampleUsed for
Base URLhttps://api.licentra.sankhyatronics.comPrefix for every call below
Product codeacme-docsIdentifies your product
Integration keysk_…Authenticates every call

Call Licentra from your backend only. The integration key must never reach a browser or a customer's machine. For self-hosted apps that you ship to customers, see the note on key handling in section 6.

3. Authentication​

Send the integration key as a bearer token:

Authorization: Bearer <integration key>
Content-Type: application/json
  • A key belongs to your organization and only works for your own products. Calls for another owner's license return 403 not_authorized.
  • Self-serve calls (plans, requests, trials, registration, embed sessions) additionally need the key's request scope (license:request). Without it they return 403 not_authorized.
  • Assigning and releasing seats (POST and DELETE on /seats) needs the seat:manage scope. Changing a seat's role (PATCH) does not check scopes.
  • Keys created in the Licentra portal, and the suite's integration keys, carry every scope. A key created through the admin API needs its scopes list sent explicitly.
  • A missing or invalid key returns 401 unauthorized.
  • If you rotate or revoke a key, update your app's configuration. A revoked key returns 401.

4. Quick start​

The smallest working integration for a self-hosted app:

1. Activate when the customer enters their license key:

curl -X POST "$BASE/v1/licenses/ABCD-EFGH-IJKL/activate" \
-H "Authorization: Bearer $INTEGRATION_KEY" \
-H "Content-Type: application/json" \
-d '{"instanceFingerprint":"cust-42-server-1","instanceName":"Head office","appVersion":"2.3.0"}'
{
"licenseToken": "eyJhbGciOiJFUzI1NiIs…",
"status": "active",
"expiresAt": "2027-10-21T00:00:00Z",
"activationId": "5c0e…"
}

2. Fetch the public key once and cache it:

curl "$BASE/v1/licenses/ABCD-EFGH-IJKL/public-key" -H "Authorization: Bearer $INTEGRATION_KEY"
{ "keyId": "prod-acme-docs-2026-01", "publicKeyPem": "-----BEGIN PUBLIC KEY-----…" }

3. Verify the token locally (ES256) and apply its contents (section 5).

4. Check in on a schedule (every few hours) so revocations, renewals and plan changes reach the app (section 6).

For a SaaS app, skip activation: validate the license token, assign seats, and record usage (sections 7 and 8).

5. The signed license token​

Every license has a signed token: a JWT signed with ES256. You can verify it offline with the product's public key (GET /v1/licenses/{key}/public-key). The license key is only a lookup handle. All authority comes from the signature.

Always verify the signature, nbf and exp before trusting any claim.

{
"lid": "7f9a…",
"key": "ABCD-EFGH-IJKL",
"pid": "acme-docs",
"cid": "…",
"kind": "paid",
"model": "self_hosted",
"status": "grace",
"iss": "licentra",
"nbf": 1735000000,
"exp": 1737600000,
"gex": 1738200000,
"gmode": "read_only",
"ogd": 7,
"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": { "admin": { "seat": true, "max": 2, "req": true } },
"ver": 3,
"iat": 1735000000,
"kid": "prod-acme-docs-2026-01"
}
ClaimMeaning
lid, key, pid, cidLicense id, license key, product code, customer organization id
kindtrial, paid, nfr (not for resale, for testing) or internal
modelself_hosted or saas
statusactive, grace, suspended, revoked, expired, plus draft and requested (a license not yet usable)
issAlways licentra for this deployment's tokens
nbf / expValid from / valid until (Unix seconds). exp is absent for a perpetual license.
gex, gmodeWhen grace ends, and the grace behavior: full, read_only or reduced. Both are present only while status is grace.
ogdSelf-hosted only: days the instance may run without a successful check-in
seatsSeat capacity
ovfOverflow seats: enabled, days each lasts, max simultaneous (0 = no cap). Present only when overflow is enabled.
quotaSaaS: allowed calls per period. Present only when the plan sets one.
featFeature grants. true, or a number for a limit (for example "storage_gb": 50). A feature that is absent is not included.
act.maxMaximum activations (installations). Present only when the plan sets a limit.
rolesPresent only when the plan limits users per role. seat says whether the role uses a seat, max is the pool (0 = not allowed, null = unlimited), req marks a required role.
verToken version. It rises whenever the license changes.
kidThe signing key id, also a claim in the payload (not a JWT header). Match it to the public key's keyId. It is absent for a product that has no signing key of its own.

Key rotation. The vendor can rotate the signing key. Cache the public key by kid, and when you see an unknown kid, fetch the public key again.

What does not change the token. Seat assignments and releases are live server state, not token content. Read them through the seat endpoints.

6. Self-hosted apps: activate and check in​

Activate​

POST /v1/licenses/{key}/activate

FieldRequiredDescription
instanceFingerprintyesStable id of this installation. Reuse the same value on every call.
instanceNamenoFriendly name shown in the portal
appVersionnoYour app's version

Returns licenseToken, status, expiresAt, activationId. Activation fails if the license has reached its activation limit (act.max) or is not usable.

Check in​

POST /v1/licenses/{key}/checkin

FieldRequiredDescription
instanceFingerprintyesThe same value used at activation
activeSeatsnoHow many seats are in use on this instance
appVersionnoYour app's version

Returns licenseToken, status, expiresAt, tokenVersion. Replace the stored token with the new one when tokenVersion is higher.

Schedule. Check in every few hours, and once at startup. This is how a suspension, revocation, renewal or plan change reaches the app. If a check-in fails because the server is unreachable, keep running on the last valid token until ogd (offline grace days) is used up, then block.

Deactivate​

POST /v1/licenses/{key}/deactivate with {"instanceFingerprint":"…"}. Returns 204. Use it when a customer moves the app to another machine, so the activation slot is released.

Read the token without activating​

GET /v1/licenses/{key}/token returns licenseToken, status, expiresAt, tokenVersion.

Key handling for shipped software. The integration key is a vendor secret. If your app runs on the customer's own servers, route activation and check-in through a backend you control, or accept that the key is present on their infrastructure and scope it accordingly. Never embed it in a desktop or mobile binary.

7. Seats and users​

Seats limit how many people can use a license. Your app tells Licentra who uses it. Use any stable id as subjectId (a user id or a hashed email), and avoid sending personal data you do not need.

CallPurpose
POST /v1/licenses/{key}/seatsAssign a seat
GET /v1/licenses/{key}/seatsList every active seat
PATCH /v1/licenses/{key}/seats/{subjectId}Change a seat's role
DELETE /v1/licenses/{key}/seats/{subjectId}Release a seat. Returns 204.
POST /v1/licenses/{key}/validate-userMay this person use the app right now?
GET /v1/licenses/{key}/rolesRole limits and how many are used

Assign a seat​

POST /v1/licenses/ABCD-EFGH-IJKL/seats
{ "subjectId": "user-1029", "subjectEmail": "sam@customer.com", "subjectName": "Sam Lee", "role": "user" }

Only subjectId is required. The call is idempotent: assigning the same person to the same role again changes nothing. Assigning a different role moves the seat.

The response is the seat: subjectId, subjectEmail, subjectName, role, kind, assignedAt, expiresAt.

kindMeaning
standardA normal seat
overflowGranted past capacity for a limited time (expiresAt). If a standard seat frees up, the oldest overflow user is promoted automatically.
freeA role that does not use a seat

Errors (409): seat_capacity_exhausted, role_limit_reached, role_not_in_license. The body always has a role key: role_limit_reached and role_not_in_license fill it in, while seat_capacity_exhausted on a license without role limits returns "role": "".

Validate a user​

POST /v1/licenses/ABCD-EFGH-IJKL/validate-user
{ "subjectId": "user-1029" }
{ "subject": "user-1029", "seatKind": "overflow", "role": "user", "seatExpiresAt": "2026-11-02T10:00:00Z", "allowed": true }

A person with no seat returns {"subject":"…","allowed":false}. Show "N days left" to overflow users using seatExpiresAt. seatKind and role appear whenever the person holds a seat (role is null when the seat has no role). seatExpiresAt appears only for an overflow seat.

8. SaaS apps: usage metering​

Record each metered action. Licentra counts it against the license's quota for the current period and tells you whether to allow it.

Record usage​

POST /v1/licenses/ABCD-EFGH-IJKL/usage
{ "subjectId": "user-1029", "featureCode": "export", "quantity": 1, "requestId": "req-7f3a91" }
FieldRequiredDescription
quantitynoUnits to count. Defaults to 1 when missing or 0 (for example 1 per call).
requestIdrecommendedYour own unique id. A repeated requestId is not counted twice, so retries are safe.
subjectIdnoThe end user
featureCodenoWhich feature or meter
  • 200 means allowed. The response's status is ok, warning (near the limit) or overage (past the limit, where the plan allows overage).
  • 402 Payment Required means the quota is exhausted and the action should be refused. This is the only way the status exceeded appears.
  • The 200 and 402 responses use PascalCase keys: Meter, Allowed, APICalls, Quota, Remaining, Status, BlockAt, ResetsAt.
  • Plan for the call being unavailable: decide whether your app fails open or closed if Licentra cannot be reached.

Read usage​

GET /v1/licenses/{key}/usage?meter=api_calls&months=12

{
"meter": "api_calls",
"period": "month",
"current": { "periodStart": "2026-10-01T00:00:00Z", "resetsAt": "2026-11-01T00:00:00Z", "used": 8120, "limit": 100000, "status": "ok", "blockAt": 100000 },
"history": []
}

limit and blockAt are null when unlimited, and also when the plan allows overage or has no hard limit. Use this to show customers their own usage inside your app.

9. Self-serve: plans, requests, trials​

These calls let your app's own screens sell and start licenses, with no portal work per customer. They need the key's request scope.

A tenant here is your customer's organization, identified by an id you choose (clientTenantId). Use the same value every time for the same customer.

List the plans you sell​

GET /v1/products/{code}/plans

[ { "code": "pro", "name": "Pro", "seatCapacity": 25, "durationDays": 365, "priceAmount": 499, "priceCurrency": "USD" } ]

Only active, sellable plans are returned. Price is for display. Licentra does not take payment.

Register a customer​

POST /v1/organizations/register with { "externalTenantId": "…", "name": "…", "contactEmail": "…" } finds or creates the customer organization. It does not create a license.

Request a license​

POST /v1/licenses/request
{
"productCode": "acme-docs",
"planCode": "pro",
"clientTenantId": "cust-42",
"clientName": "Customer 42 Ltd",
"clientContactEmail": "it@customer.com",
"requesterEmail": "sam@customer.com",
"requesterName": "Sam Lee"
}

Required: productCode, planCode, clientTenantId, requesterEmail. It finds or creates the customer organization and opens a license in requested status. You (or your reseller) approve it in the portal, and it becomes active. Asking again returns "alreadyRequested": true.

List a customer's licenses​

GET /v1/licenses/mine?productCode=acme-docs&clientTenantId=cust-42

Returns each license: licenseKey, status, kind, planCode, planName, seatCapacity, requestedAt, issuedAt, expiresAt. An unknown customer returns an empty list, not an error. Use it to find the key once a request is approved.

Start a trial​

POST /v1/trials/start
{ "productCode": "acme-docs", "clientTenantId": "cust-42", "clientName": "Customer 42 Ltd", "requesterEmail": "sam@customer.com" }
  • The response has state (installed when the trial was issued at once, or pending when it waits for approval), trialRequestId, licenseKey (when installed) and expiresAt.
  • If your trial policy is self-serve, the trial is issued immediately and the response includes the license key, so your app can activate it straight away. Otherwise the trial waits for your approval.
  • Errors: 409 already_licensed when the customer already has a license for the product. A trial rule that the policy enforces (one per customer, or per company domain or fingerprint) returns 409 conflict with a detail field, not trial_already_used.

POST /v1/trials is the older form for requesting a trial by organization id. It takes productId (uuid), requesterEmail (required), clientOrgId, resellerId and instanceFingerprint.

10. License states and what your app should do​

active ──expiry──► grace ──grace ends──► expired
│ ▲ │ │
│ └──── renew ────┴─────────────────────┘
├── suspend ──► suspended ──resume──► active
└── revoke ───► revoked (final)
CheckApply
now < exp, status activeNormal operation
exp ≤ now < gex (status grace)Apply gmode: full (work normally, show a renewal banner), read_only (no writes, no new users), reduced (your own rules)
now ≥ gex, or status suspended, revoked, expiredBlock, and explain how to renew
Token signature invalidBlock

During grace, Licentra refuses new seats and activations regardless of gmode.

Fail closed. Gate your app's main features on a valid license, and keep a path open for the admin to enter a key or request one, or customers cannot fix the problem.

11. Errors​

Errors return a JSON body with an error code:

{ "error": "seat_capacity_exhausted", "role": "user" }
StatusCodeMeaning
400invalid_request_bodyMalformed JSON
400invalid_input, trial_unavailable, disposable_email, or a snake_case message naming the field (for example instance_fingerprint is required)Missing or invalid field, or a request the product can't take
401unauthorizedMissing, invalid, revoked or expired integration key. The body is JSON, but the Content-Type is text/plain.
402expiredThe license has expired, or the quota is exhausted (usage)
403not_authorizedThe key may not do this, for example another owner's license, or a missing scope
403license_not_activeThe license is suspended, revoked, expired or in grace, so it can't activate, check in, take a seat or record usage
403plan_limitA plan limit was reached. The body has more detail.
404not_found, product_not_found, plan_not_found, client_not_foundUnknown license, product, plan or customer
409seat_capacity_exhausted, role_limit_reached, role_not_in_license, already_licensedA seat or plan conflict
409conflictA rule the trial policy enforces was broken. The body has a detail.
429quota_exceededThe vendor's own plan limit was reached. Also used by the global rate limiter.
5xxServer problem. Retry with backoff, and keep running on your last valid token.

Retry only on network errors and 5xx. Do not retry 4xx responses unchanged.

12. Integration checklist​

  • Product code, base URL and integration key are in your backend configuration, not in client code
  • Activation on first use (self-hosted) with a stable instanceFingerprint
  • Token signature, nbf, exp and kid are verified against the cached public key
  • Check-in at startup and every few hours; the stored token is replaced when tokenVersion rises
  • Offline behavior follows ogd; the app fails closed afterwards
  • gmode is applied during grace, with a visible renewal prompt
  • Seats are assigned and released as users join and leave; validate-user is used where access is decided
  • SaaS: usage is recorded with a unique requestId; 402 is handled
  • An admin screen lets customers enter a key, see their licenses and request or try a plan
  • The app handles integration key rotation without a restart
  • Integration tests cover each state: active, grace, expired, suspended, revoked

13. Embeddable UI components​

@sankhyatronics/licentra-ui gives you ready-made elements so you don't build these screens yourself. They are web components that work in any framework, with React wrappers under @sankhyatronics/licentra-ui/react.

npm install @sankhyatronics/licentra-ui
ElementWhat it showsCredential
<st-pricing>Your plans as pricing tiles, for your public websiteNone
<st-seat-manager>A customer's license, its roles against their limits, and who holds a seat; admins can assign and releaseAn embed session token
<st-role-usage>, <st-seat-list>The two parts <st-seat-manager> is built from, for building your own screenNone (you pass the data)

Pricing tiles (public site)​

<script type="module">import '@sankhyatronics/licentra-ui/pricing';</script>

<st-pricing api-url="https://api.licentra.sankhyatronics.com" org="acme" product="acme-docs"
cta-href="/signup?plan={plan}" highlight="pro"></st-pricing>
  • api-url is your Licentra URL. Pass it from your app's own environment variable.
  • org is your organization's slug and product is the product code.
  • {plan} in cta-href becomes the plan code. A button click first fires a cancelable plan-select event, so call preventDefault() to handle it yourself.
  • It calls GET /v1/public/pricing/{org}/{product}, which needs no key and returns only your active plans.
  • Set the plans property instead to render data you already have.

Seat manager (inside your app)​

The seat manager needs a session token, so your integration key stays on your server.

1. Your backend mints a token for the signed-in user, with your integration key:

POST /v1/embed/sessions
Authorization: Bearer <integration key>
Content-Type: application/json

{ "productCode": "acme-docs", "clientTenantId": "cust-42",
"subjectId": "user-1029", "subjectEmail": "sam@customer.com", "role": "admin", "ttlSeconds": 900 }
{ "token": "est_…", "expiresAt": "2026-10-21T10:15:00Z", "role": "admin" }
FieldRequiredDescription
productCode, clientTenantId, subjectIdyesYour product, your customer's id (the same one you use for licenses), and the signed-in user
rolenoviewer (default, read-only) or admin (can assign and release seats). Decide this from your own permission check.
ttlSecondsnoDefault 900, maximum 3600

The customer must already be registered. An unknown customer returns 404 client_not_found. A reseller's key can mint a session only for a customer in its own channel; any other customer returns 403 client_not_in_reseller_channel. A bad role or ttlSeconds returns 400 invalid_input.

2. Your page gives the element a way to get a token:

import '@sankhyatronics/licentra-ui/seat-manager';

const el = document.querySelector('st-seat-manager');
el.apiUrl = 'https://api.licentra.sankhyatronics.com';
el.getToken = async () => (await fetch('/api/license-session')).json().then(r => r.token);

getToken is called at start and again whenever the token is refused, so sessions renew without a reload. React: import { SeatManager } from '@sankhyatronics/licentra-ui/react'.

A token can only see and change one customer's licenses for one product. It is a bearer secret, so send it only over HTTPS.

Embed API (for building your own UI)​

These routes take Authorization: Bearer est_… and allow calls from any origin.

CallRole neededPurpose
GET /v1/embed/sessionanyThe session's role, product and customer name
GET /v1/embed/licensesanyThe customer's licenses: licenseKey, status, kind, planName, seatCapacity, seatsUsed, expiresAt and graceEndsAt
GET /v1/embed/licenses/{key}/seatsanyActive seats
GET /v1/embed/licenses/{key}/rolesanyEach role's limit and use (empty when the license has no role limits)
POST /v1/embed/licenses/{key}/seatsadminAssign a seat. Body: subjectId, subjectEmail, subjectName, role
DELETE /v1/embed/licenses/{key}/seats/{subjectId}adminRelease a seat (204)

Errors follow section 11. A license that isn't the customer's returns 404, and a viewer calling an admin route returns 403 not_authorized.

Each plan of your product can be bought from a link, with no integration work on your side. In the Licentra admin, open the product's Plans tab, set up Payments (your Google Pay values, so the money goes to your own account), and press Copy payment link on a plan. Put the link on any page or in any email.

The buyer confirms their email with a code, gives billing details and pays. They then sign in, or create an account, from the link in their receipt email, and the plan is added to their organization as an ordinary license, ready for your app's activate and check-in calls. A free plan skips payment and is granted once per person and per business email domain.

In test mode no money moves. Live payments need a payment processor connected on the Licentra side, which is not available yet.

15. Onboard your app in the portal​

Before any code runs, the app owner sets the app up in the Licentra admin portal. This is a one-time setup per app.

  1. App owner organization. A super_admin creates it (Organizations, type App owner). When the deployment allows self-service sign-up, a person can register one from their own Entra tenant, and that tenant can have only one app owner organization.
  2. Register the app (Apps, then Add a new app): name, code, and licensing model (self_hosted or saas). The code is the app's permanent id: lowercase, never changed once shipped. Set the default offline grace days for self-hosted apps.
  3. Define plans on the product's Plans tab: code, name, seat capacity, API quota (SaaS), max activations, and duration (blank = perpetual).
  4. Get the connection key. The wizard's connection-key step issues the integration key. Each organization has one key on its API keys tab: create it once, Rotate it to replace it (the old key stops working at once), or Revoke it.

The app then needs three values in its own configuration. Keep them in the backend, never in browser code:

Env varValue
License__ServerUrlLicentra's base URL
License__ProductCodeThe app code from step 2
License__ApiKeyThe integration key from step 4

Where the app can reach the Key Vault that holds the key, set License__ApiKeySecretName to the secret's name instead of the raw key. Set one or the other, never both. The secret's name is shown only to the suite's own app-owner organization. Other organizations get the raw key from the API keys tab.

An owner that already has an integration key can use it for a newly registered app, and the wizard offers to do that.

If the integration key is blank, the app still starts and its admin screens stay reachable, but its license gate fails closed and the plan picker stays empty. So an app that is registered but not yet configured is in a recoverable state.

Clients. A client organization appears in one of three ways: a customer requests a license through your app (POST /v1/licenses/request), your app registers the customer without a license (POST /v1/organizations/register), or a super_admin creates it on the Organizations screen. Whichever way, the license is then issued, renewed, suspended or revoked in the portal.

16. Integrate from Go with the shared client​

Go apps should not write their own Licentra client. Use the shared module sankhyatronics.com/licentra-client (package license). It implements sections 3 to 10 above: activation and check-in, local token verification, the fail-closed gate, seats, metering, trials, and license requests. Non-Go apps implement the same contract over HTTP.

The suite's own Go products (T-pilot, CRM, Accounts, Support) all use the module. They follow the same seven steps, and the shared module is the only place license traffic is built.

1. Config. Read the three values into your settings, then build the client:

licenseClient := license.NewClient(license.Config{
ServerURL: settings.LicentraURL,
ApiKey: settings.LicenseApiKey, // the literal key, or empty when using the secret
ApiKeySource: apiKeySource, // reads the Key Vault secret when the name is set
ProductCode: settings.LicenseProductCode,
CheckInIntervalMinutes: settings.LicenseCheckInIntervalMinutes,
OfflineGraceHours: settings.LicenseOfflineGraceHours,
TenantForSubject: ..., // enables license discovery (step 5)
OnCheckIn: ..., // optional: mirror each check-in into your own registry
}, store)
Env varDefault
License__ServerUrlnone
License__ApiKey or License__ApiKeySecretNamenone (set one)
License__ProductCodeyour app's code
License__CheckInIntervalMinutes360
License__OfflineGraceHours72

2. Store. Implement license.Store over your own table, with one row per subject (usually one organization or tenant):

type Store interface {
Get(ctx context.Context, subjectID uuid.UUID) (licenseKey, instanceID, tenantDomain string, err error)
Set(ctx context.Context, subjectID uuid.UUID, licenseKey, instanceID, tenantDomain string) error
GetLastValidCheckIn(ctx context.Context, subjectID uuid.UUID) (expiresAt *time.Time, checkedAt time.Time, ok bool, err error)
SetLastValidCheckIn(ctx context.Context, subjectID uuid.UUID, expiresAt *time.Time, checkedAt time.Time) error
ListLicensedSubjects(ctx context.Context) ([]uuid.UUID, error)
GetContact(ctx context.Context, subjectID uuid.UUID) (ContactPerson, error)
SetContact(ctx context.Context, subjectID uuid.UUID, contact ContactPerson) error
}

Store no per-tenant API key here. The integration key is the only credential.

3. Calls. Refresh activates or checks in a subject. SaveLicenseKey persists a pasted key and refreshes. GetAvailablePlans, RequestLicense and GetMyLicenses use the integration key. StartTrial, AssignSeat/ReleaseSeat, NewMeter/ConsumeMeter, and RegisterOrganization cover the rest.

4. Background refresh. Call licenseClient.StartBackgroundRefresh(ctx) once at startup. It checks in every subject from ListLicensedSubjects on its timer, so a revocation or renewal takes effect without a restart.

5. Gate and discovery. Mount license.GateWith(client, subjectFromRequest, license.GateOptions{ExemptPrefixes: ...}) early, after authentication if the resolver reads caller claims. Exempt your own admin prefix so an admin can fix the problem, plus any health check. A rejected request gets 503 {"error":"license_invalid","detail":"<code>"}. Keep that shape exact, since the React side checks it.

When TenantForSubject is set, the gate also discovers a license issued since the last check (most often a link purchase or a reseller's request). It lists the tenant's licenses, adopts the best usable one, and activates it. This runs at most once a minute per tenant, and never while Licentra is unreachable.

6. Admin endpoints. Your frontend can't call Licentra, so proxy these through your own admin routes:

RouteCalls
GET /api/admin/licenseStatus, with no upstream call
PUT /api/admin/licenseSaveLicenseKey
GET /api/admin/license/plansGetAvailablePlans. Returns [], not an error, when not configured.
GET /api/admin/license/listGetMyLicenses
POST /api/admin/license/requestRequestLicense
POST /api/admin/license/trialStartTrial
POST /api/admin/license/refreshRefresh
GET/POST /api/admin/license/seats, DELETE /api/admin/license/seats/{subjectId}seat listing, AssignSeat, ReleaseSeat

Every product uses these exact paths and the same contract, so the shared admin screens work unchanged:

  • Status shape. The status routes (GET, PUT, /refresh) return license.StatusView: isValid, errorCode (when not valid), expiresAt, checkedAt, editable, reachable and features. An app adds its own fields by embedding it (T-pilot adds pendingRequest, Support adds productCode and seatRoles). Build it with status.View(editable) from the shared client. Never define a local copy.
  • Access. Every /api/admin/license route requires an admin of the organization (or, for Support, a support admin).
  • Client name. The name sent to Licentra is the customer organization's name. The requester's name goes in requesterName only.
  • Contact person. Optional for a license request, in every app. The Contact Person is a Suite Core preference edited in the portal. When none is set, the requester's email is sent as the client contact. The License tab never blocks a request on it.
  • Upstream failures. GET /plans and GET /list return 200 [] when Licentra can't be reached. POST /request and POST /trial return 502 with the code from license.UpstreamErrorCode.

7. Wiring. In your app's main entry point: build the store, the client (one per process), start the background refresh, the optional meter, then mount the gate in the router.

Pitfalls to avoid. Do not rebuild the gate or the seat cache by hand. Use GateWith with a BeforeCheck hook for any extra checks, and the module's SeatKeeper for seats. Do not send the person's name as the client name; send the customer organization's name.

17. Admin screens in your React app​

Add license methods to the data-access interface your admin UI uses:

getLicenseStatus(): Promise<LicenseStatusView>;
saveLicenseKey(licenseKey: string): Promise<LicenseStatusView>;
getLicensePlans(): Promise<LicensePlanOptionView[]>;
getLicenses(): Promise<LicenseSummaryView[]>;
requestNewLicense(planCode: string): Promise<LicenseRequestResultView>;
refreshLicenseStatus(): Promise<LicenseStatusView>;

Each method is a thin call to the routes in step 6 above. saveLicenseKey takes only the license key. Never ask a tenant admin for the integration key; it belongs in the app's own configuration.

The License tab. Show whether the license is valid, its error code and expiry, and any pending request. While it's invalid and the user can manage licenses, offer three actions: activate a key, see the licenses on file (with a "use this key" action), and request a new one. Show the plan picker only when the plans list is non-empty.

Every suite app shows this tab the same way: the shared admin page (AdminPage from @sankhyatronics/suite-react/admin-page, which renders st-admin-page) with only the license tab enabled, or with the other tabs the app needs as well. Support, Accounts, CRM and T-pilot all do this. Do not build a license screen of your own, and do not use the presentational LicensePanel on its own: the page wires the panel's events, the seat and usage cards, the contact warning and the refresh for you.

import { AdminPage } from '@sankhyatronics/suite-react/admin-page';
import { resolveAdminPageLabels } from '@sankhyatronics/suite-components/admin-page';

<AdminPage
dataSource={dataSource} // your AdminDataSource (below)
productName="Your App" // used in the license copy
roles={roles} // your roles, with a rank per role
enabledTabs={['license']} // add 'users' and others as your app needs
licenseSeatsSupported={false} // true when the app assigns seats (Accounts, CRM, T-pilot)
licenseRequiresContact={false} // the portal collects the Contact Person for the whole suite
contactSettingsUrl={portalConfigurationUrl()} // where the warning links when the contact is missing
labels={resolveAdminPageLabels(i18n.language)}
/>

Set licenseRequiresContact={false} and link contactSettingsUrl to the portal. Contact Person is a Suite Core preference, edited in the portal. A license request doesn't wait on it: the requester's own email stands in as the client contact when none is set.

Data source. Implement AdminDataSource from @sankhyatronics/suite-components/shared. The license methods are the ones that matter for this tab: getLicenseStatus, saveLicenseKey, getLicensePlans, getLicenses, requestNewLicense, refreshLicenseStatus, getLicenseRoleUsage (the usage-by-role card; return your status's seatRoles or the equivalent), getContactPerson (read-only, from Suite Core), getLicenseSeats and the seat assign/remove methods (return an empty list and reject plainly when the app has no seats to assign). The other methods (users, usage, backup) are required by the interface; an app that doesn't show those tabs rejects them plainly rather than returning fake data.

Trials are not on this tab. An instant trial starts from the App Store, which calls the app's own POST /api/admin/license/trial. Global license_invalid handling. When any normal API call returns 503 with error: "license_invalid", redirect once to the License tab rather than letting each call show its own error:

export function isLicenseInvalidError(error: unknown): boolean {
return error instanceof HttpError && error.status === 503
&& (error.responseBody as { error?: unknown })?.error === 'license_invalid';
}