Eviworx
Docs

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.

🚀
Features
✓ Encrypted license keys (AES-256-GCM)
✓ Multi-year cost plan (invest plan, 1–10 years)
✓ Seat management with over-allocation detection
✓ Expiry notices via CronJob (30/7/3/0 days)
✓ Contract hierarchy (parentId)
✓ Asset, user and contract links (incl. bulk)
✓ Software catalog (publishers & products)
✓ Export as CSV, XLSX, PDF (logged)
✓ Optimistic locking (409 on conflict)
✓ Change history in the viewer’s language

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

MethodEndpointDescriptionPermission
GET/api/contractsPaginated list ({data, pagination})contracts.viewAll/viewOwn
GET/api/contracts/:idSingle contract (plain object, with ETag)contracts.viewAll/viewOwn
GET/api/contracts/statsStatistics (counts per status/type, expiry, volume)contracts.viewAll/viewOwn
GET/api/contracts?deleted=1Trash — 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/childrenChild contracts ({data, pagination})contracts.viewAll/viewOwn
GET/api/contracts/:id/licensesLinked licenses ({data})contracts.viewAll/viewOwn
GET/api/contracts/:id/activitiesContract history ({data, pagination}, ?limit/?offset)contracts.viewHistory
POST/api/contracts/:id/activitiesAdd comment → 201 (user-only)contracts.editAll/editOwn
GET/api/contracts/exportCSV/XLSX/PDFcontracts.export
POST/api/contractsCreate → 201contracts.create
PATCH/api/contracts/:idUpdate (editAll or editOwn; status and owner need their own right; version mandatory)contracts.editAll/editOwn
DELETE/api/contracts/:idSoft delete → 204contracts.delete
POST/api/contracts/:id/restoreRestorecontracts.restore + viewDeleted
PATCH/api/contracts/bulk/statusBulk status (user-only, max. 100)contracts.bulkUpdate
DELETE/api/contracts/bulkBulk 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)

MethodEndpointPermission
GET/api/contracts/:id/assetscontracts.viewAll/viewOwn (plus asset visibility per row — otherwise a placeholder)
POST/api/contracts/:id/assetscontracts.linkAssets
PATCH/api/contracts/:id/assets/:assetIdcontracts.linkAssets
DELETE/api/contracts/:id/assets/:assetIdcontracts.unlinkAssets
POST/api/contracts/:id/assets/bulkcontracts.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

MethodEndpointDescriptionPermission
GET/api/licensesList (scope)licenses.viewAll/viewOwn
GET/api/licenses/:idSingle licenselicenses.viewAll/viewOwn
GET/api/licenses/:id/keyLicense key in plain text (every access is logged)licenses.viewKeys
GET/api/licenses/statsStatistics (seats, costs)licenses.viewAll/viewOwn
GET/api/licenses?deleted=1Trash: deleted licenses (?deleted=1 on the list; trash entries carry deletedAt/deletedBy)licenses.viewDeleted
GET/api/licenses/exportCSV/XLSX/PDFlicenses.export
POST/api/licensesCreatelicenses.create
PATCH/api/licenses/:idUpdatelicenses.update
DELETE/api/licenses/:idSoft deletelicenses.delete
POST/api/licenses/:id/restoreRestorelicenses.restore + viewDeleted
POST/api/licenses/:id/link-contractLink to contractlicenses.linkToContract
POST/api/licenses/:id/unlink-contractUnlink from contractlicenses.unlinkFromContract
POST/api/licenses/bulk/link-contractBulk-Linklicenses.bulkLinkToContract
POST/api/licenses/bulk/unlink-contractBulk-Unlinklicenses.bulkUnlinkFromContract
PATCH/api/licenses/bulk/statusBulk-Statuslicenses.bulkUpdate
DELETE/api/licenses/bulkBulk-Deletelicenses.delete

License Assignments (user-only)

MethodEndpointPermission
GET/api/licenses/:id/assetslicenses.viewAll/viewOwn
POST/api/licenses/:id/assets (+ /bulk)licenses.assignToAsset
DELETE/api/licenses/:id/assets/:assetId (+ /bulk)licenses.unassignFromAsset
GET/api/licenses/:id/userslicenses.viewAll/viewOwn
POST/api/licenses/:id/users (+ /bulk)licenses.assignToUser
DELETE/api/licenses/:id/users/:userId (+ /bulk)licenses.unassignFromUser

Software Catalog

MethodEndpointPermission
GET/api/software-publishers (+ /search [q/per], /:id)licenses.viewAll/viewOwn ‖ manage*
POST/PATCH/DELETE/api/software-publisherslicenses.managePublishers
GET/api/software-products (+ /:id)licenses.viewAll/viewOwn ‖ manage*
POST/PATCH/DELETE/api/software-productslicenses.manageProducts

Activities

MethodEndpointDescription
GET / POST/api/contracts/:id/activitiesTimeline/comment per contract — only for users who may see the contract. Across all records in the audit log.
GET / POST/api/licenses/:id/activitiesTimeline/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

ResponseShape
Lists (contracts, trash, child contracts, history){ data, pagination }
Sub-lists (linked licenses, linked assets){ data }
Single object and mutationsplain object, no wrapper
Delete204 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)

