REST API Assets - edmozley/freeitsm GitHub Wiki

๐Ÿ’ป REST API: Assets

The complete usage guide for the Assets module of the REST API โ€” every URL, method, parameter and response shape. This mirrors the interactive documentation at System โ†’ API โ†’ Documentation, where each endpoint also has a live "Try it" tester.

Note

New to the API? Read the basics table on REST API: Tickets first โ€” base URL, authentication, response envelope, error codes, rate limits and PATCH semantics are identical across all modules.

Three things that make Assets different from Tickets:

๐Ÿข Company-scoped Every asset belongs to a company (assets.tenant_id; none recorded means Default). A key's company scope restricts every list and every read and write by id - an asset outside it is a 404.
๐Ÿท๏ธ Hostname is identity The inventory agent, Intune sync and this API all treat hostname as the asset's identity. Creating a duplicate hostname returns 409 pointing at the existing asset โ€” PATCH that instead.
๐Ÿšซ No delete There is deliberately no DELETE /assets/{id} โ€” nothing in FreeITSM deletes assets (they're agent-maintained records). Retire an asset by setting its status.

๐Ÿš€ Quick start

Sync an asset from any external source (procurement system, another RMM, a spreadsheet):

curl -X POST "https://your-server/api/v1/assets" \
  -H "Authorization: Bearer fitsm_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
        "hostname": "LT-0042",
        "manufacturer": "Dell",
        "model": "Latitude 5440",
        "service_tag": "ABC1234",
        "asset_type_id": 4,
        "warranty_expiry": "2028-06-30",
        "purchase_cost": 899.99
      }'

Every change you make afterwards lands in the asset's History exactly as if an analyst edited it in the UI. ๐Ÿ“’


๐Ÿ’ป Assets

๐ŸŸข GET /assets โ€” list / search   ๐Ÿ”‘ assets.read

Parameter Description
q Search hostname, service tag, model and manufacturer
hostname / service_tag Exact lookups (handy for sync scripts)
asset_type_id ยท asset_status_id ยท location_id ยท supplier_id ยท cost_centre_id Classification filters
assigned_user_id Assets assigned to a requester ยท unassigned=true finds unassigned assets
warranty_within_days โณ Warranty expiring within N days โ€” the dashboard's "expiring soon" shape
warranty_expired true = warranty already lapsed
not_seen_days ๐Ÿ‘ป Not reported by the inventory agent for N days (or never seen)
sort hostname (default), id, first_seen, last_seen, warranty_expiry, purchase_date, model โ€” prefix - for descending
page / per_page Pagination โ€” default 1 / 25, max per_page 100
GET /assets?warranty_within_days=30&sort=warranty_expiry     โ† renewal report
GET /assets?not_seen_days=14&asset_status_id=1               โ† agents gone quiet
GET /assets?service_tag=ABC1234                              โ† exact serial lookup

The asset shape (list and detail are the same, detail adds assigned_users):

{ "id": 568, "hostname": "LT-0042",
  "type":     { "id": 4, "name": "Laptop" },
  "status":   { "id": 2, "name": "In use" },
  "location": { "id": 2, "name": "London", "path": "UK โ€บ London" },
  "hardware": { "manufacturer": "Dell", "model": "Latitude 5440", "service_tag": "ABC1234",
                "memory": 17179869184, "cpu_name": null, "speed": null, "gpu_name": null,
                "bios_version": null, "tpm_version": null, "bitlocker_status": null },
  "os":       { "operating_system": "Windows 11 Pro", "feature_release": null, "build_number": null },
  "network":  { "domain": null, "logged_in_user": null },
  "lifecycle": { "purchase_date": null, "purchase_cost": 899.99,
                 "supplier": { "id": 3, "name": "TechSupplies Ltd" },
                 "order_number": "PO-1001", "warranty_expiry": "2028-06-30" },
  "assigned_users_count": 1,
  "first_seen": "2026-07-02T22:59:12Z", "last_seen": "2026-07-02T22:59:12Z", "last_boot_at": null }

๐Ÿ”ต POST /assets โ€” create   ๐Ÿ”‘ assets.create

