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 |
| 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 |
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
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
}
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:
- Global asset permissions (RBAC)
- Type-specific permissions (if present)
- User-specific overrides (highest priority)
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