TypeDescription
LICENSE_SUBSCRIPTIONSoftware subscription (Microsoft 365, Adobe CC)
LICENSE_VOLUMEVolume agreement (e.g., Microsoft EA)
MAINTENANCEHardware maintenance contract
SUPPORTSupport contract
SLAService Level Agreement
LEASELeasing contract
OTHEROther

Contract Status

StatusDescription
DRAFTDraft, not yet active
ACTIVEActive and running
EXPIREDExpired
CANCELLEDCancelled
SUSPENDEDSuspended
RENEWEDRenewed

Allowed Status Transitions

FromTo
DRAFTACTIVE · CANCELLED
ACTIVESUSPENDED · EXPIRED · CANCELLED · RENEWED
SUSPENDEDACTIVE · CANCELLED · EXPIRED
EXPIREDRENEWED
RENEWEDACTIVE · 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)

TypeDescription
PERPETUALOne-time purchase, perpetual
SUBSCRIPTIONSubscription (linked to contract)
VOLUMEVolume licenses
OEMBound to hardware
SITESite license (unlimited users)
USERNamed user license
DEVICEDevice license
CONCURRENTConcurrent/floating
TRIALTrial
FREEWAREFreeware
OPEN_SOURCEOpen Source
OTHEROther

License keys are only required for PERPETUAL and OEM — subscription/volume are often account-based without a traditional key.

License Status

StatusDescription
PENDINGNot yet activated (default)
ACTIVEActive and usable
EXPIREDExpired
SUSPENDEDTemporarily disabled
CANCELLEDCancelled
RETIREDRetired

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.

FieldValues
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

ParameterDescription
f.contractType / f.status / f.billingCycleEnum filters (eq/neq/in/notIn)
f.contractNumber / f.name / f.vendorText filters (eq/contains/startsWith)
f.ownerId / f.costCenterIdOwner / cost center (eq/in/isNull/isNotNull)
f.totalValue / f.recurringCost / f.oneTimeCostCost filters (gt/gte/lt/lte/between/isNull/isNotNull)
f.startDate / f.endDate / f.createdAt / f.updatedAtTime filters (gt/gte/lt/lte/between/relative)
f.autoRenewAuto renewal (true/false)
qSearch across number, name, vendor, publisher, description
page / per / sortPagination and sorting (per defaults to 25, max 200)
expiringSoontrue = ACTIVE and end date at most 30 days away
myTeam / myDepartmentTab flags: own team resp. own department
parentId / hasParentHierarchy 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

ParameterDescription
f.licenseTypePERPETUAL, SUBSCRIPTION, VOLUME, OEM, SITE, USER, DEVICE, CONCURRENT, TRIAL, FREEWARE, OPEN_SOURCE, OTHER (eq/neq/in/notIn)
f.statusPENDING, ACTIVE, EXPIRED, SUSPENDED, CANCELLED, RETIRED (eq/neq/in/notIn)
f.publisherId / f.productId / f.contractIdPublisher / product / contract (eq/in/isNull/isNotNull)
f.nameText filter (eq/contains/startsWith)
f.quantityPurchased / f.quantityUsed / f.purchasePrice / f.recurringCostQuantity and cost filters (gt/gte/lt/lte/between)
f.expirationDate / f.createdAt / f.updatedAtTime filters (gt/gte/lt/lte/between/relative)
qSearch across name, description, serial number, product and publisher name
page / per / sortPagination and sorting
overAssigned / expiringSoon / myTeam / myDepartmentFlags (true): over-assigned licenses, expiring soon, own team, own department

Permissions

contracts.*licenses.*
viewAll, viewOwn, viewDeleted, viewHistoryviewAll, viewOwn, viewDeleted, viewHistory, viewKeys
create, editAll, editOwn, delete, restorecreate, update, delete, restore
changeStatus, assign, bulkUpdate, export, reportingbulkUpdate, export, reporting
linkAssets, unlinkAssets, bulkLinkAssetsassignToAsset, unassignFromAsset, assignToUser, unassignFromUser
linkToContract, unlinkFromContract, bulkLinkToContract, bulkUnlinkFromContract, managePublishers, manageProducts

Error Handling

Error CodeHTTPDescription
CONTRACT_NOT_FOUND404Contract does not exist — or is not visible to the caller (delete, history, assets listing)
CONTRACT_NUMBER_CONFLICT409Contract number already exists
CONTRACT_VERSION_CONFLICT409Concurrent modification by someone else (version is mandatory on update)
INVALID_CONTRACT_STATUS_TRANSITION400Status transition not allowed by the matrix
CONTRACT_HAS_LINKED_ASSETS · CONTRACT_HAS_LINKED_LICENSES409Contract still carries links
CONTRACT_CIRCULAR_REFERENCE · CONTRACT_MAX_DEPTH400Hierarchy: cycle resp. maximum nesting exceeded
CONTRACT_INACTIVE_STATUS400Linking to a cancelled or expired contract
ASSET_CONTRACT_LINK_EXISTS409Asset is already linked to this contract
ASSET_CONTRACT_LINK_NOT_FOUND404Link does not exist
LEGACY_QUERY_PARAM_REMOVED400limit/offset on the contract list (page/per apply there)
LICENSE_NOT_FOUND404License does not exist
LICENSE_VERSION_CONFLICT409Concurrent modification by someone else
LICENSE_HAS_ASSIGNMENTS409License has assignments (remove first)
SESSION_ONLY403Action 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.
Next Step

Cost Centers API → Cost centers for cost allocation of contracts & licenses.