> ## Documentation Index
> Fetch the complete documentation index at: https://developer-staging.secfix.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Working with inventory

> List and filter assets in every category. Create, update, and delete custom and information assets.

The inventory endpoints return your assets in every category: cloud resources, computers, custom
assets, information assets, and employees. You can read and filter all of them. You can create,
update, and delete only custom and information assets.

Reads require **Read** access (`all:read`). Writes require **Write** access (`all:write`). See
[Scopes](https://developer-staging.secfix.com/scopes.md).

## Categories

Every asset has a `categoryKey`:

| `categoryKey` | What it is | Extra fields | Writable |
| - | - | - | - |
| `CLOUD` | Cloud resources | `accountId`, `region` | No |
| `COMPUTER` | Managed computers | `serialNumber`, `osVersion` | No |
| `CUSTOM` | Custom assets | — | Yes |
| `INFORMATION` | Information assets | — | Yes |
| `EMPLOYEE` | Employees | `email` | No |

List the categories with `GET /v1/inventory/categories`.

## What you can write

You can create, update, and delete only `CUSTOM` and `INFORMATION` assets. Every other category,
including `CLOUD`, `COMPUTER`, and `EMPLOYEE`, is **read-only**. Their data comes from the Secfix
integrations that discover them, so the API can't change their owner, description, classification,
or tags either.

| Write | `CUSTOM`, `INFORMATION` | Any other category |
| - | - | - |
| Create and delete | Yes | `403 resource_not_writable` |
| Update `name`, `customIdentifier`, `dataStored`, governance metadata (owner, `description`, `classification`), and `tags` | Yes | `403 resource_not_writable` |

* An unknown `categoryKey` returns `400 validation_failed`.
* In a batch, an asset in a read-only category fails only that record, with `resource_not_writable`.
* Your workspace's category settings and the Secfix tags on an asset can stop some fields from
  changing. See [Update an asset](#update-an-asset) and [Update assets in a batch](#update-assets-in-a-batch).

## List and filter assets

`GET /v1/inventory/assets` returns a [cursor-paginated](https://developer-staging.secfix.com/pagination.md) list. Narrow it with query
parameters:

| Parameter | Filters by |
| - | - |
| `categoryKey` | Asset category, for example `CLOUD`. |
| `subKey` | Category sub-type, such as one kind of cloud resource. Must match `categoryKey` when you send both. |
| `hasOwner` | Whether an owner is assigned (`true` / `false`). |
| `hasDescription` | Whether a description is set. |
| `classification` | `RESTRICTED`, `CONFIDENTIAL`, `INTERNAL`, `PUBLIC`, `UNKNOWN`. |
| `ownerStatus` | `COMPLETE` (an owner is assigned) or `INCOMPLETE`. `ownerStatus=COMPLETE` is the same as `hasOwner=true`. |
| `auditStatus` | `COMPLETE` / `INCOMPLETE`: the audit status the Secfix app shows. Assets without a computed status match neither value. |
| `externalSource` | Assets you created with this `externalSource`. |
| `externalAssetId` | The asset with this `externalAssetId`. Requires `externalSource`. |

```bash theme={null}
curl "https://api.secfix.com/v1/inventory/assets?categoryKey=CLOUD&hasOwner=false&pageSize=50" \
  -H "Authorization: Bearer $TOKEN"
```

To get one asset by its `id`, call `GET /v1/inventory/assets/{id}`. An unknown `id` returns
`404 not_found`.

Every asset carries two read-only status fields:

* `ownerStatus` is `COMPLETE` when an owner is assigned, else `INCOMPLETE`.
* `auditStatus` is the audit status the Secfix app shows: `COMPLETE` when every field the asset's
  category requires for an audit is set, else `INCOMPLETE`. It's left out when Secfix hasn't
  calculated it yet.

## Create an asset

`POST /v1/inventory/assets` creates one `CUSTOM` or `INFORMATION` asset:

```bash theme={null}
curl -X POST "https://api.secfix.com/v1/inventory/assets" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 2d7e4f1a-6b3c-4d8e-9f0a-1b2c3d4e5f6a" \
  -H "Content-Type: application/json" \
  -d '{
    "categoryKey": "CUSTOM",
    "name": "Office badge printer",
    "customIdentifier": "SN-48213",
    "externalSource": "acme-cmdb",
    "externalAssetId": "printer-0042",
    "description": "Printer in the front office.",
    "classification": "CONFIDENTIAL",
    "ownerEmail": "alex@example.com",
    "dataStored": {
      "containsUserData": true,
      "containsPatientData": false,
      "userDataInfo": "Customer names and email addresses."
    }
  }'
```

The response is `201 Created` with the full asset, including its new `id`. If an asset with the same
`externalSource` and `externalAssetId` already exists, it is updated instead and the response is
`200 OK`. See [Create or update by your own id](#create-or-update-by-your-own-id).

| Field | Rules |
| - | - |
| `categoryKey` | Required. `CUSTOM` or `INFORMATION`, in any case. |
| `name` | Required. 1–255 characters. Surrounding spaces are removed. |
| `customIdentifier` | Optional. Up to 255 characters. |
| `externalSource` and `externalAssetId` | Optional. Send both or neither. Up to 255 characters each. Surrounding spaces are removed. |
| `description` | Optional. Up to 500 characters. |
| `classification` | Optional. `RESTRICTED`, `CONFIDENTIAL`, `INTERNAL`, `PUBLIC`, or `UNKNOWN`, in any case. Defaults to `INTERNAL`. |
| `ownerId` or `ownerEmail` | Optional. Send one of them, not both. |
| `dataStored` | Optional. `containsUserData`, `containsPatientData`, and `userDataInfo` (up to 500 characters). Fields you leave out stay unset. |

* `customIdentifier` is the serial number or asset ID shown in Secfix as **ID/Serial No.** You can edit
  it later.
* `customIdentifier` is **not** a deduplication key. Two assets can share the same `customIdentifier`
  or `name`. To identify an asset in your own system, use `externalSource` and `externalAssetId`.
* A create body has no `tags` field. If you send `tags`, the API ignores them. Set tags afterwards with
  the single-asset `PATCH`.
* Without an owner, the asset is still created. It shows as incomplete in Secfix until you assign one.
* The asset looks exactly like one added in the Secfix app.

### Set the owner

Set the owner with either field:

* `ownerId`: the employee's `id` from [`GET /v1/employees`](https://developer-staging.secfix.com/guides/employees).
* `ownerEmail`: the owner's email address, matched case-insensitively. It must belong to an active
  member of your workspace who is included in Secfix.

Sending both returns `400 validation_failed` on `ownerEmail`. If no member matches the email, the
request fails with `validation_failed` (`unknown owner`). If more than one member matches, it fails
with `validation_failed` (`ambiguous owner`). Send `ownerId` instead.

> [!NOTE]
>
> Without `externalSource` and `externalAssetId`, duplicates are allowed, so a create that runs twice
> makes two assets. After a `5xx`, nothing may be stored under your `Idempotency-Key`, so the same key
> doesn't stop a retry from creating the asset again. After a `5xx` or a timeout, wait a few minutes.
> Then check whether the asset exists before you retry. See [Idempotency](https://developer-staging.secfix.com/idempotency.md).

## Create or update by your own id

Send `externalSource` and `externalAssetId` on a create to tie the asset to its record in your own
system:

* `externalSource` names the system the asset comes from. You choose the value. Use the same value
  every time.
* `externalAssetId` is the asset's id in that system.

When an asset in the same category already has that pair, `POST /v1/inventory/assets` updates it
instead of creating a new one:

```bash theme={null}
curl -X POST "https://api.secfix.com/v1/inventory/assets" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f" \
  -H "Content-Type: application/json" \
  -d '{
    "categoryKey": "CUSTOM",
    "name": "Office badge printer",
    "externalSource": "acme-cmdb",
    "externalAssetId": "printer-0042",
    "classification": "RESTRICTED"
  }'
```

* No match: the asset is created, and the response is `201 Created`.
* A match: that asset is updated, and the response is `200 OK` with the same `id`. Only the fields you
  send change.
* A match on an asset that's hidden in the Secfix app returns `403 resource_not_writable`, and the
  asset isn't changed.
* Send both fields or neither. Sending only one returns `400 validation_failed` naming the missing one.
* The pair is unique per workspace, category, and `externalSource`, among assets that haven't been
  deleted. Values are matched exactly, so `acme-cmdb` and `ACME-CMDB` are different sources.
* The same pair in the other category is a different asset.
* The pair is set on create and can't be changed. A `PATCH` that sends `externalSource` or
  `externalAssetId` returns `400 validation_failed`.
* After you delete the asset, the pair is free again: the next create with it makes a new asset.
* If two requests create the same pair at the same moment, one of them gets `409 write_conflict`.
  Retry it after the `Retry-After` header: it then updates the asset the other request created.

Find the asset by its pair with `GET /v1/inventory/assets?externalSource=acme-cmdb&externalAssetId=printer-0042`.

## Create assets in a batch

`POST /v1/inventory/assets/batch-create` creates up to 100 assets in one request. Each record in
`records` has the same shape as a single create:

```bash theme={null}
curl -X POST "https://api.secfix.com/v1/inventory/assets/batch-create" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 4a9b2c7d-1e3f-4a5b-8c6d-7e8f9a0b1c2d" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [
      { "categoryKey": "CUSTOM", "name": "Office badge printer", "ownerEmail": "alex@example.com" },
      { "categoryKey": "CUSTOM", "name": "Lobby kiosk", "ownerEmail": "nobody@example.com" },
      { "categoryKey": "INFORMATION", "name": "Customer contracts", "classification": "RESTRICTED" }
    ]
  }'
```

The response is `200 OK` with one result per record. For each created record, `id` is the new asset's
id. See [Read a batch result](#read-a-batch-result).

* A batch can contain several records with the same name.
* A record whose `externalSource` and `externalAssetId` match an asset in its category updates that
  asset instead. Its result has `action: UPDATED`, so you can resend a whole list without creating
  duplicates.
* The same `categoryKey`, `externalSource`, and `externalAssetId` twice in one request rejects the
  whole request with `400 validation_failed` on `records`. Nothing is created.
* An owner mistake fails only that record, with `validation_failed` and `field` set to `ownerEmail` or
  `ownerId`. Owner mistakes are: sending both `ownerId` and `ownerEmail`, an unknown owner, or an
  ambiguous owner. The batch still returns `200`.

## Update an asset

`PATCH /v1/inventory/assets/{id}` updates one `CUSTOM` or `INFORMATION` asset. Send only the fields you
want to change:

```bash theme={null}
curl -X PATCH "https://api.secfix.com/v1/inventory/assets/$ASSET_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 5f3c9a2e-8b4d-4e7a-9c1f-2a5b6c7d8e9f" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Signed customer contracts, 2024 onward",
    "classification": "CONFIDENTIAL",
    "ownerEmail": "jane.doe@example.com",
    "tags": [{ "key": "environment", "value": "production" }]
  }'
```

The response is `200 OK` with the updated asset.

You can update these fields:

* `name`: 1–255 characters. Surrounding spaces are removed, and a blank name is rejected.
* `customIdentifier`: up to 255 characters.
* `dataStored`: `containsUserData`, `containsPatientData`, and `userDataInfo`. Fields you leave out
  stay as they are.
* `ownerId` or `ownerEmail`: the new owner. Send one of them, not both. The
  [owner rules](#set-the-owner) are the same as on create.
* `description`: up to 500 characters.
* `classification`: `RESTRICTED`, `CONFIDENTIAL`, `INTERNAL`, `PUBLIC`, or `UNKNOWN`, in any case.
* `tags`: up to 20 entries. Each `key` must be the name of a tag that already exists in your
  workspace. Create tags in the Secfix app first: the API can't create them, and an unknown key
  returns `400 validation_failed`. Each tag gets the `value` you send. `tags` replaces the asset's
  tags: a tag you leave out is removed, so send every tag you want to keep. `[]` removes them all.
  Leave `tags` out to keep the current tags.
* Tags that Secfix manages, such as `SecfixOwner`, come back on reads with `readOnly: true`. You
  can't write them: sending one in `tags` returns `400 validation_failed`. Send only keys that reads
  return with `readOnly: false`.

`externalSource` and `externalAssetId` are set on create and can't be changed. Sending either one in a
`PATCH` returns `400 validation_failed`.

```bash theme={null}
curl -X PATCH "https://api.secfix.com/v1/inventory/assets/$ASSET_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 8e2f6a1b-3c4d-4e5f-9a7b-0c1d2e3f4a5b" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Front office badge printer",
    "customIdentifier": "SN-48213-B",
    "dataStored": { "containsPatientData": true }
  }'
```

* Updating an asset in any other category (a computer, a cloud resource, or an employee) returns
  `403 resource_not_writable`.
* Changing the owner, `description`, or a `dataStored` field returns `400 validation_failed` naming
  the field when your category settings don't include that field, or a Secfix tag on the asset sets
  it, as in the Secfix app. Sending a field with the value it already has isn't a change, so it's
  never rejected. A rejected request changes nothing.

Every write except `DELETE` requires an [`Idempotency-Key`](https://developer-staging.secfix.com/idempotency) header.

## Update assets in a batch

`PATCH /v1/inventory/assets/batch-update` updates up to 100 assets in one request. Each record in
`records` carries an asset `id` and its own values:

```bash theme={null}
curl -X PATCH "https://api.secfix.com/v1/inventory/assets/batch-update" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 9c1f2a5b-6c7d-4e8f-9a0b-1c2d3e4f5a6b" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [
      { "id": "3f1c9a2e-8b4d-4e7a-9c1f-2a5b6c7d8e9f", "classification": "INTERNAL" },
      { "id": "7a0e4b11-2c3d-4f5a-8b9c-0d1e2f3a4b5c", "ownerEmail": "alex@example.com", "name": "Lobby kiosk" }
    ]
  }'
```

The response is `200 OK` with one result per record. Each result's `id` is the id you sent. See
[Read a batch result](#read-a-batch-result).

* Each record accepts the same fields as the single-asset `PATCH`, except `tags`.
* A record that includes `tags` fails with `field` set to `tags`. Set tags with the single-asset
  `PATCH`.
* An asset outside `CUSTOM` and `INFORMATION` (a computer, a cloud resource, or an employee) fails with
  `resource_not_writable`.
* Records follow the Secfix app's **bulk edit**. Some fields can't be bulk-edited on an asset: fields
  not in your category settings, fields a Secfix tag sets, and fields marked not editable. This can
  include `classification`. Such a field is **skipped**, not rejected:

  * The other fields are saved, and the result is `SUCCESS` with the skipped fields in
    `skippedFields`.
  * When every field you sent is skipped, nothing is saved and the record fails with `not_editable`.

  The single-asset `PATCH` checks fewer fields: it rejects only an owner, `description`, or
  `dataStored` change that your category settings don't include or a Secfix tag sets, and saves the
  other fields.
* A record with no fields to change fails with `field` set to `body`.
* An owner mistake fails only that record, with `validation_failed` and `field` set to `ownerEmail`
  or `ownerId`.
* An unknown `id` fails with `not_found`.
* An id can appear only once. A repeated id returns `400 validation_failed`.

In this example, the first record sent `description` and `classification`, and its category doesn't
let you bulk-edit `description`. The second record sent only `classification`, which its category
doesn't let you bulk-edit:

```json Response theme={null}
{
  "results": [
    { "index": 0, "id": "3f1c9a2e-8b4d-4e7a-9c1f-2a5b6c7d8e9f", "status": "SUCCESS", "skippedFields": ["description"] },
    { "index": 1, "id": "7a0e4b11-2c3d-4f5a-8b9c-0d1e2f3a4b5c", "status": "ERROR", "code": "not_editable",
      "skippedFields": ["classification"], "message": "None of the sent fields can be edited on this asset: classification" }
  ]
}
```

## Delete an asset

`DELETE /v1/inventory/assets/{id}` deletes one `CUSTOM` or `INFORMATION` asset.

```bash theme={null}
curl -X DELETE "https://api.secfix.com/v1/inventory/assets/$ASSET_ID" \
  -H "Authorization: Bearer $TOKEN"
```

* The response is `204 No Content`.
* `DELETE` doesn't take an `Idempotency-Key` header.
* Deleting an asset that was already deleted returns `404 not_found`. So does an unknown id.
* Deleting an asset in any other category returns `403 resource_not_writable`.

## Delete assets in a batch

`POST /v1/inventory/assets/batch-delete` deletes up to 100 assets in one request. Each
record in `records` carries the `id` of one asset:

```bash theme={null}
curl -X POST "https://api.secfix.com/v1/inventory/assets/batch-delete" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 6b8c0d2e-4f5a-4b6c-9d7e-8f9a0b1c2d3e" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [
      { "id": "3f1c9a2e-8b4d-4e7a-9c1f-2a5b6c7d8e9f" },
      { "id": "7a0e4b11-2c3d-4f5a-8b9c-0d1e2f3a4b5c" }
    ]
  }'
```

The response is `200 OK` with one result per record. Each result's `id` is the id you sent. See
[Read a batch result](#read-a-batch-result).

* An unknown id, or one already deleted, fails with `not_found`.
* An asset outside `CUSTOM` and `INFORMATION` fails with `resource_not_writable`.
* An id can appear only once. A repeated id returns `400 validation_failed`.

## Read a batch result

The three batch endpoints (create, update, and delete) share these rules:

* A request carries 1–100 records.
* Every batch request requires an [`Idempotency-Key`](https://developer-staging.secfix.com/idempotency) header.
* If the request itself is invalid, the API answers `400` and writes nothing. That covers an empty
  list, more than 100 records, a malformed body, and a repeated id.
* An `id` in `records` that isn't a UUID fails the whole request with `400 malformed_request`.
* Otherwise the response is **always `200 OK`**, even when some or all records fail.
* Each record is saved on its own. A failing record never undoes the others.

The response lists one result per record, in request order:

```json Response theme={null}
{
  "results": [
    { "index": 0, "id": "3f1c9a2e-8b4d-4e7a-9c1f-2a5b6c7d8e9f", "status": "SUCCESS", "action": "CREATED" },
    { "index": 1, "status": "ERROR", "code": "validation_failed", "field": "ownerEmail", "message": "unknown owner" },
    { "index": 2, "id": "7a0e4b11-2c3d-4f5a-8b9c-0d1e2f3a4b5c", "status": "SUCCESS", "action": "UPDATED" }
  ]
}
```

| Field | Meaning |
| - | - |
| `index` | The record's position in your request, starting at 0. |
| `id` | On create, the new asset's id, or the id of the asset it matched by `externalSource` and `externalAssetId`. A failed create record has an `id` only when it matched an asset that then couldn't be updated. On update and delete, the id you sent. |
| `status` | `SUCCESS` or `ERROR`. |
| `action` | `CREATED` or `UPDATED`: what a successful create record did. Only on batch create. |
| `code` | Why the record failed: `validation_failed`, `resource_not_writable`, `not_editable`, `not_found`, `write_conflict`, or `internal_error`. Only on `ERROR`. |
| `skippedFields` | Batch update only. The fields you sent that weren't changed because the asset's category doesn't let them be bulk-edited. On `SUCCESS` the other fields were saved; on `not_editable` none was. Left out when every change was applied. |
| `field` | The request field that failed validation, or `body` when an update has no fields to change. Only on `validation_failed`. |
| `message` | A short explanation. Only on `ERROR`. |

Check `status` on every result. A `200` doesn't mean every record succeeded.

### Retry failed records

To retry, resend **only the failed records** under a **new** `Idempotency-Key`. Match them by `index`
to the records you sent.

* Fix `validation_failed`, `resource_not_writable`, `not_editable`, and `not_found` records before you
  resend them. A `not_editable` record needs a change in the asset's category settings in the Secfix
  app, or a single-asset `PATCH` after that.
* You can resend `internal_error` records as they are.
* Wait a second or two before you resend `write_conflict` records. The record result has no `Retry-After`.
* Reusing the original key replays the stored response, which is kept for at least 24 hours. It
  doesn't run the records again.
* The `index` values in the new response refer to positions in the new request.

### When a batch fails or times out

A batch that answers `5xx` or times out may have saved some records already, because each record is
saved on its own. Only a `2xx` response is stored under your `Idempotency-Key`. After a `5xx`, or a
request that never finished, nothing may be stored, and a resend under the same key can run every record
again, exactly like a new key.

Running a batch update or delete again is safe for your data. Wait a few minutes, then resend it under
the same key. A delete that runs again reports the assets the first run already deleted as
`ERROR not_found`. An update that runs again overwrites any edits made in Secfix in the meantime.

If every record in a batch create carries `externalSource` and `externalAssetId`, resend the whole batch
under a **new** `Idempotency-Key`. Records the first request already created are updated, not created
again (`action: UPDATED`).

Without them, a batch create can create an asset twice if the first request is still running when you
resend it. A request can keep running on our side after your client times out. To avoid duplicates:

1. Wait a few minutes, then [list your assets](#list-and-filter-assets) in that category and find the
   records from your request that already exist. Names and `customIdentifier` values don't have to be
   unique, so match them carefully, or send `externalSource` and `externalAssetId` next time.
2. List again a minute later. If the results changed, the first request is still running: wait and
   list again until two lists agree.
3. Send only the records that don't exist yet, under a **new** `Idempotency-Key`. Don't reuse the
   original key: if the first request finished with `200`, a changed body under that key returns
   `409 idempotency_conflict`.
4. Check `results` in the response, as described in [Read a batch result](#read-a-batch-result).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.