Contracts & Licenses API
The Contracts & Licenses API manages contracts and software licenses with AES-256-GCM encrypted license keys, TCO tracking, seat management, renewal monitoring, parent-child hierarchies (parentId), a software catalog (publishers/products), asset/user assignments and a full audit trail.
Authentication & Permissions
All endpoints accept a session or an X-API-Key with a role; each action has its own permission under contracts.* or licenses.*. For contracts, the edit right distinguishes between all contracts (editAll) and one’s own as owner (editOwn). For licenses, every permission applies to all licenses. See User Management & RBAC.
User context vs. API key: Master-data CRUD, export and statistics accept users AND API keys. Assignments/links (license↔asset/user, contract↔asset), the activity history and bulk status/delete however require a logged-in user — an API key gets 403 here ("requires a logged-in user account, not an API key").
Endpoints Overview
Contracts
| Method | Endpoint | Description | Permission |
|---|---|---|---|
GET | /api/contracts | Paginated list ({data, pagination}) | contracts.viewAll/viewOwn |
GET | /api/contracts/:id | Single contract (plain object, with ETag) | contracts.viewAll/viewOwn |
GET | /api/contracts/stats | Statistics (counts per status/type, expiry, volume) | contracts.viewAll/viewOwn |
GET | /api/contracts?deleted=1 | Trash — the same list endpoint with deleted=1 (only this value, otherwise 400). Filters, search, sorting and saved views apply there too, as do the visibility rules. | contracts.viewDeleted |
GET | /api/contracts/:id/children | Child contracts ({data, pagination}) | contracts.viewAll/viewOwn |
GET | /api/contracts/:id/licenses | Linked licenses ({data}) | contracts.viewAll/viewOwn |
GET | /api/contracts/:id/activities | Contract history ({data, pagination}, ?limit/?offset) | contracts.viewHistory |
POST | /api/contracts/:id/activities | Add comment → 201 (user-only) | contracts.editAll/editOwn |
GET | /api/contracts/export | CSV/XLSX/PDF | contracts.export |
POST | /api/contracts | Create → 201 | contracts.create |
PATCH | /api/contracts/:id | Update (editAll or editOwn; status and owner need their own right; version mandatory) | contracts.editAll/editOwn |
DELETE | /api/contracts/:id | Soft delete → 204 | contracts.delete |
POST | /api/contracts/:id/restore | Restore | contracts.restore + viewDeleted |
PATCH | /api/contracts/bulk/status | Bulk status (user-only, max. 100) | contracts.bulkUpdate |
DELETE | /api/contracts/bulk | Bulk delete (user-only, max. 100) | contracts.delete |
Visibility before action: contracts.delete and contracts.viewHistory apply to all contracts regardless of the owner. Deleting and the history therefore additionally check visibility of this very contract — whoever may not see it gets a 404, so its existence is not revealed. The trash involves two permissions: contracts.viewDeleted opens the trash, and the normal visibility rules (owner + management chain) still apply within it. Restoring requires both permissions and visibility of the contract — so trash and restore show the same set. Bulk delete follows the same rule: a contract that is not visible comes back as a failed entry with CONTRACT_NOT_FOUND — deliberately the same code as for unknown IDs, so the response does not reveal which other contracts exist.
Critical actions: contracts.delete, contracts.restore and contracts.export are re-checked on every use, so a revoked permission takes effect immediately; denied attempts are logged.
Contract-Asset Linking (user-only)
| Method | Endpoint | Permission |
|---|---|---|
GET | /api/contracts/:id/assets | contracts.viewAll/viewOwn (plus asset visibility per row — otherwise a placeholder) |
POST | /api/contracts/:id/assets | contracts.linkAssets |
PATCH | /api/contracts/:id/assets/:assetId | contracts.linkAssets |
DELETE | /api/contracts/:id/assets/:assetId | contracts.unlinkAssets |
POST | /api/contracts/:id/assets/bulk | contracts.bulkLinkAssets |
The assets tab shows only what the caller may see: Whoever may see the contract does not automatically see its assets. Each row carries either the full asset or a placeholder of id, assetTag, status and restricted: true — for instance for a restricted asset type without a grant. The row stays so the counter is right; name, type, location and assignment are missing, and the row does not lead into the asset detail.
A link has no type of its own; the kind of contract comes from the contract's contractType. isPrimary applies PER ASSET, not per contract: an asset has at most one primary contract — setting a new one automatically demotes the previous. Contracts in CANCELLED or EXPIRED state do not accept new links.
The list of linked assets follows the same visibility rules as the detail view (including the management chain): whoever may not see the contract gets a 404, so its existence is not revealed.
Licenses
| Method | Endpoint | Description | Permission |
|---|---|---|---|
GET | /api/licenses | List (scope) | licenses.viewAll/viewOwn |
GET | /api/licenses/:id | Single license | licenses.viewAll/viewOwn |
GET | /api/licenses/:id/key | License key in plain text (every access is logged) | licenses.viewKeys |
GET | /api/licenses/stats | Statistics (seats, costs) | licenses.viewAll/viewOwn |
GET | /api/licenses?deleted=1 | Trash: deleted licenses (?deleted=1 on the list; trash entries carry deletedAt/deletedBy) | licenses.viewDeleted |
GET | /api/licenses/export | CSV/XLSX/PDF | licenses.export |
POST | /api/licenses | Create | licenses.create |
PATCH | /api/licenses/:id | Update | licenses.update |
DELETE | /api/licenses/:id | Soft delete | licenses.delete |
POST | /api/licenses/:id/restore | Restore | licenses.restore + viewDeleted |
POST | /api/licenses/:id/link-contract | Link to contract | licenses.linkToContract |
POST | /api/licenses/:id/unlink-contract | Unlink from contract | licenses.unlinkFromContract |
POST | /api/licenses/bulk/link-contract | Bulk-Link | licenses.bulkLinkToContract |
POST | /api/licenses/bulk/unlink-contract | Bulk-Unlink | licenses.bulkUnlinkFromContract |
PATCH | /api/licenses/bulk/status | Bulk-Status | licenses.bulkUpdate |
DELETE | /api/licenses/bulk | Bulk-Delete | licenses.delete |
License Assignments (user-only)
| Method | Endpoint | Permission |
|---|---|---|
GET | /api/licenses/:id/assets | licenses.viewAll/viewOwn |
POST | /api/licenses/:id/assets (+ /bulk) | licenses.assignToAsset |
DELETE | /api/licenses/:id/assets/:assetId (+ /bulk) | licenses.unassignFromAsset |
GET | /api/licenses/:id/users | licenses.viewAll/viewOwn |
POST | /api/licenses/:id/users (+ /bulk) | licenses.assignToUser |
DELETE | /api/licenses/:id/users/:userId (+ /bulk) | licenses.unassignFromUser |
Software Catalog
| Method | Endpoint | Permission |
|---|---|---|
GET | /api/software-publishers (+ /search [q/per], /:id) | licenses.viewAll/viewOwn ‖ manage* |
POST/PATCH/DELETE | /api/software-publishers | licenses.managePublishers |
GET | /api/software-products (+ /:id) | licenses.viewAll/viewOwn ‖ manage* |
POST/PATCH/DELETE | /api/software-products | licenses.manageProducts |
Activities
| Method | Endpoint | Description |
|---|---|---|
GET / POST | /api/contracts/:id/activities | Timeline/comment per contract — only for users who may see the contract. Across all records in the audit log. |
GET / POST | /api/licenses/:id/activities | Timeline/comment per license — only for users who may see the license. Across all records in the audit log. |
Besides the field changes, activity entries carry a text key with parameters (bodyKey, bodyParams). The interface builds the text from it in the viewer's language instead of showing a fixed stored sentence.
Response Formats
| Response | Shape |
|---|---|
| Lists (contracts, trash, child contracts, history) | { data, pagination } |
| Sub-lists (linked licenses, linked assets) | { data } |
| Single object and mutations | plain object, no wrapper |
| Delete | 204 without body |
| Bulk operations | { processed, failed, errors[] } — per failed row id, errorCode and message |
| Errors | { error, errorCode, details? } |
The pagination of the contract list carries page, limit, total, totalPages and hasMore; the history instead pages via total, limit and offset. The owner and creator come with ID and name only in the list — the email address is carried by the detail view alone, where the sidebar displays it.
ETag: The contract detail returns an ETag and answers a matching If-None-Match with 304. The value covers not only the contract itself but also its derived counters (licenses, assets, child contracts) — otherwise a freshly linked license would stay invisible in the browser because the contract itself did not change.
Contract Types (contractType)
| Type | Description |
|---|---|
LICENSE_SUBSCRIPTION | Software subscription (Microsoft 365, Adobe CC) |
LICENSE_VOLUME | Volume agreement (e.g., Microsoft EA) |
MAINTENANCE | Hardware maintenance contract |
SUPPORT | Support contract |
SLA | Service Level Agreement |
LEASE | Leasing contract |
OTHER | Other |
Contract Status
| Status | Description |
|---|---|
DRAFT | Draft, not yet active |
ACTIVE | Active and running |
EXPIRED | Expired |
CANCELLED | Cancelled |
SUSPENDED | Suspended |
RENEWED | Renewed |
Allowed Status Transitions
| From | To |
|---|---|
DRAFT | ACTIVE · CANCELLED |
ACTIVE | SUSPENDED · EXPIRED · CANCELLED · RENEWED |
SUSPENDED | ACTIVE · CANCELLED · EXPIRED |
EXPIRED | RENEWED |
RENEWED | ACTIVE · EXPIRED · CANCELLED |
CANCELLED | — (terminal) |
An invalid transition ends with 400 INVALID_CONTRACT_STATUS_TRANSITION. Changing the status needs its own permission: on top of the edit permission it requires contracts.changeStatus — this also applies on creation as soon as a status other than DRAFT (the default) is set. Likewise, setting or changing the owner (ownerId) is bound to contracts.assign.
A contract counts as "expiring soon" when it is ACTIVE and its end date is at most 30 days away — the expiry day itself is included. The list provides the derived field isExpiringSoon for this; the flag ?expiringSoon=true serves as the filter.
License Types (licenseType)
| Type | Description |
|---|---|
PERPETUAL | One-time purchase, perpetual |
SUBSCRIPTION | Subscription (linked to contract) |
VOLUME | Volume licenses |
OEM | Bound to hardware |
SITE | Site license (unlimited users) |
USER | Named user license |
DEVICE | Device license |
CONCURRENT | Concurrent/floating |
TRIAL | Trial |
FREEWARE | Freeware |
OPEN_SOURCE | Open Source |
OTHER | Other |
License keys are only required for PERPETUAL and OEM — subscription/volume are often account-based without a traditional key.
License Status
| Status | Description |
|---|---|
PENDING | Not yet activated (default) |
ACTIVE | Active and usable |
EXPIRED | Expired |
SUSPENDED | Temporarily disabled |
CANCELLED | Cancelled |
RETIRED | Retired |
Costs, Billing & Currency
Contracts: oneTimeCost, recurringCost, totalValue + billingCycle — all amounts are JSON numbers. Licenses: purchasePrice, recurringCost + billingInterval (Int) and billingUnit. costPerSeat is a boolean flag (whether the amount is per seat or total) — NOT the seat price itself.
| Field | Values |
|---|---|
billingCycle (Contract) | MONTHLY, QUARTERLY, SEMI_ANNUALLY, ANNUALLY, BIENNIAL, TRIENNIAL, ONE_TIME, ON_DEMAND |
billingUnit (License) | WEEK, MONTH, YEAR |
billingInterval (License) | Int (e.g., 1 = per billingUnit) |
Total value (totalValue) is not an input field: The server calculates it as one-time costs plus all recurring payments over the contract term (without an end date: projected to one year). A value sent in the request is discarded. Exactly this value appears in the list, detail, key figures and export. It is calculated on create and on every save; a contract with totalValue = null receives it on its next save.
Currency: Neither contract nor license has its own currency field. All amounts are interpreted in the global system currency (general settings: systemCurrency, no conversion); this also applies to exports. See Settings & Global Search API. Cost allocation is done via costCenterId (contract & license) — see Cost Centers API.
AES-256-GCM Encryption (license keys)
License keys are stored encrypted in the database:
• AES-256-GCM = Authenticated encryption (AEAD) - Key: 32 Bytes (256 Bit), hex-encoded (LICENSE_ENCRYPTION_KEY) - IV: 16 Bytes (random per encryption) - AuthTag: 16 Bytes (prevents tampering) Stored in DB: "iv:authTag:encrypted" (hex) Security:• Key never in API response — only licenseKeyMasked• Full key only via GET /:id/key (licenses.viewKeys) + audit log (IP, user agent)
# Generate key (exactly 32 bytes!)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# In docker-compose.yaml:
LICENSE_ENCRYPTION_KEY=46a0bb175f00dadf828a90042bfbb3cada81b385f73162c66059a32035826f0a
CRITICAL: NEVER change LICENSE_ENCRYPTION_KEY after the first licenses — otherwise all existing keys become undecryptable. Key rotation requires re-encrypting all keys.
API Examples
Create Contract
POST /api/contracts
{
"name": "Microsoft 365 Enterprise Agreement 2026",
"contractNumber": "MS-EA-2026-001",
"contractType": "LICENSE_VOLUME",
"status": "ACTIVE",
"vendor": "Microsoft Corporation",
"publisher": "Microsoft",
"startDate": "2026-01-01",
"endDate": "2027-12-31",
"renewalDate": "2027-10-01",
"autoRenew": true,
"noticePeriodDays": 90,
"billingCycle": "ANNUALLY",
"oneTimeCost": 0,
"recurringCost": 450000,
"purchaseOrder": "PO-2026-0123",
"costCenterId": "clx-cost-center-id",
"budgetCode": "CC-IT-001",
"ownerId": "clx-user-id",
"department": "IT",
"tags": ["microsoft", "office365", "enterprise"]
}
Create License (with encryption)
POST /api/licenses
{
"name": "Microsoft 365 E5 - Pool License",
"licenseType": "USER",
"status": "ACTIVE",
"publisherId": "clx-publisher-id",
"productId": "clx-product-id",
"productName": "Microsoft 365 E5",
"productVersion": "2026",
"licenseKey": "XXXXX-YYYYY-ZZZZZ-AAAAA-BBBBB",
"quantityPurchased": 500,
"purchasePrice": 22950.00,
"recurringCost": 22950.00,
"billingInterval": 1,
"billingUnit": "MONTH",
"costPerSeat": false,
"contractId": "clx-contract-id",
"costCenterId": "clx-cost-center-id",
"purchaseDate": "2026-01-15",
"expirationDate": "2027-01-14",
"tags": ["microsoft", "office365", "e5"]
}
Note: The licenseKey is stored AES-256-GCM encrypted. Responses only contain licenseKeyMasked; the full key is returned by GET /api/licenses/:id/key (with audit logging). publisherId/productId reference the software catalog; productName/productVersion are free fields.
Assign License to User
POST /api/licenses/:id/users
{
"userId": "clx-user-id",
"activationDate": "2026-01-28",
"userEmail": "john.doe@company.com",
"userAccount": "john.doe",
"notes": "Assigned for sales team onboarding"
}
Export
GET /api/contracts/export?format=xlsx&f.status=ACTIVE&expiringSoon=true
The export takes over the list's filters and search (formats csv, xlsx, pdf; default csv) and is capped at 10,000 records. Column headers, sheet name as well as PDF title and footer follow the language of the executing account (the user's language, otherwise the installation default); API-key and system exports run in English. Date values in CSV and XLSX are machine-readable as YYYY-MM-DD, the PDF shows them localized. Every export is logged.
Upcoming renewals of contracts AND licenses are shown by the renewal calendar on the reports page. The invest plan there projects the costs of current contracts and licenses, including automatic renewals, over 1–10 years (default 5). Reports API
Notices before expiry are sent by the built-in CronJob "Expiry Monitor" (daily at 07:00, disabled on delivery): 30, 7, 3 and 0 days before the contract end (endDate) or the license expiration date (expirationDate), only once per milestone. For contracts the notice goes to the contract owner (ownerId). CronJobs API →
Parent-Child Hierarchy
Contracts can be nested hierarchically via parentId (e.g. an overarching volume agreement with sub-contracts per product). A contract's children are returned by GET /api/contracts/:id/children; filter via parentId / hasParent.
// 1. Parent contract
POST /api/contracts
{ "name": "Microsoft EA 2026-2028", "contractType": "LICENSE_VOLUME", "status": "ACTIVE", "startDate": "2026-01-01", "endDate": "2028-12-31", "billingCycle": "ANNUALLY", "recurringCost": 500000 }
// 2. Sub-contract (child)
POST /api/contracts
{ "name": "Microsoft 365 E5 Subscription", "contractType": "LICENSE_SUBSCRIPTION", "parentId": "clx-parent-id", "startDate": "2026-01-01", "endDate": "2027-12-31", "billingCycle": "ANNUALLY", "recurringCost": 225000 }
Filtering
Contract-Filters
| Parameter | Description |
|---|---|
f.contractType / f.status / f.billingCycle | Enum filters (eq/neq/in/notIn) |
f.contractNumber / f.name / f.vendor | Text filters (eq/contains/startsWith) |
f.ownerId / f.costCenterId | Owner / cost center (eq/in/isNull/isNotNull) |
f.totalValue / f.recurringCost / f.oneTimeCost | Cost filters (gt/gte/lt/lte/between/isNull/isNotNull) |
f.startDate / f.endDate / f.createdAt / f.updatedAt | Time filters (gt/gte/lt/lte/between/relative) |
f.autoRenew | Auto renewal (true/false) |
q | Search across number, name, vendor, publisher, description |
page / per / sort | Pagination and sorting (per defaults to 25, max 200) |
expiringSoon | true = ACTIVE and end date at most 30 days away |
myTeam / myDepartment | Tab flags: own team resp. own department |
parentId / hasParent | Hierarchy filter |
GET /api/contracts?f.status=in:ACTIVE,SUSPENDED&f.totalValue=gte:10000&f.endDate=relative:next_30_days&sort=endDate:asc&page=1&per=50
A filter has the form f.<field>=<operator>:<value>; without an operator prefix equality applies (f.status=ACTIVE). Multi-value operators take a comma list (in:A,B — commas inside values are encoded), isNull and isNotNull stand without a value. Sorting uses sort=<field>:asc|desc, multi-level via comma.
List and export accept only page, per, q and sort for pagination, search and sorting; limit, offset, search, sortBy and sortDirection are rejected with 400 LEGACY_QUERY_PARAM_REMOVED. The trash is the same list with deleted=1; filters, search, sorting and saved views apply there too. The child-contract listing is simpler: it accepts limit/offset and sortBy from a predefined field list (name, contractNumber, contractType, status, startDate, endDate, updatedAt; default startDate descending), but no saved views. Unknown values are rejected with 400.
License-Filters
| Parameter | Description |
|---|---|
f.licenseType | PERPETUAL, SUBSCRIPTION, VOLUME, OEM, SITE, USER, DEVICE, CONCURRENT, TRIAL, FREEWARE, OPEN_SOURCE, OTHER (eq/neq/in/notIn) |
f.status | PENDING, ACTIVE, EXPIRED, SUSPENDED, CANCELLED, RETIRED (eq/neq/in/notIn) |
f.publisherId / f.productId / f.contractId | Publisher / product / contract (eq/in/isNull/isNotNull) |
f.name | Text filter (eq/contains/startsWith) |
f.quantityPurchased / f.quantityUsed / f.purchasePrice / f.recurringCost | Quantity and cost filters (gt/gte/lt/lte/between) |
f.expirationDate / f.createdAt / f.updatedAt | Time filters (gt/gte/lt/lte/between/relative) |
q | Search across name, description, serial number, product and publisher name |
page / per / sort | Pagination and sorting |
overAssigned / expiringSoon / myTeam / myDepartment | Flags (true): over-assigned licenses, expiring soon, own team, own department |
Permissions
| contracts.* | licenses.* |
|---|---|
viewAll, viewOwn, viewDeleted, viewHistory | viewAll, viewOwn, viewDeleted, viewHistory, viewKeys |
create, editAll, editOwn, delete, restore | create, update, delete, restore |
changeStatus, assign, bulkUpdate, export, reporting | bulkUpdate, export, reporting |
linkAssets, unlinkAssets, bulkLinkAssets | assignToAsset, unassignFromAsset, assignToUser, unassignFromUser |
| — | linkToContract, unlinkFromContract, bulkLinkToContract, bulkUnlinkFromContract, managePublishers, manageProducts |
Error Handling
| Error Code | HTTP | Description |
|---|---|---|
CONTRACT_NOT_FOUND | 404 | Contract does not exist — or is not visible to the caller (delete, history, assets listing) |
CONTRACT_NUMBER_CONFLICT | 409 | Contract number already exists |
CONTRACT_VERSION_CONFLICT | 409 | Concurrent modification by someone else (version is mandatory on update) |
INVALID_CONTRACT_STATUS_TRANSITION | 400 | Status transition not allowed by the matrix |
CONTRACT_HAS_LINKED_ASSETS · CONTRACT_HAS_LINKED_LICENSES | 409 | Contract still carries links |
CONTRACT_CIRCULAR_REFERENCE · CONTRACT_MAX_DEPTH | 400 | Hierarchy: cycle resp. maximum nesting exceeded |
CONTRACT_INACTIVE_STATUS | 400 | Linking to a cancelled or expired contract |
ASSET_CONTRACT_LINK_EXISTS | 409 | Asset is already linked to this contract |
ASSET_CONTRACT_LINK_NOT_FOUND | 404 | Link does not exist |
LEGACY_QUERY_PARAM_REMOVED | 400 | limit/offset on the contract list (page/per apply there) |
LICENSE_NOT_FOUND | 404 | License does not exist |
LICENSE_VERSION_CONFLICT | 409 | Concurrent modification by someone else |
LICENSE_HAS_ASSIGNMENTS | 409 | License has assignments (remove first) |
SESSION_ONLY | 403 | Action requires user context (no API key) |
Attachments
Contracts & licenses use the central attachment system (signed contracts, amendments, invoices):
POST /api/attachments/CONTRACT/:contractId
POST /api/attachments/LICENSE/:licenseId
GET /api/attachments/CONTRACT/:contractId
GET /api/attachments/LICENSE/:licenseId
Details: See Attachments & File Settings API for virus scanning, file settings and retention periods.
Cost Centers API → Cost centers for cost allocation of contracts & licenses.