Only hostname is required (max 50 chars, unique โ€” duplicate โ†’ 409 with the existing asset's id). Every field from the PATCH table below can be set at creation. first_seen/last_seen are stamped automatically; a creation entry is written to the asset history; setting warranty_expiry re-syncs the calendar's warranty events. Returns 201 with the full asset.

asset_tag (3.1.0). Send one and it is kept, if it is free in the asset's company (409 naming the asset that has it otherwise). Leave it out and, if the company has automatic asset tags switched on, the asset gets the next one in sequence โ€” the response carries it. See Asset tag numbering โ€” Developer Guide.

๐ŸŸข GET /assets/{id} โ€” get one   ๐Ÿ”‘ assets.read

The full asset plus its current holders inline:

"assigned_users": [ { "user_id": 174, "name": "Jane Smith", "email": "[email protected]",
                      "assigned_at": "2026-07-02T22:59:36Z",
                      "expected_return_date": "2026-08-01", "notes": "Loan laptop" } ]

๐ŸŸ  PATCH /assets/{id} โ€” update   ๐Ÿ”‘ assets.update

Send only what changes; explicit null clears a field. Every change is audited into the asset's History with the same stable field keys the UI writes (so API edits render identically in the History modal), lookup changes are audited by display name ("Laptop", "UK โ€บ London"), and a warranty_expiry change re-syncs the calendar. Unknown lookup ids โ†’ 422; a hostname clash โ†’ 409. Re-sending the same PATCH is idempotent.

Group Fields
Classification asset_type_id ยท asset_status_id ยท location_id
Lifecycle purchase_date ยท purchase_cost ยท supplier_id ยท order_number ยท warranty_expiry (dates YYYY-MM-DD) ยท cost_centre_id (3.5.0 โ€” an active cost centre of the asset's company, 422 otherwise; one already held may be kept after it's made inactive). Read back as lifecycle.cost_centre: {id, code, name}
Identity & hardware hostname ยท manufacturer ยท model ยท service_tag ยท memory ยท cpu_name ยท speed ยท gpu_name ยท bios_version ยท tpm_version ยท bitlocker_status
OS & network operating_system ยท feature_release ยท build_number ยท domain ยท logged_in_user

๐Ÿงพ Custom asset fields (3.12.0, #194)

POST /assets and PATCH /assets/{id} take an optional fields object, keyed by each custom field's field_key - the same keys GET returns under fields. Only the fields the asset's type carries are accepted; an unknown key, a field of another type or an invalid value is a 422 and nothing is saved - core and custom changes go in one transaction. Leave a key out to keep it; null or "" clears a field that is not required; {} changes nothing. Changing asset_type_id and setting the new type's fields in the same request works - they are checked against the type you are moving to.

PATCH /assets/42
{ "asset_type_id": 3, "fields": { "rack_position": "U14", "support_tier": "Gold" } }

Keys that were not used. A top-level key that is not an asset field (a typo, or company_id on a PATCH - an asset cannot move company) is still accepted, so older scripts keep working, but the response lists it: "ignored": ["colour"]. The array is absent when everything was used. Check for it in scripts.

Note

Contributed by @Dr4inder in PR #195 and merged with fixes - see the review.

Tip

The classification/lifecycle fields are what analysts edit in the UI; the hardware/OS fields are normally maintained by the inventory agent โ€” the API can write both, so a non-agent source (Intune export, another RMM) can keep assets in sync.


๐Ÿ“ก Reporting inventory from your own tool (3.12.0, #194)

An RMM, Lansweeper, a CMDB export - any tool that scans devices - can report what it saw on an asset: software, volumes (disks) and network adapters. The rows go into the same inventory the agent and Intune fill, so the asset's Software and Disks tabs, the Software module, reports and the read endpoints all show them, each with its source. Nothing else is touched: not the asset's own fields, not last_seen (it keeps meaning "the agent reported", so an old scan never makes a device look alive), and never another source's rows.

The key needs asset_inventory.create to report and asset_inventory.delete to unlink - not assets.update, so an inventory feed cannot rename assets.

๐Ÿ”ต POST /assets/{id}/inventory   ๐Ÿ”‘ asset_inventory.create

{
  "schema_version": 1,
  "source": "lansweeper",
  "source_device_ref": "LS-0042-ABC",
  "observed_at": "2026-10-01T08:30:00Z",
  "sections": {
    "software":         { "complete": true,  "items": [ { "display_name": "7-Zip", "publisher": "Igor Pavlov", "display_version": "23.01" } ] },
    "disks":            { "complete": false, "items": [ { "external_id": "volume-c", "drive": "C:", "size_bytes": 100000000000, "free_bytes": 40000000000 } ] },
    "network_adapters": { "complete": true,  "items": [ { "external_id": "eth0", "name": "Ethernet", "mac_address": "00:11:22:33:44:55", "ip_address": "10.0.0.12" } ] }
  }
}
Field
source Your tool's name - lowercase letters, digits, . _ -, up to 48. The first analyst to report under a name in a company owns it, so give each integration its own analyst (a service account) and keys for it. A new key for the same analyst keeps the source; another integration sending the same name gets 403 source_owner. Names FreeITSM uses itself (agent, intune, proxmox, vcloud, vcenter, manual, api, system, freeitsm) are refused
source_device_ref Your tool's own id for the device. It is linked to one asset per company: naming it on a different asset is 409 device_ref_conflict, naming the asset it is on - never a silent move
observed_at When your tool scanned, RFC 3339 with seconds and a timezone; not more than five minutes in the future
sections Any of software, disks, network_adapters, each { "complete": true|false, "items": [...] }, up to 10,000 items. Software is matched by name and publisher to the application catalogue (an application nobody has seen is added, and fires the application discovered workflow trigger); volumes and adapters need your own stable external_id

What each section does - judged one at a time:

The section is... Result
Complete Brought exactly into line for this source: added, updated in place, and anything this source reported before but not now removed. "items": [] removes all of this source's rows
Partial ("complete": false) Added and updated; nothing removed
Not sent Untouched
Older than the newest already applied for it stale - left alone; newer_observed_at says what is there
The same time and the same content replayed - nothing written. Retries are safe for ever, not just for a while
The same time, different content failed, observation_conflict
Invalid (a bad value, a duplicate item) failed, invalid_field, naming the problem

The answer lists every section sent, with added, updated and removed counts for those applied:

{ "asset_id": 42, "source": "lansweeper", "source_device_ref": "LS-0042-ABC", "observed_at": "2026-10-01T08:30:00Z",
  "sections": { "software": { "result": "applied", "added": 1, "updated": 37, "removed": 2 },
                "disks":    { "result": "failed", "error": { "code": "invalid_field", "message": "free_bytes exceeds size_bytes." } } } }

200 - nothing failed. 207 - some sections failed and the rest applied; each failed one was rolled back on its own. 422 all_sections_failed - none applied; details names each. To retry, send the same report again: the sections that applied answer replayed, and only the failed ones are tried again. Problems with the report as a whole (the envelope, ownership, a device linked elsewhere) refuse all of it.

๐ŸŸข GET /assets/{id}/inventory   ๐Ÿ”‘ asset_inventory.read

Which sources report on this asset: each one's source, source_device_ref, the analyst who owns the name (owner_analyst_id), and per section when it was observed and imported, and by whom. Since 3.15.0 the inventory agent is listed too, as source: "agent" with no device reference, and each section also says what its last attempt came to: last_outcome (succeeded, empty, skipped - left out of the agent's last report - or failed), last_error and last_attempt_at. A failed or skipped attempt never moves observed_at, so old data never looks new; observed_at is null for a section that has only ever failed. A REST section that fails is recorded this way too. See Inventory freshness. The rows themselves are in GET /assets/{id}/software, /disks and /network-adapters, which now include source, observed_at and imported_at on every row (agent and intune rows have no observation times).

๐Ÿ”ด DELETE /assets/{id}/inventory/{source}   ๐Ÿ”‘ asset_inventory.delete

Unlinks the source from this asset and removes every row it reported here. This is how a device reference moves to another asset (a reimaged machine): unlink it here and the next report links it there. Only the owning analyst's keys may unlink. Written to the asset's history, as every import is.

Note

Contributed by @Dr4inder in PR #197 and merged with fixes - see the review. The Inventory sources panel followed in 3.15.0 (Inventory freshness). Not yet: physical drives as a section.


๐Ÿ”Œ Connected assets (3.15.0, #200)

What is physically attached to an asset - a monitor on a computer, a phone in a dock. Not custody (who holds it) and not a CMDB dependency. See Connected assets.

Reporting connections: the connections section

Add it to the same POST /assets/{id}/inventory report (๐Ÿ”‘ asset_inventory.create), alongside or instead of the other sections:

"connections": {
  "complete": true,
  "items": [
    { "connected_asset_id": 512 },
    { "serial": "CN-0F7RDV-74445", "name": "Dell P2422H", "model": "P2422H" },
    { "serial": "3CM1234XYZ", "connected": false }
  ]
}
  • Name the other asset by connected_asset_id if your tool has already matched it (it must be in the reported asset's company), or by serial, matched exactly as FreeITSM matches any inventory source: one asset with that serial is a match, none or several is not.
  • connected: false says your tool saw it is no longer connected, and ends your row for it.
  • complete: true means these and no others: any other connection of yours on this asset ends. A partial section never ends anything, and neither does leaving a connection out of a report.
  • A serial no asset has, or several share, is not created and not guessed. It comes back in the section's unmatched list and is kept for review in FreeITSM. Once exactly one asset has that serial, your next report connects it.
  • Your rows are yours alone. They never end or change a person's confirmation, or another tool's rows. People see them as last observed connected, with your timestamp.
  • Every connection and disconnection is written to both assets' history. Unlinking your source (DELETE .../inventory/{source}) removes your connection rows on that asset too.

An asset that has been merged

When two assets are merged from the review list (Asset review), one of them is removed. A tool that keeps reporting to the removed asset's id - POST /assets/{old id}/inventory, or a connection with connected_asset_id of the old id - reaches the kept asset; the response's asset_id says which, so a tool can update its own record. Other endpoints answer the old id with 404.

๐ŸŸข GET /assets/{id}/connections   ๐Ÿ”‘ assets.read

What is connected now and what was connected before, from either end:

  • current: one entry per connected asset (other: id, hostname, type, serial, make and model), with every source's row in rows, side by side.
  • history: rows that have ended, newest first.

Each row has source (manual for a person, or rest:<name>), connected_at, first_observed_at and last_observed_at (a tool's sightings), disconnected_at, confirmed_by, disconnected_by, and reported_here (whether it was recorded from this asset's side).


๐Ÿ”— The configuration item link (GH #201)

An asset can be linked to one configuration item meaning the same piece of equipment (Linking an asset to its configuration item). The permission is asset_ci_links, separate from assets and cmdb_objects, so an integration can maintain links without editing either record. The key's company scope must reach both. GET /assets/{id} also carries a read-only linked_ci: {id, name, class}, {"restricted": true}, or null.

Restricted: a key without cmdb_objects.read sees the CI end as {"restricted": true} - no id, no name - in current, in history, and in conflict messages. In GET /assets/{id}/history, a cmdb_link row's values are ci:<id> or restricted by the same rule.

๐ŸŸข GET /assets/{id}/ci-link   ๐Ÿ”‘ asset_ci_links.read

{ "data": { "current": { "id": 88, "name": "LON-SRV-01", "class_name": "Server", "link_id": 12,
                         "linked_at": "2026-10-26T09:14:00Z", "linked_by": "Ed Mozley", "link_source": "ui" },
            "history": [ { "link_id": 12, "other": { "id": 88, "name": "LON-SRV-01", "class_name": "Server" },
                           "linked_at": "2026-10-26T09:14:00Z", "unlinked_at": null, "unlink_reason": null } ] } }

Each period has linked_by / link_source and, once ended, unlinked_by / unlink_source / unlink_reason (unlinked, replaced, ci_deleted, asset_merged). made_on names the asset a period was made on when that asset was later merged into this one.

๐Ÿ”ต POST /assets/{id}/ci-link   ๐Ÿ”‘ asset_ci_links.create

{ "cmdb_object_id": 88 }
  • Retry-safe: the same pair again is 200 with "unchanged": true.
  • One each: if the asset or the CI already has a live link, 409 ci_already_linked or 409 asset_already_linked, naming the other record (or saying the key cannot see it), with the live link ids in error.details.conflicts.
  • Replace on purpose: { "cmdb_object_id": 88, "replace": true, "replaces_link_ids": [12] } - exactly the ids from the 409. If the links changed since, 409 link_changed and nothing happens.
  • Different companies: 422 different_company. It is POST rather than PUT because this API takes only GET, POST, PATCH and DELETE.

๐Ÿ”ต POST /assets/{id}/ci-link/create-ci   ๐Ÿ”‘ asset_ci_links.create + cmdb_objects.create

Creates a configuration item of the class the asset's type is mapped to (Assets โ†’ Settings โ†’ Asset types โ†’ CMDB class) and links it, in one transaction.

{ "name": "LON-SRV-01", "properties": { "hostname": "lon-srv-01" } }

name defaults to the asset's; properties must supply the class's required ones, validated as POST /cmdb/objects. 201 with {cmdb_object_id, link_id, created: true}. 422 no_class_mapped when the type has no class. 409 possible_matches with error.details.matches when unlinked CIs of that class in the company already have the asset's name - link one of them instead, or send "ignore_matches": true. Retry-safe: an asset already linked creates nothing and answers 200 with its CI and created: false.

๐Ÿ”ด DELETE /assets/{id}/ci-link   ๐Ÿ”‘ asset_ci_links.delete

Ends the live link; both records are kept. 404 not_linked when there is none. ?link_id=12 makes it conditional: 409 link_changed if that is no longer the live link.


๐ŸชŸ Virtual estate (Hyper-V) (GH #199)

What the Hyper-V collector sends, and how to watch it. The permission is virtual_estate.

๐Ÿ”ต POST /virtual-estate/{connection}/reports   ๐Ÿ”‘ virtual_estate.create

Only the key the connection was given (Assets โ†’ Settings โ†’ Hyper-V) may post its reports - another key is 403, even with the permission. The collector builds this; the shape, for anyone writing their own:

{ "schema_version": 1, "observed_at": "2026-10-26T09:14:00Z",
  "scope": [ { "kind": "cluster", "id": "<Get-Cluster Id>", "name": "CL-PROD" }, { "kind": "host", "name": "HV-LAB01" } ],
  "collector": { "name": "Invoke-HyperVInventory", "version": "1.0.0", "powershell": "5.1", "host": "MGMT01" },
  "unreadable": [ { "kind": "host", "name": "HV-03", "error": "Access is denied" } ],
  "sections": {
    "clusters":      { "complete": true, "items": [ { "id": "<guid>", "name": "CL-PROD", "quorum_type": "NodeAndFileShareMajority", "witness": "File Share Witness" } ] },
    "hosts":         { "complete": true, "items": [ { "id": "<hardware UUID>", "name": "HV-01", "cluster_id": "<guid>", "node_state": "Up", "os_version": "...", "cpu_count": 64, "memory_bytes": 549755813888 } ] },
    "cluster_roles": { "complete": true, "items": [ { "id": "<guid>", "name": "PAYROLL-01", "cluster_id": "<guid>", "role_type": "VirtualMachine", "owner_host": "HV-01", "state": "Online", "preferred_owners": ["HV-01"] } ] },
    "vms":           { "complete": true, "items": [ { "id": "<Get-VM Id>", "name": "PAYROLL-01", "host": "HV-01", "state": "Running", "is_clustered": true, "cluster_role_id": "<guid>", "possible_owners": ["HV-01", "HV-02"], "generation": 2, "cpu_count": 4, "memory_bytes": 8589934592, "guest_addresses": ["10.0.0.5"] } ] },
    "vm_disks":      { "complete": true, "items": [ { "vm_id": "<Get-VM Id>", "id": "scsi-0:0", "path": "...", "size_bytes": 136365211648, "used_bytes": 42949672960, "controller": "SCSI 0:0" } ] },
    "vm_networks":   { "complete": true, "items": [ { "vm_id": "<Get-VM Id>", "id": "<adapter id>", "name": "Network Adapter", "switch_name": "vSwitch-Prod", "mac_address": "00155D010203", "ip_addresses": ["10.0.0.5"], "vlan_id": 20 } ] }
  } }
  • scope is what the report covers. Only objects in its scope are judged, so -ReportPer Cluster sends one report per cluster without the others being marked gone.
  • Identity: a host by its hardware UUID (a missing, all-zero, all-F or repeated one falls back to its name, marked name-based); a VM by its Get-VM Id; a cluster and a role by their Ids. Inside a report, VMs and roles name their host by host name.
  • Sections: complete = exactly this list (what is missing is marked not_seen_since, never deleted); false = added and updated only; left out = untouched. A missing value is null, never 0.
  • unreadable: everything on that host or cluster is kept and marked not confirmed.
  • Answers: 200 applied, or a stale (older than the newest for this scope) or replayed report (same scope, time and content); 207 some sections failed, each named - send the same report again to retry them; 422 all failed; 409 observation_conflict a different report for the same scope and time.

๐ŸŸข GET /virtual-estate/{connection}   ๐Ÿ”‘ virtual_estate.read

freshness (on_time, late, overdue, never), the last report's times, outcome and summary, the collector, counts of clusters, hosts, VMs and not-confirmed VMs, and open identity reviews - for your own monitoring. The key's company scope must cover the connection's company.


๐Ÿค Assignments (check-out / check-in)

Assignment is to requesters (end users), exactly like the UI: assigning writes the custody log's check-out and an assigned_user history entry; unassigning snapshots the holder and writes the check-in.

๐ŸŸข GET /assets/{id}/assignments   ๐Ÿ”‘ asset_assignments.read

Current holders with dates, due-back, notes and which analyst assigned them.

๐Ÿ”ต POST /assets/{id}/assignments   ๐Ÿ”‘ asset_assignments.create

Field Required Description
user_id or user_email โœ… An existing requester (create one first with POST /users โ€” needs users.create)
notes Assignment notes
expected_return_date Due back, YYYY-MM-DD

Already assigned โ†’ 409. Returns 201.

๐Ÿ”ด DELETE /assets/{id}/assignments/{user_id}   ๐Ÿ”‘ asset_assignments.delete

Checks the asset back in. Unknown assignment โ†’ 404.


๐Ÿ“’ History & custody

๐ŸŸข GET /assets/{id}/history   ๐Ÿ”‘ asset_history.read

Every audited change, newest first โ€” field key, old/new values (display names for lookups) and the analyst:

{ "data": [ { "id": 12, "field": "location", "old_value": null, "new_value": "London",
              "analyst": { "id": 1, "name": "Administrator" },
              "created_at": "2026-07-02T22:59:36Z" } ] }

๐ŸŸข GET /assets/{id}/custody   ๐Ÿ”‘ asset_history.read

The check-out / check-in trail โ€” who had the asset, when, expected return, processed by which analyst:

{ "data": [ { "id": 3, "action": "checkout", "user_id": 174, "user_name": "Jane Smith",
              "expected_return_date": "2026-08-01", "notes": "Loan",
              "by": "Administrator", "at": "2026-07-02T22:59:36Z" } ] }

๐Ÿงฐ Inventory (agent-collected)

Read-only views of what the inventory agent reports โ€” the same data as the asset detail pane's Storage/Devices/Software.

Endpoint Permission Returns
๐ŸŸข GET /assets/{id}/disks ๐Ÿ”‘ asset_inventory.read Drives with size/free bytes and used percent
๐ŸŸข GET /assets/{id}/network-adapters ๐Ÿ”‘ asset_inventory.read Adapters with MAC, IP, subnet, gateway, DHCP flag
๐ŸŸข GET /assets/{id}/devices ๐Ÿ”‘ asset_inventory.read Device-manager entries (class, name, status, driver)
๐ŸŸข GET /assets/{id}/software ๐Ÿ”‘ asset_inventory.read Installed software โ€” system components excluded unless ?include_components=true

๐Ÿ“š Reference data

All under the shared ๐Ÿ”‘ reference.read permission:

Endpoint Returns
๐ŸŸข GET /asset-types Asset types (id, name, description, is_active)
๐ŸŸข GET /asset-statuses Asset statuses
๐ŸŸข GET /asset-locations All locations, flat, each with parent_id and its full path ("UK โ€บ London โ€บ Office 1")
๐ŸŸข GET /suppliers Suppliers with display name and a supplies_assets flag

๐Ÿงช Worked example: warranty renewal report

A read-only key (assets: read + reference: read) pulls everything a renewals spreadsheet needs:

B="https://your-server/api/v1"; K="Authorization: Bearer fitsm_โ€ฆ"

# Assets whose warranty expires in the next 90 days, soonest first
curl -s -H "$K" "$B/assets?warranty_within_days=90&sort=warranty_expiry&per_page=100" \
  | jq -r '.data[] | [.hostname, .hardware.service_tag, .lifecycle.warranty_expiry,
                      (.lifecycle.supplier.name // "-"), (.location.path // "-")] | @csv'

And the reverse direction โ€” a procurement system stamping newly purchased kit:

# Find the asset by serial, then set its lifecycle fields
ID=$(curl -s -H "$K" "$B/assets?service_tag=ABC1234" | jq -r '.data[0].id')
curl -s -X PATCH "$B/assets/$ID" -H "$K" -H "Content-Type: application/json" \
  -d '{"purchase_date":"2026-07-01","purchase_cost":899.99,"order_number":"PO-1001","warranty_expiry":"2029-07-01"}'

Under the hood: REST API โ€” How It Works ยท Tickets endpoints: REST API: Tickets ยท Keys & permissions: System โ†’ API (System module) ยท Module docs: Assets.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