Assets API
The Assets API manages assets with types, categories and locations, checkout and handover with a QR/PDF record, CMDB relations, type-specific permissions, duplicate detection for manufacturer/model and inventory scanning.
Authentication & Permissions
All asset endpoints accept a session (user) or an X-API-Key header (API key with an active role). In both cases the role's permissions apply. See User Management & RBAC and Authentication API.
| Area | Permission keys (feature.action) |
|---|---|
| Read | assets.viewAll, assets.viewOwn, assets.viewDeleted (trash), assets.viewHistory |
| Mutate | assets.create, assets.update, assets.delete, assets.restore, assets.checkout, assets.checkin |
| Bulk / Export | assets.bulkEdit, assets.bulkDelete, assets.export |
| Reporting | assets.reporting (use asset and handover data in reports) |
| Labels / Scan | assets.generateLabel, assets.scan |
| Master data | assets.manageTypes, assets.manageCategories, assets.manageLocations, assets.manageClusters, assets.manageTypePermissions |
| Handover | assets.viewAllHandovers, assets.viewOwnHandovers, assets.initiateHandover, assets.confirmReceipt, assets.requestReturn, assets.managePolicies |
Type lock: In addition to the global permissions, an asset type can be locked. For locked types the per-role or per-user grants apply (the assigned user always sees their asset); for open types only the global permissions. Lists therefore contain only the assets the caller may see.
The same visibility check precedes EVERY mutation: update, delete, check-out/check-in, install/deinstall, label and barcode actions answer 403 if the asset is not visible to the caller — a global action permission alone is not enough. The trash requires two permissions: assets.viewDeleted to see it and assets.restore to restore from it.
Critical permissions (re-checked on every request, so a revocation takes effect immediately; denied attempts are logged): assets.export, assets.managePolicies, assets.manageClusters and assets.manageTypePermissions.
Response formats: Every list in this domain returns { data } resp. { data, pagination } — assets, types, categories, locations, handovers and activities. Single objects come without a wrapper. Amounts (purchasePrice, depreciationRate) are JSON numbers. Counters such as assetCount are counted live.
Read scope follows the permission: without visibility of the individual asset the scan returns base data only (tag, name, status, type, location) — an inventory still works, but serial number and assignment stay out. The type list shows the display fields to everyone; customFieldSchema, hasTypePermissions and assetCount only with viewAll, manageTypes or manageTypePermissions. Categories and locations require viewAll or the respective manage permission. The global activity history only shows assets the reader may see.
Endpoints Overview
Core CRUD
| Method | Endpoint | Description |
|---|---|---|
GET | /api/assets | List all assets (with filters); ?deleted=1 = trash (permission assets.viewDeleted) |
GET | /api/assets/:id | Get single asset |
POST | /api/assets | Create asset |
PATCH | /api/assets/:id | Update asset |
DELETE | /api/assets/:id | Delete asset (soft-delete) |
Handover (QR/PDF)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/assets/handovers | All handovers (IT view) |
GET | /api/assets/handovers/pending | Pending handovers for current user |
POST | /api/assets/handovers | Create new handover (checkout/transfer) |
POST | /api/assets/handovers/:id/accept | Accept handover |
POST | /api/assets/handovers/:id/reject | Reject handover |
PATCH | /api/assets/handovers/:id/expires-at | Change the return date of a checkout |
GET | /api/assets/handovers/:id/pdf | Generate PDF protocol |
GET | /api/handovers/:id/public | Public QR access (no auth, HMAC token) |
One operation per asset: Concurrent operations on the same asset are mutually exclusive — check-out, check-in, creating a handover, requesting a return, installing, deinstalling and the component take-overs all claim the asset. The second call gets 409 — so the same device is never issued to two different recipients. Also 409: a second handover on an asset that already has an open one. Database lock conflicts report 409 DEADLOCK_DETECTED.
Handovers you cannot see: Detail, PDF and return date answer 404 when the handover is not visible to the caller — the same answer as for a made-up ID, so its existence is not revealed. Whoever may see but not modify it gets a 403.
Public protocol: The QR/share link leads to a data-minimized version: it names the parties involved but no email addresses — neither in the JSON response nor in the PDF. The internal PDF stays complete. The footer of both PDFs shows the handover number (HO-00042), not the internal database ID.
Model Clustering
| Method | Endpoint | Description |
|---|---|---|
GET | /api/asset-model-clusters | All clusters (duplicate candidates) |
GET | /api/asset-model-clusters/stats | Cluster statistics |
PATCH | /api/asset-model-clusters/:id/canonical | Set canonical values |
POST | /api/asset-model-clusters/:id/approve | Approve cluster (ready to merge) |
POST | /api/asset-model-clusters/:id/merge | Merge cluster (deduplicate) |
POST | /api/asset-model-clusters/:id/reject | Reject cluster (not duplicate) |
DELETE | /api/asset-model-clusters/:id | Delete cluster |
Relations & Inventory
| Method | Endpoint | Description |
|---|---|---|
POST | /api/asset-relations | Create CMDB relation |
DELETE | /api/asset-relations/:id | Delete relation |
GET | /api/asset-relations/asset/:id | All relations of an asset |
GET | /api/asset-relations/asset/:id/graph | CMDB graph (visualization) |
GET | /api/asset-relations/types | Available relation types |
Advanced Operations
| Method | Endpoint | Description |
|---|---|---|
GET | /api/assets/stats | Asset statistics |
GET | /api/assets/export | CSV/PDF export |
PATCH | /api/assets/bulk | Bulk update (multiple assets) |
DELETE | /api/assets/bulk | Bulk delete (soft-delete) |
POST | /api/assets/:id/restore | Restore deleted asset (requires assets.restore AND assets.viewDeleted) |
POST | /api/assets/:id/checkout | Checkout asset (assign to user) |
POST | /api/assets/:id/checkin | Checkin asset (return) |
POST | /api/assets/:id/install | Install LOCATION asset (→ location) |
POST | /api/assets/:id/deinstall | Deinstall LOCATION asset |
GET | /api/assets/:id/label | Generate QR label (PDF) |
GET | /api/assets/:id/damage-report | Damage report |
GET | /api/assets/my-consumables | My consumables |
GET | /api/assets/suggestions | Auto-complete suggestions |
GET | /api/asset-activities | Activities of all visible assets |
Attachments follow their asset: When an asset is deleted its attachments move into the trash with it, and restoring brings them back — attachments deleted individually beforehand stay deleted. Both happen together or not at all; there is no state in which the asset is still there but its attachments are gone. Bulk delete behaves exactly like deleting a single asset. Details on retention and virus scanning on the Attachments & File Settings API.
Response of the asset bulk actions: The two bulk endpoints answer in a shape of their own and carry no errors list. The response names success, updatedCount resp. deletedCount, assetIds with the assets actually changed resp. deleted, plus deniedCount and deniedAssets; each entry there carries id, assetTag, name and a reason. Only what fails on the asset type permission is skipped — if not a single asset of the selection is allowed, the call answers 403. Business obstacles, by contrast, abort the whole call instead of reporting individual rows: an unknown or already deleted ID (404 ASSET_NOT_FOUND), an active handover record when changing status or assignment (400 BULK_PENDING_HANDOVER_BLOCKED), existing CMDB relations (409 ASSET_HAS_RELATIONS), and on deletion still-assigned assets, open consumable issues, contract or license links (400 BULK_DELETE_BLOCKED, details.blockedAssets names them). The general shape of bulk actions is described in the API Overview.
Inventory Sessions
Stocktakes run through dedicated sessions under /api/inventory-sessions: starting one takes a snapshot of the expected assets, then items are recorded via QR/barcode and finally reconciled against that snapshot. They carry their own permissions (inventory.*), a due date with reminders and a trash. Endpoints, fields and permissions live in full on their own page: Inventory API.
The inventory routes never move assets themselves — actions on scanned, unexpected or missing devices run through the asset endpoints on this page (checkout, checkin, install, deinstall) and through the assets.* permissions.
Asset Types (CRUD + Policies)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/asset-types | All asset types |
POST | /api/asset-types | Create asset type (incl. policies) |
PATCH | /api/asset-types/:id | Update asset type |
DELETE | /api/asset-types/:id | Delete asset type |
GET | /api/asset-types/:id/permissions | Get type permissions |
PUT | /api/asset-types/:id/permissions/users/:userId | Set user permission |
PUT | /api/asset-types/:id/permissions/roles/:roleId | Set role permission |
Asset Type Fields (POST/PATCH)
| Field | Typ | Description |
|---|---|---|
name / displayName | String | Internal name (unique) / display name |
trackingMode | enum (default PERSON) | PERSON | LOCATION | CONSUMABLE. Determines among other things whether the type is a consumable. Immutable once the type has assets → otherwise HTTP 409. |
standalone | boolean (default true) | false = built-in component (RAM/SSD): no standalone checkout/handover, follows its container via co-move. CONSUMABLE is always standalone (enforced). Freely toggleable (audited). |
requiresConfirmation | boolean (default false) | Handover with recipient confirmation as the type default. |
hasTypePermissions | Boolean | Type lock: enables type-specific permissions (see below). |
icon / color / customFieldSchema | String / JSONB | UI icon, color, custom field schema (JSON). |
Mode semantics (anchor, actions, status sets) see Asset Lifecycle.
Categories & Locations (CRUD)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/asset-categories | All categories incl. hierarchy (parentId); permission: assets.viewAll or manageCategories |
POST | /api/asset-categories | Create category |
PATCH | /api/asset-categories/:id | Update category |
DELETE | /api/asset-categories/:id | Delete category |
GET | /api/asset-locations | All locations incl. hierarchy (parentId); permission: assets.viewAll or manageLocations |
POST | /api/asset-locations | Create location |
PATCH | /api/asset-locations/:id | Update location |
DELETE | /api/asset-locations/:id | Delete location |
Asset Status
There are 11 lifecycle statuses. Full semantics, status groups, anchor rules and the transition matrix are on the Asset Lifecycle page.
| Status | Description |
|---|---|
ORDERED | Ordered, not yet delivered |
RECEIVED | Received, not yet ready |
AVAILABLE | Available/ready (stock), freely assignable |
RESERVED | Reserved — not yet handed out |
PENDING_ACCEPTANCE | Person handover running, awaiting recipient confirmation |
IN_USE | In use — PERSON: with the user / LOCATION: installed at the location |
MAINTENANCE | In maintenance/repair (anchor may remain) |
RETURN_PENDING | Return running, awaiting IT confirmation |
RETIRED | Retired (reactivatable) |
LOST | Lost/stolen (mandatory reason in statusNote) |
DISPOSED | Disposed/sold (final) |
Create Asset
Regular Asset (e.g., Laptop)
POST /api/assets
{
"name": "Dell XPS 15",
"description": "Developer laptop with 32GB RAM",
"serialNumber": "SN123456789",
"manufacturer": "Dell",
"model": "XPS 15 9520",
"typeId": "clx-laptop-type",
"categoryId": "clx-hardware-category",
"locationId": "clx-office-munich",
"status": "RECEIVED",
"criticality": "HIGH",
"purchaseDate": "2026-01-15",
"purchasePrice": 2499.00,
"purchaseOrder": "PO-2026-001",
"vendor": "Dell Direct",
"costCenterId": "clx-cost-center-id",
"warrantyEnd": "2029-01-15",
"usefulLifeMonths": 36,
"depreciationMethod": "LINEAR",
"customFields": {
"ramGB": 32,
"storageGB": 1024,
"cpu": "Intel i7-12700H",
"display": "15.6\" 4K OLED"
},
"tags": ["developer", "high-performance", "mobile"]
}
Consumable (Bulk Item, e.g., USB Cables)
A consumable is created by an asset type with trackingMode=CONSUMABLE (a property of the type, not of the individual asset). The asset carries quantity/minQuantity; distribution runs via ConsumableAssignment, not status/assignee.
{
"name": "USB-C to USB-A Cable (1m)",
"typeId": "clx-consumable-type",
"categoryId": "clx-cables-category",
"status": "RECEIVED",
"quantity": 50,
"minQuantity": 10,
"purchasePrice": 5.99,
"purchaseOrder": "PO-2026-002",
"tags": ["cable", "usb-c", "consumable"]
}
Consumables: The type (trackingMode=CONSUMABLE) tracks quantity; serialNumber is optional. Handout decrements quantity via ConsumableAssignment (user OR location) instead of changing status/assignee. Allowed statuses: ORDERED, RECEIVED, AVAILABLE, RETIRED, DISPOSED. No handover, no CMDB relations.
Response (201 Created)
{
"id": "clx...",
"assetTag": "00042",
"name": "Dell XPS 15",
"serialNumber": "SN123456789",
"manufacturer": "Dell",
"model": "XPS 15 9520",
"status": "RECEIVED",
"type": {
"id": "clx...",
"name": "Laptop",
"displayName": "Laptop"
},
"category": {
"id": "clx...",
"name": "Hardware",
"color": "#3b82f6"
},
"location": {
"id": "clx...",
"name": "Munich Office - 2nd Floor"
},
"createdAt": "2026-01-27T17:00:00.000Z"
}
QR/PDF Handover Workflow
Handover is the formal handout path with recipient confirmation (→ PENDING_ACCEPTANCE), QR code, PDF protocol and email. It applies ONLY to trackingMode=PERSON (LOCATION uses install, CONSUMABLE has no handover) and is subject to the same checks as checkout/checkin. Accept → IN_USE; reject or cancel restores the previous state. Direct checkout (see below) is the fast path without confirmation.
Step 1: Create Handover (IT)
POST /api/assets/handovers
{
"recipientId": "clx-user-id",
"assetIds": ["clx-asset1", "clx-asset2"],
"quantities": { "clx-asset1": 1 },
"note": "New laptop for developer onboarding",
"expiresAt": "2026-12-31T23:59:59Z"
}
Handover type & policy: The handover type (CHECKOUT_WITH_CONFIRMATION, CHECKOUT_DIRECT, RETURN, RETURN_DIRECT) and any required policy acceptance are derived server-side from the asset/type configuration — not set in the request. quantities is optional (consumables/bulk items).
Response (201 Created)
{
"id": "clx...",
"handoverNumber": "HO-00042",
"type": "CHECKOUT_WITH_CONFIRMATION",
"status": "PENDING",
"qrUrl": "https://your-domain.com/handover/clx.../verify?token=abc123...",
"pdfUrl": "/api/assets/handovers/clx.../pdf",
"initiatedBy": { "name": "IT Admin" },
"recipient": { "name": "John Doe", "email": "john@example.com" },
"items": [
{
"asset": {
"assetTag": "00042",
"name": "Dell XPS 15",
"serialNumber": "SN123456789"
}
}
],
"initiatedAt": "2026-01-27T17:00:00.000Z"
}
Step 2: User Scans QR Code
The QR code contains a URL with HMAC token for secure access WITHOUT authentication:
# PUBLIC endpoint (no JWT needed!)
GET /api/handovers/:id/public?token=HMAC_TOKEN
{
"handoverNumber": "HO-00042",
"type": "CHECKOUT_WITH_CONFIRMATION",
"status": "PENDING",
"initiatedBy": { "name": "IT Admin" },
"recipient": { "name": "John Doe" },
"items": [
{
"asset": {
"assetTag": "00042",
"name": "Dell XPS 15",
"serialNumber": "SN123456789",
"type": { "name": "Laptop" }
}
}
],
"policies": [
{ "id": "clx...", "version": 3, "title": "Laptop Usage Policy", "acceptedAt": "2026-01-27T17:15:00.000Z" }
],
"companyName": "Your Company"
}
Security: The public endpoint returns NO internal IDs, email addresses or sensitive data. Only minimal information for verification.
Step 3: User Accepts Handover
POST /api/assets/handovers/:id/accept
{
"note": "Asset received in good condition. All components present.",
"policyAccepted": true,
"policyLinkOpened": true
}
Response
{
"id": "clx...",
"handoverNumber": "HO-00042",
"status": "CONFIRMED",
"confirmedAt": "2026-01-27T17:15:00.000Z",
"confirmedBy": { "name": "John Doe" },
"recipientNote": "Asset received in good condition..."
}
Policies are acknowledged per version: A handover can contain devices of several asset types — the confirmation therefore acknowledges every active policy of the involved types. The responses carry policies[] for that (lists: id, version, title, acceptedAt; pending handovers and the detail additionally the full text and the recipient's acknowledgement state per version). Protocol, PDF and the public page list all acknowledged policies with version and timestamp. Versions the same recipient already acknowledged do not have to be ticked again.
When a new version of a policy is published later, the recipients of running handovers of that type are notified (in-app and email). The open versions come from GET /api/assets/handovers/policy-updates; they are acknowledged through POST /api/assets/handovers/:id/reaccept-policy — which requires assets.confirmReceipt, applies only to confirmed handovers and acknowledges all open versions in one go. Which version applies is decided solely by the server.
Automatically: Asset status transitions to IN_USE. assignedToId (recipient) stays set, deployedAt is set.
Step 4: Generate PDF Protocol
GET /api/assets/handovers/:id/pdf
Generates PDF protocol with QR code, asset details, signatures (digital), policy text. The expected return date comes from the record itself — a date changed later on the asset does not alter an older record.
Change the return date
PATCH /api/assets/handovers/:id/expires-at
{
"expiresAt": "2027-06-30T12:00:00Z",
"reason": "Projektlaufzeit verlaengert"
}
Applies to checkout records with status PENDING or ACCEPTED. expiresAt=null makes the checkout open-ended; a date must lie in the future (otherwise 400 VALIDATION_ERROR). reason is optional. On a confirmed checkout the date also moves to the assets involved (expectedCheckinAt), open reminders for the old date are dropped and the recipient is notified (in-app and email).
A return record has no return date — there the call answers 400 HANDOVER_EXPIRY_NOT_APPLICABLE; any status other than PENDING or ACCEPTED yields 409 HANDOVER_ALREADY_PROCESSED. The call is allowed with assets.initiateHandover or — for locked types — with the type-level grant for initiating handovers, for every asset type involved; not only the original issuer may change it.
In the asset history the change appears as a single value pair "expected return date: old → new" in the user's format, plus the handover number and — if given — the reason. The audit entry carries the same value pair.
Model Clustering (Duplicate Detection)
Eviworx automatically detects duplicates in manufacturer/model spellings and suggests cleanup.
How Does Clustering Work?
- Detection: CronJob runs periodically (e.g., daily)
- Analysis: Similar manufacturer/model combinations found (string similarity)
- Cluster: Variants grouped (e.g., "DELL", "Dell", "dell")
- Review: Admin reviews cluster and sets canonical values
- Merge: All assets in cluster get canonical values
Get Clusters
GET /api/asset-model-clusters?status=PENDING
Response
{
"data": [
{
"id": "clx...",
"canonicalManufacturer": null,
"canonicalModel": null,
"variants": [
{ "manufacturer": "DELL", "model": "XPS 15", "count": 15 },
{ "manufacturer": "Dell", "model": "XPS 15", "count": 23 },
{ "manufacturer": "dell", "model": "xps 15", "count": 5 }
],
"assetCount": 43,
"variantCount": 3,
"status": "PENDING",
"detectedAt": "2026-01-27T08:00:00.000Z"
}
],
"pagination": { "page": 1, "limit": 25, "total": 12, "totalPages": 1, "hasMore": false }
}
Counters per status come from GET /api/asset-model-clusters/stats as an object status → { count, assetCount }.
Set Canonical Values
PATCH /api/asset-model-clusters/:id/canonical
{
"canonicalManufacturer": "Dell",
"canonicalModel": "XPS 15"
}
Merge Cluster
POST /api/asset-model-clusters/:id/merge
Automatically: All 43 assets get manufacturer="Dell" and model="XPS 15". Cluster status → MERGED.
Reject Cluster (not duplicate)
POST /api/asset-model-clusters/:id/reject
Cluster status → REJECTED. The cluster then does not appear among the open duplicate candidates.
CMDB Relations, Graph & Impact
Assets are linked as configuration items via typed relations. Each relation is stored once and is visible from both assets; a relation that already exists in the reverse direction is rejected as a duplicate.
Endpoints
| Method | Endpoint | Description |
|---|---|---|
POST | /api/asset-relations | Create relation |
DELETE | /api/asset-relations/:relationId | Delete relation |
GET | /api/asset-relations/asset/:assetId | Relations of an asset |
GET | /api/asset-relations/asset/:assetId/graph?depth=1..3 | Transitive relation graph |
GET | /api/asset-relations/types | Available relation types |
GET | /api/assets/:id/impact?targetStatus=X | Impact/dependency analysis (read-only) |
POST | /api/assets/:id/impact/apply | Guided co-update of neighbors |
GET | /api/assets/:id/impact/recovery | Recovery when maintenance ends |
POST | /api/assets/impact/batch | Aggregated impact over a bulk selection |
GET | /api/assets/:id/containment | Built-in components (transitive) |
Relation Types
CONNECTED_TO– Bidirectional: physically connected (laptop ↔ monitor)INSTALLED_ON– Software on hardwarePART_OF– Component is part of (RAM → server)DEPENDS_ON– Functional dependency (VM → host)DOCKING_STATION– Bidirectional: laptop dockingBACKUP_OF– Backup/redundancy relationshipREPLACES– Replaces (hardware swap)OTHER– Other, free text
Create Relation
POST /api/asset-relations
{
"parentAssetId": "clx-laptop-id",
"childAssetId": "clx-monitor-id",
"relationType": "CONNECTED_TO",
"description": "DisplayPort cable"
}
First the server checks that the caller may see and edit both assets, only then the business rules — so error messages reveal nothing about assets the caller cannot see. An audit entry is written for each of the two assets. Rejected are: self-reference, duplicate (also reverse), deleted ends, CONSUMABLE ends (a consumable is not a CI) and containment cycles (PART_OF/INSTALLED_ON in the reverse direction → ASSET_RELATION_CYCLE, HTTP 409).
Relation Graph
GET /api/asset-relations/asset/:assetId/graph?depth=1..3
Relation graph across 1–3 levels, arranged by level. Assets the caller may not see appear without details. Creating and deleting relations in the UI is available in the desktop view only.
Impact/Dependency Awareness
On an asset status change the impact analysis shows the transitively affected neighbors — respecting direction and relation type (PART_OF works in one direction only), up to 10 levels deep; assets the caller cannot see appear without details. Affected neighbors are only changed when confirmed in the apply step.
GET /api/assets/:id/impact?targetStatus=MAINTENANCE
{
"impacted": [
{ "assetId": "clx...", "assetTag": "00099", "name": "App-Server", "distance": 1, "propagates": true }
],
"counts": { "total": 1, "propagating": 1, "hints": 0, "redacted": 0 }
}
Guided co-update (the edit permission applies per asset; individual assets may fail without blocking the rest):
POST /api/assets/:id/impact/apply
{
"items": [ { "assetId": "clx...", "version": 3, "status": "MAINTENANCE" } ],
"reason": "Host in Wartung — abhängige VMs mit"
}
Also: GET /api/assets/:id/impact/recovery returns neighbors in MAINTENANCE that can be reactivated when this asset's maintenance ends; POST /api/assets/impact/batch { assetIds, targetStatus } aggregates impact over a bulk selection (read-only, with counts.inSelection). The co-update itself is always done from a single asset.
Built-in Components (Containment & Co-Move)
An asset type with standalone=false is a built-in component (RAM/SSD/PCIe): no standalone checkout/handover — it follows its container. GET /api/assets/:id/containment returns the transitively built-in components (via PART_OF/INSTALLED_ON; components the caller cannot see are only counted).
- Co-Move / Co-Locate / Co-Return: checkout, checkin, install, deinstall, confirmReturn and PATCH /api/assets/:id (location change) move the components in one step (coMoveAssetIds / coLocateAssetIds / coReturnAssetIds — items in the same record or at the same location). Errors: CO_ITEM_NOT_CONTAINED / CO_ITEM_INVALID_STATE.
- Co-LOST: when reporting a container as lost, the built-in components can optionally be reported lost too (checked by default, same mandatory reason) — via the API this is a separate call per component after the container.
- Inventory auto-account: an unscanned component counts as accounted for once its container was scanned in the session. Details on the Inventory API page.
Linking rules: out-of-service assets (RETIRED/LOST/DISPOSED) cannot be newly linked to licenses/contracts (removal is always allowed); retire/dispose is blocked while active contract/license links exist. Asset↔asset relations to out-of-service assets remain intentionally allowed (CMDB history). Consumables (CONSUMABLE) are excluded from CMDB relations.
Type-specific Permissions
Permissions can be set per asset type (e.g., only certain roles may check out laptops).
Create Permission
An entry carries eight permissions: view (canView), create (canCreate), edit (canEdit), checkout (canCheckout), checkin (canCheckin), delete (canDelete), handover (canInitiateHandover) and “use in reports” (canReport). The first seven allow actions on the asset, the eighth is a pure data permission: it decides whether assets of this type appear in reports.
Type ID and role/user ID go in the path; the body contains only the capability flags:
PUT /api/asset-types/:id/permissions/roles/:roleId
{
"canView": true,
"canCreate": true,
"canEdit": true,
"canCheckout": true,
"canCheckin": true,
"canDelete": true,
"canInitiateHandover": true,
"canReport": true
}
Or for specific user:
PUT /api/asset-types/:id/permissions/users/:userId
{
"canView": true,
"canEdit": true,
"canDelete": false
}
Permission Check
On asset access, the system automatically checks:
- Open type: only the global asset permissions of the role apply.
- Locked type: the user entry counts if there is one — otherwise the entry of their role. A user entry fully replaces the role entry, including where it allows less. Without either the type stays closed; the assigned user still sees their own asset.
- assets.manageTypePermissions lifts the type lock: whoever holds this management permission works with all types, regardless of individual entries.
Use in Reports (canReport)
canReport is the data axis of the type permission and requires canView on the same entry: a reporting permission without visibility of the type would have no effect. The server rejects both directions — canReport without canView as well as clearing canView while canReport is set — with HTTP 400 and the code ASSET_TYPE_PERMISSION_REPORT_REQUIRES_VIEW. What is checked is the entry as it looks after the change.
- With the role permission assets.reporting, open types are reportable, plus the caller's own assets of locked types. Beyond that, a locked type only opens up through canReport on that very type.
- Without the role permission but with canReport on individual types, exactly those types are reportable — whoever comes in through the type sees the type only.
- If both are missing, the report builder does not offer the assets and handovers data sources, and a report requesting them anyway is refused with ENTITY_NOT_ALLOWED. A handover is reportable as soon as at least one of its assets is reportable.
- assets.manageTypePermissions lifts the type lock here too — for reports however only together with the role permission assets.reporting: the management permission opens visibility and upkeep, not reports.
- Reports never show more than visibility permits: the report scope is additionally narrowed to the visible assets.
Inventory Scan (Bulk Upload)
For hardware inventories, assets can be captured via barcode/QR scanner.
Two mechanisms: Single-code lookup via GET /api/assets/scan and the full multi-step inventory via inventory sessions (see the "Inventory Sessions" section above).
Single Lookup (code → asset)
GET /api/assets/scan?code=00042
Resolves a scanned assetTag or serial number to the asset (e.g. to add it to a session during inventory). Requires the assets.scan permission.
Scan into an inventory session
POST /api/inventory-sessions/:id/scan
{
"code": "00042",
"quantity": 1
}
Either code (the scanned QR/barcode value) or assetId — one of the two is required. quantity is optional (default 1) and counts for consumables.
lastSeenAt: Updated on every scan. Assets without lastSeenAt in last X months can be marked as "missing".
Locations & Categories
Locations
GET /api/asset-locations
{
"data": [
{
"id": "clx...",
"name": "Munich Office - 2nd Floor",
"address": "Sample Street 1, 80333 Munich",
"building": "Main Building",
"floor": "2",
"room": "201",
"isActive": true,
"assetCount": 42
}
]
}
Categories
GET /api/asset-categories
{
"data": [
{
"id": "clx...",
"name": "Hardware",
"description": "Physical hardware devices",
"color": "#3b82f6",
"isActive": true,
"assetCount": 156
},
{
"id": "clx...",
"name": "Software",
"description": "Software licenses and subscriptions",
"color": "#8b5cf6",
"isActive": true,
"assetCount": 89
}
]
}
Checkout / Checkin & Install / Deinstall (flow endpoints)
Assignment runs exclusively through these endpoints — a PATCH that sets assignedToId together with the transition to IN_USE is rejected with ASSET_ASSIGN_VIA_FLOW_ONLY. PERSON assets use checkout/checkin, LOCATION assets install/deinstall. Each endpoint writes an activity entry and checks the anchor rules (see Asset Lifecycle).
Checkout (PERSON → IN_USE)
POST /api/assets/:id/checkout
{
"userId": "clx-user-id",
"expectedCheckin": "2026-12-31",
"note": "Issued for home office setup",
"coMoveAssetIds": ["clx-ram-id", "clx-ssd-id"]
}
Sets status=IN_USE + assignedToId (CHECKOUT_DIRECT). Source: AVAILABLE, RESERVED or MAINTENANCE (user-less). External recipient instead of userId: externalFirstName / externalLastName / externalEmail (creates an END_USER + email). coMoveAssetIds moves built-in components in the same record. expectedCheckin is the expected return date; it is held on the asset (expectedCheckinAt) and on the checkout record created, where it can be changed later (see the handover workflow).
Checkin (return → AVAILABLE | MAINTENANCE | RETIRED)
POST /api/assets/:id/checkin
{
"status": "AVAILABLE",
"note": "Returned in good condition",
"damageReport": "Minor scratch on lid",
"coReturnAssetIds": ["clx-ram-id"]
}
Clears assignedToId, checkedOutAt, expectedCheckinAt, checkoutNote and deployedAt. status defaults to AVAILABLE (allowed: AVAILABLE, MAINTENANCE, RETIRED); LOST/DISPOSED do not run via checkin. For target RETIRED the retirement rules apply (blocked while active contract/license links exist). damageReport is stored on the handover record (overview card).
Install (LOCATION → IN_USE)
POST /api/assets/:id/install
{
"locationId": "clx-serverroom-b12",
"note": "Rack 4, Slot 12",
"coLocateAssetIds": []
}
For trackingMode=LOCATION only. Sets status=IN_USE + locationId (no user), deployedAt=now. No handover, no email — a pure IT action with an activity log.
Deinstall (LOCATION → AVAILABLE | MAINTENANCE)
POST /api/assets/:id/deinstall
{
"status": "AVAILABLE",
"keepLocation": false,
"note": "Decommissioned"
}
Clears deployedAt and (by default) locationId; keepLocation=true keeps the location (device on site, out of service). status AVAILABLE (default) or MAINTENANCE.
Formal handover: For handovers with recipient confirmation, PDF/QR and email use the handover workflow (PERSON only, see above). Field assignment & anchor rules: Asset Lifecycle.
Financial Tracking & Depreciation
Fields
| Field | Type | Description |
|---|---|---|
purchaseDate | DateTime | Purchase date |
purchasePrice | Decimal | Purchase price |
purchaseOrder | String | Purchase order number |
vendor | String | Vendor |
warrantyEnd | DateTime | Warranty end |
maintenanceEnd | DateTime | Maintenance end |
bookValue | Decimal (computed) | Current book value — read-only, computed on every read from purchasePrice − depreciation |
depreciationMethod | String | LINEAR, DEGRESSIVE, NONE |
usefulLifeMonths | Int | Useful life in months |
depreciationStartDate | DateTime | Depreciation start |
Currency & gross/net: Assets store only a single purchasePrice (Decimal) — there is no per-asset currency or gross/net field. The system currency (general settings: systemCurrency, default EUR, no conversion) and the price mode (priceTaxMode: net | gross, default net — labeling only, no tax calculation) are configured globally in the general settings and applied to all amounts. See Settings & Global Search API.
Example: Depreciation
{
"purchaseDate": "2026-01-15",
"purchasePrice": 2499.00,
"depreciationMethod": "LINEAR",
"usefulLifeMonths": 36,
"depreciationStartDate": "2026-01-15"
}
Calculation: With linear depreciation over 36 months: Monthly depreciation = €2,499 / 36 = €69.42. After 12 months: bookValue = €1,665.96.
Custom Fields
Fields defined per asset type in the customFieldSchema are stored in the customFields object:
Example: Laptop
{
"customFields": {
"ramGB": 32,
"storageGB": 1024,
"storagetype": "NVMe SSD",
"cpu": "Intel i7-12700H",
"display": "15.6\" 4K OLED",
"gpu": "NVIDIA RTX 3050 Ti",
"battery": "86 Wh",
"weight": "2.0 kg"
}
}
Example: Server
{
"customFields": {
"rackUnit": "42U",
"position": "Rack A, U15-U18",
"cpuCores": 32,
"ramGB": 128,
"storageType": "SAS RAID 10",
"networkPorts": 4,
"ipAddress": "192.168.1.100",
"powerSupply": "Redundant 800W"
}
}
Filtering: The asset list can be filtered by any custom field: f.customField.<key>=<operator>:<value> with the operators eq, neq, contains, isNull and isNotNull (e.g. f.customField.cpu=contains:i7). The full-text search q covers asset tag, name, serial number and description, not the custom fields.
Sensitive Custom Fields
A field can be marked as sensitive in the customFieldSchema (e.g. credentials, keys). Sensitive values are overwritten with a constant mask in ALL read paths (detail, list, create prefill) and in activity/audit entries — the cleartext never leaves the normal response.
| Method | Endpoint | Description |
|---|---|---|
GET | /api/assets/:id/custom-fields/:field/reveal | Cleartext of ONE sensitive field; permission: view access to the asset (viewAll/viewOwn or the asset type's canView); every reveal is logged (like /licenses/:id/key) |
Saving sensitive fields: For a sensitive field: omit the key = keep the stored value, null = clear it, sending the mask value back = 400. This prevents a masked display from ever being saved as the real value.
List Assets
Request
GET /api/assets?f.status=IN_USE&f.typeId=clx-laptop&per=50
Query Parameters
| Parameter | Description |
|---|---|
q | Full-text search (asset tag, name, serial number, description) |
f.status | Filter by status, e.g. f.status=in:AVAILABLE,IN_USE |
f.typeId / f.categoryId / f.locationId | Filter by asset type, category or location |
f.assignedToId | Filter by assignee |
f.criticality | Filter by criticality (LOW, MEDIUM, HIGH, CRITICAL) |
f.manufacturer / f.model | Filter by manufacturer or model |
f.type.trackingMode | Filter by tracking mode (PERSON, LOCATION, CONSUMABLE) |
f.customField.<key> | Filter by a custom field (see Custom Fields) |
page / per | Page and page size (per defaults to 25, max 100) |
sort | Sorting, e.g. sort=createdAt:desc |
mine / myTeam / myDepartment | =1: only assets of the caller, their team or their department |
lowStock | =1: only consumables at or below their minimum quantity |
notLinkedToLicenseId | License ID: only assets not assigned to this license — the candidate list for a license assignment |
deleted | =1: trash (permission assets.viewDeleted) |
Lifecycle Management
Assets track their complete lifecycle:
| Field | Description |
|---|---|
createdAt | Created in system |
deployedAt | First deployment |
retiredAt | Retired from service |
disposedAt | Disposed/sold |
deletedAt | Soft-delete (trash) |
Code Example: Complete Handover Flow
// ===================================================
// ASSET HANDOVER - FROM CREATION TO CONFIRMATION
// ===================================================
const API_URL = 'https://your-instance.com/api';
// Auth via HttpOnly Cookies (credentials: 'include')
// 1. IT creates handover (issue laptop)
const handover = await fetch(`${API_URL}/assets/handovers`, {
method: 'POST',
credentials: 'include', // HttpOnly cookie auth
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipientId: 'clx-john-doe',
assetIds: ['clx-laptop-id'],
note: 'Laptop for new developer',
expiresAt: '2026-12-31T23:59:59Z'
})
}).then(r => r.json());
console.log('Handover created:', handover.handoverNumber); // HO-00042
console.log('QR URL:', handover.qrUrl);
// 2. Generate PDF protocol
const pdfBlob = await fetch(
`${API_URL}/assets/handovers/${handover.id}/pdf`,
{
credentials: 'include' // HttpOnly cookie auth
}
).then(r => r.blob());
// Save or print PDF
// PDF contains QR code + asset details + policy text
// 3. User scans QR code (opens handover.qrUrl in browser)
// NO authentication required! HMAC token in URL
const publicData = await fetch(handover.qrUrl)
.then(r => r.json());
console.log('Public handover data:', publicData);
// Shows: Asset details, initiator name, policy text
// NO internal IDs, NO emails
// 4. User confirms handover (in UI after QR scan)
const confirmed = await fetch(
`${API_URL}/assets/handovers/${handover.id}/accept`,
{
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
note: 'Laptop received. All components present.',
policyAccepted: true
})
}
).then(r => r.json());
console.log('Handover confirmed:', confirmed.handoverNumber);
console.log('Status:', confirmed.status); // CONFIRMED
// Asset status is now IN_USE
// assignedToId = John Doe
// checkedOutAt = now
Code Example: Model Clustering
// ===================================================
// MODEL CLUSTERING - DEDUPLICATE MANUFACTURER/MODEL
// ===================================================
// 1. Get pending clusters (detected by CronJob)
const clusters = await fetch(`${API_URL}/asset-model-clusters?status=PENDING`, {
credentials: 'include'
}).then(r => r.json());
console.log('Pending clusters:', clusters.data.length);
// Example cluster:
// {
// "variants": [
// { "manufacturer": "DELL", "model": "XPS 15", "count": 15 },
// { "manufacturer": "Dell", "model": "XPS 15", "count": 23 },
// { "manufacturer": "dell", "model": "xps 15", "count": 5 }
// ],
// "assetCount": 43,
// "variantCount": 3,
// "status": "PENDING"
// }
const cluster = clusters.data[0];
// 2. Set canonical values
await fetch(`${API_URL}/asset-model-clusters/${cluster.id}/canonical`, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
canonicalManufacturer: 'Dell',
canonicalModel: 'XPS 15'
})
});
// 3. Merge cluster (apply canonical values to all 43 assets)
await fetch(`${API_URL}/asset-model-clusters/${cluster.id}/merge`, {
method: 'POST',
credentials: 'include'
});
console.log('Merged! All 43 assets now have manufacturer="Dell", model="XPS 15"');
// Cluster status is now MERGED
// All assets updated in single transaction
// Activity log created for each asset
Attachments
Assets use the central attachment system for purchase orders, warranty certificates, invoices, etc.:
# Upload file to asset
POST /api/attachments/ASSET/:assetId
# All attachments of an asset
GET /api/attachments/ASSET/:assetId
# Download
GET /api/attachments/:id/download
Details: See Attachments & File Settings API for virus scanning, file settings and retention periods.
Workflows API →
Learn more about the Workflows API
Entity Linking API →
Link assets with tickets/problems/incidents/changes/contracts