Layer IQ Public API
The Layer IQ Public API lets external systems manage assets in your organization: bulk import (with optional normalization), normalize-only previews, listing, retrieval, update, and soft-delete.
All requests go through the Layer IQ API gateway over HTTPS. Pick the server for your environment (production, development, or local) from the dropdown above. Every path below is relative to that base URL.
Authentication
Every request must be authenticated. There are two accepted credentials:
| Method | Header | Use it for |
|---|---|---|
| API key (recommended) | X-API-Key: <key> |
Server-to-server / integrations |
| Bearer JWT | Authorization: Bearer <token> |
Calls made with an existing app session token |
API keys are scoped to a single organization and carry full access to that organization's data. Keep them secret, treat them like passwords, and never embed them in client-side code.
Generating an API key in the app
- Sign in to the Layer IQ web app.
- Open Settings → API Keys.
- Click Create API Key, give it a name (e.g. "Acme ERP sync"), and create it.
- Copy the key immediately — it is shown only once. Store it in your secret manager. If you lose it, revoke it and create a new one.
Keys are prefixed so you can tell environments apart: liq_live_… (production)
and liq_test_… (development).
First request
curl https://api.layer-iq.com/api/v1/public/assets \
-H "X-API-Key: liq_live_your_key_here"
A missing or invalid credential returns 401; a valid credential whose
organization does not have API access enabled returns 403
(FEATURE_NOT_AVAILABLE).
Response format
Responses use a consistent shape so clients can be written once:
-
A single resource is returned bare (the object itself), e.g.
GET,PATCHon/assets/{identifier}. - A collection is wrapped:
{ "data": [ … ], "pagination": { … } }. -
An async operation returns
202 Acceptedwith aLocationheader and{ "taskId": "…" }(see Asynchronous operations below). - An error always uses the standard error envelope (see Errors below).
Pagination
List endpoints use cursor (keyset) pagination — stable and efficient even
over very large result sets. Each list response ends with a pagination object:
{
"data": [ /* up to `limit` assets */ ],
"pagination": { "cursor": "eyJjcmVhdGVkQXQiOiI…", "hasMore": true, "total": 1432 }
}
hasMore—trueif more records match beyond this page.-
cursor— an opaque token marking where the next page begins, ornullwhen there are no more pages. -
total— the total number of records matching the current filters (across all pages, not just this one). OnGET /assetsthis reflects your active (non-archived) assets after anysearch/categoryType/source/date filters, so it's a quick way to get a live count without paging through everything.
To fetch the next page, pass the returned cursor back as the cursor query
parameter. Repeat until hasMore is false.
# Page 1
curl "https://api.layer-iq.com/api/v1/public/assets?limit=100" \
-H "X-API-Key: $KEY"
# Page 2 — use the cursor from page 1's pagination.cursor
curl "https://api.layer-iq.com/api/v1/public/assets?limit=100&cursor=eyJjcmVhdGVkQXQiOiI…" \
-H "X-API-Key: $KEY"
Loop pattern:
cursor = null
do:
GET /assets?limit=100[&cursor=<cursor>]
process(response.data)
cursor = response.pagination.cursor
while response.pagination.hasMore
Page size (limit). Defaults to 25, maximum 100, minimum 1.
Values above 100 are clamped to 100.
Treat cursors as opaque. Do not parse, build, or modify them. A cursor is
tied to the sort order it was generated with — keep order the same across a
pagination run. An invalid/expired cursor returns an empty page rather than an
error.
Sorting
Results are ordered by creation time (createdAt), with the asset id as a
stable tie-breaker so paging never skips or repeats a row. Use the order
parameter to choose direction:
order=desc(default) — newest first.order=asc— oldest first.
Sorting by other fields is not currently supported; filter the set down with the parameters below instead.
Searching & filtering
All filters combine with AND. The search term matches with OR across a
set of text fields. Available GET /assets query parameters:
| Parameter | Type | Description |
|---|---|---|
search |
string | Case-insensitive contains match across identifier, vendor, model, serialNumber, and manufacturer. |
categoryType |
string | One or more AssetCategoryType values, comma-separated. Unknown values are ignored. |
source |
string | One or more data sources, comma-separated: IMPORT, MOBILE, WEB, API, JOB, MANUAL, CONNECTION. |
companyEntityId |
string | Restrict to a single linked company entity. |
enrichment |
string | One of not_enriched, pricing, carbon, materials, fmv. |
dateFrom |
date-time | Only assets created at/after this ISO-8601 timestamp. |
dateTo |
date-time | Only assets created at/before this ISO-8601 timestamp. |
limit |
integer | Page size (1–100, default 25). |
order |
string | desc (default) or asc. |
cursor |
string | Opaque cursor from a previous page. |
Valid categoryType values: SERVER_CPU_INTEL, SERVER_CPU_AMD, SERVER_GPU,
SERVER_RAM_DIMM, SERVER_MOTHERBOARD, HDD_ENTERPRISE_3_5,
SSD_ENTERPRISE_NVME, SSD_ENTERPRISE_SATA, NETWORK_SWITCH_CORE,
NETWORK_SWITCH_TOR, NETWORK_CARD_NIC_HBA, LAPTOP_HDD, LAPTOP_SSD,
DESKTOP, TABLET_PHONE, PSU_SERVER, CHASSIS_RACK, UPS_BATTERY, OTHER.
Example — Dell laptops or desktops, newest first, 50 per page:
curl "https://api.layer-iq.com/api/v1/public/assets?search=Dell&categoryType=LAPTOP_SSD,DESKTOP&limit=50&order=desc" \
-H "X-API-Key: $KEY"
Archived (soft-deleted) assets are excluded from list results.
Rate limits
Limits are enforced per API key at the gateway. Every response carries the current window state:
RateLimit-Limit— max requests in the window.RateLimit-Remaining— requests left.RateLimit-Reset— seconds until the window resets.
Exceeding the limit returns 429 RATE_LIMITED with a Retry-After header
(seconds to wait). Back off and retry after that delay.
Asynchronous operations
The bulk endpoints POST /assets/ingest, POST /assets/normalize,
POST /assets/bulk-update, and POST /assets/bulk-delete are asynchronous so they
can handle very large batches (tens of thousands of rows). They return
202 Accepted with a Location header and a taskId:
{ "taskId": "ckv8…" }
Poll GET /assets/tasks/{taskId} for status (QUEUED, RUNNING, COMPLETE,
FAILED, CANCELED), progress (0–1), and counts (received/inserted/updated/
skipped/failed/retired/deleted).
Single vs. bulk update & delete — pick by sync vs async
The dividing line is how the work runs, not how many assets you touch:
| You want… | Endpoint | Method | Runs | Returns |
|---|---|---|---|---|
| Update one asset | /assets/{identifier} |
PATCH |
synchronously | the updated asset |
| Delete one asset | /assets/{identifier} |
DELETE |
synchronously | 204 No Content (no body) |
| Update many assets | /assets/bulk-update |
POST |
async (queued task) | 202 + { taskId } → poll |
| Delete many assets | /assets/bulk-delete |
POST |
async (queued task) | 202 + { taskId } → poll |
-
Single (
PATCH/DELETEon/{identifier}) is the instant, REST path for one-off changes.PATCHbody is the changed fields;DELETEtakes no body. -
Bulk (
POST /assets/bulk-update,POST /assets/bulk-delete) create an async task that you poll — this is required because deleting/updating thousands of rows can't finish inside a single request. Bodies are{ "updates": [{ "identifier": "…", … }] }and{ "identifiers": ["…", "…"] }. A bulk call with a single-element list is fine — that's the canonical way to do one delete/update through the async path if you prefer uniform tooling. Identifiers with no matching asset — and update rows with no changed fields — are reported asskippedin the task report; the batch is never rejected wholesale for one bad row. Successful rows are reflected only incounts(counts.updated,counts.deleted).
Common 405: There is no
DELETEorPATCHon the/assetscollection.GET /assetslists andPOST /assets/ingestcreates; sendingDELETE /assets(even with anidentifiersbody) returns 405 Method Not Allowed. For many assets usePOST /assets/bulk-delete; for one useDELETE /assets/{identifier}.DELETErequests must not carry a JSON body — bulk deletion is aPOST.
Normalize tasks apply the same model-name canonicalization the in-app
spreadsheet import uses (typo/whitespace/OEM-casing fixes, e.g. poweredge r730
→ PowerEdge R730, dell inc. → Dell), infer missing vendor/category, and
return the cleaned normalizedRows (re-submittable to /assets/ingest). Each
normalized row carries its own validation problems inline in row.issues; the
top-level issues list then holds only run-level notices. Paginate the rows with
rowsCursor.
Ingest, bulk-update, and bulk-delete tasks have no per-row objects, so all
per-row outcomes appear in the top-level paginated issues list (via issuesCursor).
Send an Idempotency-Key header on ingest, bulk-update, or bulk-delete to make
retries safe — a repeated key returns the original task instead of starting a
duplicate run.
Worked example: bulk update & delete (end to end)
Both flows are identical: POST → get a taskId → poll the task until
status is COMPLETE (or FAILED) → read counts and issues. Set
BASE and KEY (use your API key, or Authorization: Bearer <jwt>):
BASE="http://localhost:8000" # dev gateway; prod: https://api-dev.layer-iq.com
KEY="liq_test_…" # your X-API-Key
Bulk delete (archive many assets)
The body is ONLY a list of identifiers — no assets, no syncMode, no
normalize (those belong to /assets/ingest). To delete assets BULK-00001…BULK-00100:
# 1) Enqueue. Capture the taskId from the JSON body.
TASK=$(curl -s -X POST "$BASE/api/v1/public/assets/bulk-delete" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: delete-bulk-batch-1" \
-d '{"identifiers":["BULK-00001","BULK-00002","BULK-00003","BULK-00100"]}' \
| sed -E 's/.*"taskId":"([^"]+)".*/\1/')
echo "taskId=$TASK"
# 2) Poll until status is COMPLETE.
curl -s "$BASE/api/v1/public/assets/tasks/$TASK" -H "X-API-Key: $KEY"
Completed response (shape):
{
"taskId": "ckv8…", "mode": "DELETE", "status": "COMPLETE",
"progress": 1, "progressPercent": 100,
"counts": { "received": 100, "deleted": 98, "skipped": 2, "failed": 0,
"inserted": 0, "updated": 0, "retired": 0 },
"issues": {
"data": [
{ "rowIndex": 42, "identifier": "BULK-00043", "code": "not_found",
"message": "No asset found with identifier \"BULK-00043\".", "severity": "WARNING" }
],
"pagination": { "cursor": null, "hasMore": false }
}
}
counts.deleted is how many were archived; identifiers with no live asset are
skipped with a not_found notice (delete is idempotent — re-running is safe).
The full 100-identifier body is in examples/public-asset-api/bulk-delete-100.json.
Bulk update (patch many assets)
Each item is { "identifier", …changed fields } (same fields as the single-asset
PATCH). Rows with no matching asset, or with no changed fields, are skipped:
TASK=$(curl -s -X POST "$BASE/api/v1/public/assets/bulk-update" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: update-bulk-batch-1" \
-d '{"updates":[
{"identifier":"BULK-00001","conditionGrade":"B","locationName":"DC-West / Rack 04"},
{"identifier":"BULK-00002","year":2022,"storageCapacity":"3.84TB"}
]}' \
| sed -E 's/.*"taskId":"([^"]+)".*/\1/')
curl -s "$BASE/api/v1/public/assets/tasks/$TASK" -H "X-API-Key: $KEY"
Completed: counts.updated is how many were patched; issues lists any
not_found / no_updatable_fields skips. Paginate large reports with
?issuesCursor=<cursor>&limit=200. Example bodies:
examples/public-asset-api/bulk-update-10.json.
Tip: poll about once per second; small batches usually finish in a few seconds. A repeated
Idempotency-Keyreturns the sametaskIdinstead of re-running.
Errors
Every 4xx/5xx response uses one envelope:
{
"error": {
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Request validation failed.",
"path": "/api/v1/public/assets/ingest",
"request_id": "trace_abc123",
"timestamp": "2026-06-09T18:00:00.000Z",
"errors": [
{ "field": "assets[0].identifier", "message": "identifier is required.", "code": "REQUIRED" }
]
}
}
Always log request_id and include it in support tickets — it ties your request
to our traces. errors[] is present only for validation failures.
code |
HTTP | Meaning |
|---|---|---|
UNAUTHORIZED |
401 | Missing/invalid credential. |
INVALID_API_KEY |
401 | API key not recognized. |
REVOKED_API_KEY |
401 | API key was revoked. |
FORBIDDEN |
403 | Authenticated but not allowed. |
FEATURE_NOT_AVAILABLE |
403 | Your org does not have API access enabled. |
FEATURE_LIMIT_REACHED |
403 | Plan usage limit hit. |
SUBSCRIPTION_REQUIRED |
403 | A subscription is required. |
RESOURCE_NOT_FOUND |
404 | The asset/task does not exist (or isn't yours). |
ROUTE_NOT_FOUND |
404 | No such endpoint. |
VALIDATION_ERROR |
400 | Request body/params invalid; see errors[]. |
CONFLICT |
409 | Conflicting state (e.g. idempotency mismatch). |
RATE_LIMITED |
429 | Too many requests; honor Retry-After. |
INTERNAL_ERROR |
500 | Unexpected server error; retry with backoff. |
SERVICE_UNAVAILABLE |
503 | Temporarily unavailable; retry with backoff. |