> ## Documentation Index
> Fetch the complete documentation index at: https://docs.2501.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# API Overview

> Drive 2501 as code over the versioned /api/v1 HTTP API

**`/api/v1`** on your Command Center host is the stable, documented HTTP API: what you script against to drive 2501 from a pipeline, a CMDB sync, or a shell script. It is a real contract, not a private back end that happens to be reachable - Command Center's own hosts and agents screens are built on these same endpoints.

```bash theme={null}
curl "https://<your-command-center-host>/api/v1/hosts?org_id=<org-id>" \
  -H "Authorization: Bearer $API_KEY"
```

```json theme={null}
{
  "object": "list",
  "data": [{ "id": "hst_8272f9b4-...", "name": "web-01" }],
  "has_more": false
}
```

A call that returns `200` is how you confirm a key works. What the key can reach is fixed at creation - see [Authentication](#authentication).

## What is in the API

| Resource | Endpoints |
| - | - |
| **Hosts** | list, search, read, create, update, delete, the agents on a host, bulk import and export |
| **Agents** | list, search, read, create, update, archive, its tasks, its plugins, connection test, bulk import and export |
| **Plugins** | list, search, read, create, update, delete, the agents and specialties it is assigned to |
| **Blacklists** | list, search, read, create, update, delete |
| **Specialties** | list, search, read, create, update, delete, the agents and rules referencing it |
| **Gateways** | list, search, read, create, update, delete, what references it |
| **Credentials** | list, search, read, create, update, delete, what references it. The secret value is write-only: no response carries it |
| **Operational Rules** | list, search, read, create, update, delete |
| **Scenarios** | list, read, create, update, delete, run, poll or stop a run |
| **Benchmarks** | run a set of scenarios as one benchmark |
| **Webhooks** | list, create, delete. Creating one returns its secret or signal key once, and for ServiceNow the Business Rule to add |
| **Signals** | receive a Splunk alert, with the organization's signal key |

Every endpoint - with its request and response schema and a live request builder - is in the **API Reference** in the sidebar.

> [!NOTE]
>
> Other things Command Center manages - tickets, jobs, knowledge - are still served from unversioned routes that exist for the UI and are **not** part of the `v1` contract yet. They can change in any release. Resources move under `/api/v1` release by release, and only what is documented here is stable.

## Authentication

Two ways in, and every `/api/v1` endpoint but one accepts either:

* An **API key** as a bearer token. This is the one for scripts and integrations.
* A **Command Center session cookie**. This is what the web UI uses.

The exception is `POST /api/v1/signals`, where Splunk sends its alerts: it takes only a signal key, created with `POST /api/v1/webhooks` (`source_type` `splunk`), and refuses an API key or a session with `401`.

Generate a key in **Settings** → **API Keys**, or read [API Keys](https://docs.2501.ai/0.19/configure/api-keys.md) for the full walkthrough of scopes, expiry, and revocation. The raw key is shown once, at creation.

```bash theme={null}
curl "https://<your-command-center-host>/api/v1/hosts?org_id=<org-id>" \
  -H "Authorization: Bearer 2501_ak_..."
```

Before you script against it:

* **A key is an administrator inside its own scope.** Full read and write on its organization, or on every organization in the tenant for a tenant-scoped key. That reach is frozen at creation: it does not follow the person who created it.
* **A key never reaches anything outside `/api/v1`.** The bearer header is ignored elsewhere, and the CC-only `/api/internal` routes reject it outright. Notably, a key cannot create, list, or revoke keys - that stays session-only, so a leaked key cannot mint a successor.
* **A bad key is a hard failure, never a downgrade.** An invalid, revoked, or expired key gets `401 UNAUTHORIZED`; it never falls back to an anonymous request.

> [!WARNING]
>
> Treat a key as a password. It is a full administrator within its scope, and it does not expire unless you asked it to.

## Organizations

Use `GET /api/v1/organizations` to list organizations you can access, and
`GET /api/v1/organizations/{id}` to read one.

A tenant-scoped key creates an organization with `POST /api/v1/organizations`
and archives one with `POST /api/v1/organizations/{id}/archive`. An org-scoped
key gets `403` on both. A name already used by an active organization is a
`409`, and so is archiving the tenant's last active organization. Renaming and
unarchiving stay in Command Center, for a tenant-level administrator.

```bash theme={null}
curl -X POST "https://<host>/api/v1/organizations" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "demo-lab", "description": "Lab for the Q4 demo"}'
```

Hosts and agents belong to exactly one organization, so **anything addressing a collection has to name one**:

* reading a collection (`GET /hosts`, `/hosts/search`, `/hosts/export`) carries `org_id` in the query string,
* creating (`POST /hosts`) carries `org_id` in the body, or in the query string for a CSV `batch`,
* anything addressed by id - a read, an update, a delete, an action, a sub-resource - carries none. The id already fixes the organization.

Omitting it where it is required is a `400`. Naming one you cannot reach fails in one of two ways, and the difference is deliberate: an **org-scoped** caller pointing anywhere outside its own organization gets `403 ORG_ACCESS_DENIED`, while a **tenant-scoped** caller naming an organization that is not in its tenant gets `404 NOT_FOUND` - another tenant's organizations are invisible, not merely forbidden.

```bash theme={null}
# The organization id is on the Organizations page in Command Center,
# and in the URL of any of its resources.
curl "https://<host>/api/v1/agents?org_id=org_460b541c-..." \
  -H "Authorization: Bearer $API_KEY"
```

Credentials, operational rules, specialties and blacklists can also belong to the whole tenant instead of one organization. Their lists take `org_id` as a filter, not a requirement: with it you get that organization's rows plus the tenant-wide ones, and without it everything the caller can see. Creating one with no organization makes it tenant-wide, which takes a tenant-scoped caller; an org-scoped caller gets `403`. An org-scoped caller can read tenant-wide rows but cannot change or delete them.

## Reading lists

Every list endpoint answers with the same envelope:

```json theme={null}
{
  "object": "list",
  "data": [{ "id": "hst_8272f9b4-...", "name": "web-01" }],
  "has_more": false
}
```

Pages are **cursors**, not page numbers. Ask for `limit` rows, then pass the **id of the last row you got** as `starting_after` to get the next page. `has_more` tells you when to stop.

| Parameter | Description |
| - | - |
| `limit` | Rows per page. Default 25, maximum 100 |
| `starting_after` | Id of the last row of the previous page. Walks forward |
| `ending_before` | Id of the first row of the previous page. Walks backward. Cannot be combined with `starting_after` |
| `include=total_count` | Adds `total_count` to the envelope. Left out otherwise, because most callers iterating a list never need the count |

```bash theme={null}
# Walk a fleet, 100 at a time.
curl "https://<host>/api/v1/hosts?org_id=<org>&limit=100" -H "Authorization: Bearer $API_KEY"
curl "https://<host>/api/v1/hosts?org_id=<org>&limit=100&starting_after=hst_8272f9b4-..." \
  -H "Authorization: Bearer $API_KEY"
```

A cursor names a row rather than a position, so a walk is safe while other people are writing: nothing gets skipped or served twice because rows were inserted above you. There is no `page` parameter anywhere in `v1`. A cursor id that does not resolve - a deleted row, a row in another organization - is a `400 INVALID_CURSOR` rather than a silent jump back to page one.

## Writing

**`POST` creates and `POST` updates.** `POST /api/v1/hosts` creates a host; `POST /api/v1/hosts/{id}` updates that one. There is no `PUT` in `v1` - sending one is a `405 METHOD_NOT_ALLOWED` that names the verbs the endpoint does have, never a silent no-op.

An update is a **partial** update, and the rules are the same everywhere:

| In the body | Effect |
| - | - |
| field absent | left unchanged |
| field with a value | set to that value |
| field explicitly `null` | cleared, where the field is nullable |

Creating or updating a plugin, and updating an agent, refuse a field the endpoint does not know with `400 VALIDATION_FAILED`, for example `enable is not a known field`, so a misspelt field never answers `200` while changing nothing. An agent update still takes `host_id` when it names the agent's current host, as a client that sends the whole agent back does; an agent never changes host, so another one answers `400`.

```bash theme={null}
# Only knowledge changes. Everything else on the host is untouched.
curl -X POST "https://<host>/api/v1/hosts/hst_8272f9b4-..." \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"knowledge": "Runs the billing API. Restart with systemctl restart billing."}'
```

Verbs that are not create-read-update-delete are `POST` actions on an item: `POST /api/v1/agents/{id}/archive`, `POST /api/v1/agents/{id}/test-connection`.

**Success is always `200`**, creates included. There is no `201` and no `204`.

## Technologies, applications and tags

A host's [technologies, applications and tags](https://docs.2501.ai/0.19/core-concepts/hosts.md#technologies-applications-and-tags) come back in full on every host read - a list, a search, `GET /hosts/{id}`, and the response to a create or an update:

```json theme={null}
{
  "id": "hst_8272f9b4-...",
  "name": "db-01",
  "technologies": [
    { "id": "tech_5c2e9a1f-...", "name": "PostgreSQL", "known": true, "logo": "postgresql" },
    { "id": "tech_0b7d3e55-...", "name": "Temenos T24", "known": false, "logo": null }
  ],
  "applications": [{ "id": "app_8d41b7c3-...", "name": "Payroll", "description": "Monthly salary runs" }],
  "tags": [{ "id": "tag_3f9a6c20-...", "name": "customer-facing", "description": null, "category": null }]
}
```

`known` says whether a technology is a known one or a custom one. `logo` names its logo, or is `null` when it shows the generic technology icon, as every custom technology does. A tag's `category` is `{ "id": "tcat_...", "name": "Environment" }` when it is in one, or `null`.

### Writing a host's lists

`POST /hosts` and `POST /hosts/{id}` take `technologies`, `applications` and `tags`, each a list of entries named by `id` or by `name`:

```bash theme={null}
curl -X POST "https://<host>/api/v1/hosts/hst_8272f9b4-..." \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"technologies": [{"name": "postgres"}, {"name": "Kafka"}], "applications": [{"id": "app_8d41b7c3-..."}, {"name": "Billing"}]}'
```

* **A list you send replaces the host's list, and a list you leave out keeps it.** The host above ends with exactly PostgreSQL and Kafka as technologies and Payroll and Billing as applications, and its tags are untouched.
* **An `id` must exist in the host's organization**, or the write fails. When an entry carries both, the `id` wins and the `name` is ignored, so a list read from the API can be written back as it is, even after a rename.
* **A `name` is matched whatever its case, and created when missing.** A technology name that is a known technology's name or other name becomes that known technology, so `postgres` gives PostgreSQL. Any other technology name becomes a custom technology. Names follow the [name rule](https://docs.2501.ai/0.19/core-concepts/hosts.md#names).
* **A write that fails creates nothing.**
* **A tag sent by `name` can carry the `category_id` it is created in.** A tag that already exists keeps its own category, because a tag's name is unique in the organization whatever its category.

### Filtering hosts and agents

`GET /hosts` and `GET /agents` filter by entry, each parameter taking comma-separated ids:

| Parameter | Matches |
| - | - |
| `technology` | Hosts with any of these technologies |
| `application` | Hosts with any of these applications |
| `tag` | Hosts with any of these tags in each of their categories, the tags in no category counting as one more |
| `none` | Hosts with nothing in the named groups, any of `technology`, `application` and `tag` |
| `none_category` | Hosts with no tag in these tag categories |

The groups combine: `technology=<PostgreSQL id>,<Redis id>&application=<Payroll id>` is hosts that have PostgreSQL or Redis, and Payroll. Each tag category is a group of its own, so prod and staging in Environment match either, while prod and dba in Team match both. An agent matches through its host, so an agent with no host never matches a filter.

```bash theme={null}
# Hosts running PostgreSQL that have no application yet
curl "https://<host>/api/v1/hosts?org_id=<org>&technology=tech_5c2e9a1f-...&none=application" \
  -H "Authorization: Bearer $API_KEY"
```

## Errors

Every error, on every endpoint, has the same flat body:

```json theme={null}
{
  "code": "VALIDATION_FAILED",
  "message": "Validation failed",
  "request_id": "req_3702b7e7-...",
  "errors": [{ "field": "org_id", "message": "org_id field has an invalid format" }]
}
```

| Field | When |
| - | - |
| `code` | Always. Branch on this, not on `message` |
| `message` | Always. Human-readable, and free to be reworded |
| `request_id` | Always, and repeated in the `X-Request-Id` response header. Quote it in a support request |
| `field` | When one input is at fault |
| `errors` | When several are. Every failing field at once, not just the first |
| `details` | Extra context for some codes, such as the id that was not found |

The codes you will actually meet:

| Status | `code` | Meaning |
| - | - | - |
| 400 | `VALIDATION_FAILED` | A field is missing, malformed, or not allowed. See `errors` |
| 400 | `BAD_REQUEST` | The request is well-formed but wrong, for example a tag id that does not exist in the host's organization |
| 400 | `INVALID_CURSOR` | `starting_after` / `ending_before` names a row that is not there |
| 401 | `UNAUTHORIZED` | No credentials, or a key that is invalid, revoked, or expired |
| 403 | `FORBIDDEN` | Authenticated, but not allowed - including an auditor attempting a write |
| 403 | `ORG_ACCESS_DENIED` | The organization is real but out of this caller's reach |
| 403 | `LICENSE_CAP_REACHED`, `LICENSE_EXPIRED` | The write would exceed the licensed host count, or the licence is no longer valid |
| 404 | `NOT_FOUND` | No such row **that this caller can see**. A host or agent in another organization is a 404, never a 403. Also an endpoint that does not exist |
| 405 | `METHOD_NOT_ALLOWED` | The path is real, the verb is not - `PUT` on anything, for instance. `details.allowed` and the `Allow` header list the verbs it does have |
| 409 | `CONFLICT`, `DUPLICATE_KEY`, `FOREIGN_KEY_VIOLATION`, `RESOURCE_IN_USE` | The write conflicts with what is already there, or the row is still referenced |
| 500 | `INTERNAL_ERROR` | Our fault. `request_id` is what to send us |
| 5xx | `ENGINE_ERROR` | Command Center is fine; the engine behind it is not. Only reachable from endpoints that call the engine, such as `test-connection` |

`code` is drawn from a closed set, so it is safe to branch on. The table above is the part of that set a normal caller meets; a few others (`TIMEOUT`, `PAYLOAD_TOO_LARGE`, `RATE_LIMITED`) exist for the same reasons they do in any HTTP API. Treat an unrecognised `code` by its status class.

## Rate limiting

`v1` sets no rate limit of its own, but Command Center applies a **per-deployment** one in front of every route, and a request over it comes back `429 RATE_LIMITED`. The default allows a few hundred requests a minute per client, which no ordinary integration approaches - it is there to stop a runaway loop, not to meter you. Because an operator can change it, treat the exact number as a property of the deployment you are calling, not of the API: back off on a `429` rather than pacing yourself to a constant.

Two things do have fixed limits, and they are the ones a bulk caller hits first: a list page is capped at **100 rows** (`limit`), and import and export are capped at **10000 rows**.

Bulk import is the one deliberate exception: `POST /{resource}/batch` returns `200` with a verdict per row even when every row failed, because the per-row results *are* the answer. A `4xx` there means the request itself was wrong.

## Versioning

`v1` is a promise about shapes, not a frozen file. Fields and optional parameters get **added**; nothing documented here is renamed, removed, or given a new meaning without a new version. So:

* read fields by name and ignore the ones you do not know,
* do not depend on field order or on the absence of a field,
* do not depend on undocumented fields. A response may carry extras the UI needs; those are not part of the contract,
* treat a documented enum as open unless it is stated closed. A host's `target_type` is a closed set; a task's `status` is a state machine that gains steps.

There is no deprecation channel today: the versioned path is the whole signal, and a `v2` would be a new path served beside `v1`. Watch the release notes for the release you upgrade to.

> [!NOTE]
>
> Host `tags` changed once inside `v1`, when hosts gained technologies and applications: it was a list of `axis:value` strings such as `tech:postgres`, and is now a list of tag entries beside the `technologies` and `applications` lists. Old values are not translated. A script written against the strings needs updating.

## Next

> [card] [API Keys](https://docs.2501.ai/0.19/configure/api-keys.md)
>
> Generating, scoping, and revoking keys.

> [card] [Import and Export](https://docs.2501.ai/0.19/configure/import-export.md)
>
> Bulk CSV and JSON, in and out.


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