Running a Scan Through the API
Everything the console does when it runs a discovery scan is available over the API. This page is the end-to-end sequence: authenticate, assemble what the scan needs, run it, find out when it finished and read what it found.
The route catalogue is on the API Reference page and the session model is on the API Authentication page. This page is about the order they go in.
The model in one paragraph
A template holds sensor configuration — which sensors to run and with what settings. A task binds one or more templates to a set of hosts. A deployment is one run of a task. Scanning through the API therefore means creating or finding a template, creating a task that points at it and at some hosts, and then posting a deployment. Nothing is scanned by naming a target directly; the target has to be a host the platform knows about, because that is what results are attributed to.
Step 1 — Authenticate
POST /cbomsrv/api/v1/auth/login
Content-Type: application/json
{ "username": "svc-automation", "password": "..." }
The response carries a session_id. Send it as a bearer token on every subsequent request:
Authorization: Bearer <session_id>
Sessions are short-lived. A long-running integration should call POST /auth/auto-renew periodically rather than logging in again each time — see API Authentication for the renewal rules.
Use a dedicated service account with the narrowest role that works. Deploying a task needs admin, manager or user. Reading results needs less. An integration that only exports does not need an account that can deploy.
Step 2 — Find the hosts
GET /cbomsrv/api/v1/hosts/
Each host carries a hostId, which is what a task refers to. To add a host as part of the automation:
POST /cbomsrv/api/v1/hosts/
Content-Type: application/json
{
"hostName": "app01.corp.example.com",
"ipAddress": "10.20.30.41",
"osType": "linux",
"credentials": { "username": "cbom-agent", "password": "..." }
}
The response returns the allocated hostId. Credentials are encrypted on write and are never returned by any read — a subsequent GET shows a mask, and posting that mask back does not overwrite the stored value.
For a bulk load, POST /hosts/import-csv/ runs as a background job and GET /hosts/import-csv/status/{jobId}/ reports its progress.
Step 3 — Find or create a template
GET /cbomsrv/api/v1/template/
To learn what a sensor accepts before configuring it:
GET /cbomsrv/api/v1/template/schemas/list/
GET /cbomsrv/api/v1/template/schemas/?name=Discover_SSH
The schema is the authoritative field list for that sensor in that deployment — it is what the console's own form is built from, so a template assembled against it cannot carry a field the sensor will reject.
Creating a template:
POST /cbomsrv/api/v1/template/define/
Content-Type: application/json
{
"templateName": "ssh-estate",
"sensors": [
{
"ExtName": "Discover_SSH",
"Hosts": ["bastion.corp.example.com", "jump02.corp.example.com"],
"timeout_seconds": 15
}
]
}
A cloned template lands in draft, deliberately. POST /template/clone/ never produces a finalised template, because an unreviewed copy that was immediately assignable to hosts is how a half-edited configuration reaches production. Activate it explicitly with POST /template/activate/.
Step 4 — Create the task
POST /cbomsrv/api/v1/tasks/assign-hosts/
Content-Type: application/json
{
"taskName": "nightly-ssh",
"templateIds": ["<templateId>"],
"hosts": ["10001", "10002"],
"tags": [3, 7]
}
tags are optional and are integer ids from GET /cmtags/. They propagate onto every material the scan produces, which is what makes a scoped dashboard possible later — so it is worth setting them here rather than trying to attribute results afterwards.
Step 5 — Run it
POST /cbomsrv/api/v1/deployment/
Content-Type: application/json
{ "taskId": "<taskId>" }
This is the call that does the work. It deploys the agent where one is needed, runs the sensors, and returns when the run has been dispatched and accounted for.
Read the counters in the response, not just the status code. The response reports, per host, how many documents were requested, inserted, updated and skipped, alongside any per-host errors. A 200 means the deployment ran, not that everything landed:
| Counter | Meaning |
|---|---|
requested_count | Documents the sensors produced |
inserted_count | New materials written |
updated_count | Existing materials refreshed |
skipped_count | Documents that failed validation and were discarded |
skipped_count is the one to watch. A document that fails to upsert leaves no other trace — this counter is the only record that it existed. A run with a non-zero skipped count has lost discovery data, and the cause is in the ingestion log.
A deployment that succeeded on some hosts and failed on others returns 200 with the failures named in the response. It is not a partial HTTP status; read the body.
Step 6 — Track status
There is no single "scan status" endpoint, because a run is distributed across hosts and transports. Three reads cover it:
| Read | Answers |
|---|---|
GET /tasks/status | The rollup across tasks — what is running now |
GET /deployment/history/?taskId=<id> | The runs of one task, newest first, with the same four counters and any errors |
GET /ui/utils/lastCMDocsInsertTime | When the inventory last changed, as a cheap freshness check |
GET /deployment/history/ is the one to poll. It records only the ingestion facts — the deployment ids, the errors, the counters and the time. It deliberately does not record the live agent tokens the deployment response carries.
Results arrive asynchronously. Agents publish findings to the message broker and the collector writes them as they land, so the inventory continues to fill after the deployment call has returned. Polling the deployment history is how you learn that it finished; polling immediately after the deploy call will show less than the run eventually produces.
Being told instead of polling
There is no per-scan callback registration — you do not hand the platform a URL when you start a scan. Scan lifecycle is handled by the notification engine instead, which is configured once and applies to every run.
GET /cbomsrv/api/v1/notification/catalog/events
The events relevant to a scan:
| Event | Raised when |
|---|---|
task.started | A task's run begins |
task.completed | A task's run finishes |
task.failed | A task's run fails |
deploy.host.succeeded | One host in the run completed |
deploy.host.failed | One host in the run failed |
cm.collect.completed | A collection finished landing in the inventory |
cm.collect.failed | A collection failed |
dedup.run.completed | The deduplication pass that resolves and rolls up materials finished |
cm.material.created | A material appeared that was not there before |
cm.material.resolved | A material stopped being seen and was resolved |
Destinations are registered with POST /notification/destinations and verified with POST /notification/destinations/{id}/verify. Supported outputs are in-app, email, Microsoft Teams, Jira and ServiceNow; GET /notification/catalog/outputs returns what the deployment actually has. A Teams destination takes an incoming webhook URL, which is the closest equivalent to a callback: a rule on task.completed with a Teams destination posts to your URL on every run.
Delivery is tracked — GET /notification/outbox shows what was sent and what failed, and POST /notification/outbox/{id}/retry resends.
Reading what the scan found
| Read | Returns |
|---|---|
GET /cyclonedx/export?... | The materials themselves, as CycloneDX 1.6 |
GET /cmhistory/ | What changed across the inventory, as a feed |
GET /analytic/host-analysis/ | The per-host risk rollup |
POST /ui/dashboard/widgets/{wid}/data | Any dashboard figure, under your own parameters |
Searching and filtering are covered on the Searching the Inventory page.
GET /cyclonedx/export requires a time bound. The all-types export will not run unbounded — an unbounded export of a large estate is a request that times out and takes resources with it. The per-type exports accept the same filters and are the better choice for an integration that syncs one material type.
A complete worked example
Running an existing task nightly and failing the job if anything was skipped:
BASE=https://cbom.example.com/cbomsrv/api/v1
SID=$(curl -s -X POST "$BASE/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"svc-automation","password":"'"$CBOM_PASSWORD"'"}' \
| jq -r .data.session_id)
RUN=$(curl -s -X POST "$BASE/deployment/" \
-H "Authorization: Bearer $SID" \
-H 'Content-Type: application/json' \
-d '{"taskId":"'"$TASK_ID"'"}')
echo "$RUN" | jq '.data'
SKIPPED=$(echo "$RUN" | jq '[.data[]?.skipped_count // 0] | add // 0')
if [ "$SKIPPED" -gt 0 ]; then
echo "WARNING: $SKIPPED document(s) were rejected during ingestion" >&2
exit 1
fi
curl -s -X GET "$BASE/auth/logout" -H "Authorization: Bearer $SID" > /dev/null
Log out when the automation finishes. A session left open stays valid until it expires, and a service account's session is worth as much as its password.
Common problems
401 on every call after the first. The session identifier is being sent as something other than a bearer token, or the session has expired. Sessions are short-lived by design; renew rather than re-authenticating in a loop.
403 on the deploy call. The account's role does not carry the deployment permission. Authorisation is enforced server-side from the Roles and Permissions page, so a role that can see the button in the console is not necessarily a role that can make this call — and the reverse is not true either.
404 on the deploy call. The taskId does not name a task. A task id from a different environment is the usual cause.
200 with zero inserted and zero updated. The scan ran and found nothing new. Where that is unexpected, check the per-host errors in the response before suspecting the sensor — an agent that could not reach its target reports here.
The inventory does not change after a successful deploy. Results travel through the broker, so a broker that is not consuming leaves them queued. Check the collector worker, and check skipped_count for documents the validator rejected.