The Wazuh API in 5.0 is two APIs. The manager API on port 55000 handles agents, groups, the cluster and RBAC, authenticating with HTTP Basic to get a JWT and then sending that JWT as a Bearer token. The indexer API on port 9200 is where the security data lives: events, findings, the state indices, detection content and alerting. A script that onboards a customer touches the first; a script that reports on what was detected touches the second. This lesson builds a small Python client for both.

What you need

  • Network access to the manager API on 55000 and, for the second half, the indexer API on 9200.
  • Credentials for a manager API user. The defaults are wazuh and wazuh-wui; the password management tool rotates them.
  • Credentials for an indexer internal user, such as admin.
  • Python 3 and requests.

The manager API reference and the indexer API reference each publish a browsable OpenAPI specification. Keep both open; they are the only complete list of what survived into 5.0.

Authenticate to the manager API

The specification declares two security schemes. basicAuth is described as the “Intended method to get a token”, and jwt is a bearer token in JWT format, which is the global security requirement for every other operation.

So the flow is always: Basic once, Bearer thereafter.

curl -sk -u <USER>:<PASSWORD> -X POST "https://<MANAGER>:55000/security/user/authenticate?raw=true"

The raw parameter returns the token as text/plain rather than wrapped in the standard response envelope, which is convenient in a shell. Without it the token arrives inside a JSON data object.

Check the token against GET / before anything else. It is the cheapest confirmation that you have the right host, the right port and a live credential, and it tells you exactly which build you are automating against:

{
   "data": {
      "title": "Wazuh API REST",
      "api_version": "5.0.0",
      "revision": "beta5",
      "license_name": "GPL 2.0",
      "license_url": "https://github.com/wazuh/wazuh/blob/v5.0.0-beta5/LICENSE",
      "hostname": "wazuh.manager",
      "timestamp": "2026-09-23T02:34:43Z"
   },
   "error": 0
}

revision is the field to assert on while 5.0 is in beta. Behaviour changed between beta builds, and a script that logs the revision it ran against costs nothing and saves an argument later.

The documented expiry is worth internalising. The endpoint description says the token “will expire after auth_token_exp_timeout seconds (default: 900)” and that the value is changed with PUT /security/config. Fifteen minutes is short enough that a long-running script must handle re-authentication, and long enough that nobody notices until the script has been running in production for a month.

In Python, the pattern is a session that fetches a token lazily and refreshes it on a 401:

resp = session.post(
    f"{base_url}/security/user/authenticate",
    auth=(user, password),
    verify=verify,
    timeout=timeout,
)
resp.raise_for_status()
token = resp.json()["data"]["token"]
session.headers["Authorization"] = f"Bearer {token}"

The full client, with retry-on-401 and TODO markers for credentials, is in the companion files for this lesson.

The response envelope

Every manager API response uses the same shape, which makes generic handling easy once you know it. For operations that affect a set of items, the data object carries affected_items, total_affected_items, total_failed_items and failed_items. The documented example for PUT /agents/upgrade is:

{
  "data": {
    "affected_items": ["001", "002"],
    "total_affected_items": 2,
    "total_failed_items": 0,
    "failed_items": []
  },
  "message": "All upgrade tasks were created",
  "error": 0
}

Two things follow from that shape, and both catch people out.

A 200 response does not mean everything worked. A bulk operation can return 200 with items in failed_items, which is where per-item errors live. Always check total_failed_items, not just the HTTP status.

And error: 0 at the top level is the API’s own status field, separate from both the HTTP code and the per-item failures. A client that survives contact with production checks all three.

List agents

GET /agents returns information about all available agents or a list of them. The parameter list is long and most of it is filtering: agents_list, offset, limit, select, sort, search, status, q, older_than, the os.* family, version, group, name, ip, registerIP and distinct.

Before you write any of it, look at the empty case. On a manager with nothing enrolled, GET /agents returns:

{
   "data": {
      "affected_items": [],
      "total_affected_items": 0,
      "total_failed_items": 0,
      "failed_items": []
   },
   "message": "No agent information was returned",
   "error": 0
}

HTTP 200, error: 0, and a message that reads like a failure. It is not one. A 5.0 manager has no agent zero and no implicit self-registration, so “no agents” is the correct answer on a fresh install. Do not write a health check that treats that message as an error.

Three of those do most of the work in practice.

select trims the response to the fields you need, which matters because the Agent schema carries version, id, name, status, group, ip, registerIP, dateAdd, lastKeepAlive, an os object and a numeric status_code. Pulling all of it for ten thousand agents to count disconnections is wasteful.

limit and offset page the result. There is a default limit, so a script that does not page will silently truncate. The client in the repository pages until affected_items comes back short.

group filters to one agent group, which is the natural unit for per-tenant or per-role reporting. Group design is covered in agent groups and centralised configuration. If your agents run in Kubernetes, the same listing is how you confirm that the DaemonSet enrolled one agent per node and did not leave duplicates behind.

Create a group and add an agent

Order matters here more than anywhere else in the 5.0 API. Groups must exist before an agent enrols into one, so a provisioning script creates the group first, always.

POST /groups takes a CreateGroupBody with a single required field:

{
  "group_id": "NewGroup_1"
}

The name can contain a-z, A-Z, 0-9, _, - and ., with a maximum length of 128. The names . and .. are restricted. The documented success response is:

{
  "message": "Group 'NewGroup_1' created",
  "error": 0
}

Then POST /agents adds the agent. The body requires name and optionally takes ip and force:

{
  "name": "NewHost_2",
  "ip": "10.0.10.11"
}

If ip is omitted the API derives it from the request. The response carries the pieces you need to configure the endpoint:

{
  "data": {
    "id": "009",
    "key": "MDA5IE5ld0hvc3RfMiAxMC4wLjEwLjExIDhkNjllMzRiYTM2ZjcwMzgzZmVkNTIyNzk4MzFiZDg2NWQzZGRkZjJhMTkwZWRjMjY5YmRhOTY1ODU4M2JkMjg="
  },
  "error": 0
}

That base64 key is the whole reason this endpoint survived into 5.0. It is how you provision an agent without the endpoint ever talking to authd, and it is how the 4.x to 5.0 migration imports an existing client.keys. The migration runbook uses exactly this call.

Assign the agent to the group with PUT /agents/{agent_id}/group/{group_id}. To retrieve a key later, use GET /agents/{agent_id}/key, which returns the same base64 form inside affected_items.

Upgrade agents

PUT /agents/upgrade upgrades agents using a WPK file from the online repository. The parameters include agents_list, wpk_repo, upgrade_version, use_http, force and package_type, plus the same os.* and group filters as the listing endpoint, so you can upgrade by group rather than by enumerating IDs.

One operational note from the description: “When upgrading more than 3000 agents at the same time, it’s highly recommended to use the parameter wait_for_complete set to true to avoid a possible API timeout.”

PUT /agents/upgrade_custom does the same with a WPK you supply. GET /agents/outdated lists the agents that need it, which is the right way to drive a staged rollout. The agent_upgrade command-line tool and the WPK constraints are covered in upgrade, backup and retention.

Query findings on the indexer API

The manager API tells you about your deployment. It does not tell you what was detected. For that you go to the indexer on port 9200, which the specification describes with the same authentication options: HTTP Basic credentials or a Bearer JWT.

The endpoint groups are worth scanning once, because the surface is larger than most people expect. Alongside the standard OpenSearch families (Search, Cat, Index, Document, Cluster, Snapshot management, Index state management, Nodes stats) sit the Wazuh-specific ones: KVDBs, Decoders, Filters, Rules, Integrations, Logtest, Promotion, Spaces, Policy, Detectors, Alerts, Findings, plus Alerting monitors, Notification channels, Reporting and the security plugin’s Roles, Role mappings and Internal users.

Rules-as-code automation lives here, not on the manager API.

A findings query

Findings are stored in wazuh-findings-v5-<category> data streams. A plain search against the pattern works:

curl -sk -u <INDEXER_USER>:<INDEXER_PASSWORD> -X POST "https://<INDEXER>:9200/wazuh-findings-v5-*/_search" -H 'Content-Type: application/json' -d @query.json

The fields to query on come from the Wazuh Common Schema. The ones that carry a findings query:

Field What it holds
wazuh.rule.level severity as a string: informational, low, medium, high, critical
wazuh.rule.title the rule’s human-readable title
wazuh.rule.id the rule identifier, a UUID
wazuh.agent.name, wazuh.agent.id which endpoint
wazuh.agent.groups which group, so which role or tenant
wazuh.integration.name which log source produced it
event.original the original raw log
@timestamp when

Note wazuh.rule.level is a string, not an integer. A range query on it does nothing useful. Use a terms query over the severity names you care about.

The repository ships a query that aggregates findings by rule title over the last seven days, which is the starting point for the noise budget described in cutting false positives.

State indices

Agent state is not in the findings streams. It is in the stateful indices, which hold the latest known state rather than a time series:

wazuh-states-sca, wazuh-states-fim-files, wazuh-states-fim-registry-keys, wazuh-states-fim-registry-values, wazuh-states-vulnerabilities, and the wazuh-states-inventory-* family covering users, groups, packages, processes, services, ports, networks, interfaces, protocols, hardware, system, hotfixes and browser extensions.

This is where the removed manager API endpoints went. A 4.x script that called GET /sca/{agent_id} now queries wazuh-states-sca filtered on wazuh.agent.id. One that called GET /syscollector/{agent_id}/packages now queries wazuh-states-inventory-packages. The data is richer than it was, because SCA state documents carry the compliance mappings and MITRE references alongside the check result.

What it looks like when it runs

The client in the repository is one file with a subcommand per task:

python3 wazuh_client.py --help
usage: wazuh_client.py [-h]
                       {agents,groups,outdated,policies,onboard,findings,state} ...
...
positional arguments:
  {agents,groups,outdated,policies,onboard,findings,state}
    agents              list agents (paged)
    groups              list groups
    outdated            list outdated agents
    policies            show what this token is allowed to do
    onboard             idempotent: ensure group, add agent, assign
    findings            aggregate findings by rule (indexer API)
    state               read a wazuh-states-* index for one agent
...

Against the 5.0.0-beta5 lab, with two agents enrolled into two groups:

$ python3 wazuh_client.py agents
{"status": "active", "name": "lab-agent-container", "id": "001", "version": "v5.0.0", "group": ["default"], "lastKeepAlive": "2026-09-23T02:48:08+00:00"}
{"status": "active", "name": "lab-agent-ubuntu", "id": "002", "version": "v5.0.0", "group": ["lab-linux"], "lastKeepAlive": "2026-09-23T02:48:25+00:00"}

$ python3 wazuh_client.py groups
[
  {
    "name": "default",
    "count": 1,
    "mergedSum": "ca35f9148a3273413eccb79f149f4757",
    "configSum": "ab73af41699f13fdd81903b5f23d8d00"
  },
  {
    "name": "lab-linux",
    "count": 1,
    "mergedSum": "f69165383f8d6a6aafe9b34934d2fcb4",
    "configSum": "ab73af41699f13fdd81903b5f23d8d00"
  }
]

$ python3 wazuh_client.py outdated
[]

Three details from that output are worth carrying into your own client. group is an array, because an agent can be in several, so anything that treats it as a string will break the first time someone assigns a second group. lastKeepAlive is ISO 8601 with an offset, not an epoch. And outdated returning an empty list is a normal, successful answer, which is the same shape of trap as the empty agent listing above: page it, do not truth-test it.

Common errors

401 Unauthorized on every request after the first few minutes

The JWT expired. The default auth_token_exp_timeout is 900 seconds. Either refresh on 401, which is what the repository client does, or raise the timeout with PUT /security/config if your automation genuinely needs a longer session. Do not treat a longer token as free: it is a credential with a longer blast radius.

403 Forbidden on an endpoint that works in the dashboard

RBAC. Each operation in the specification declares the action it requires, for example group:create on POST /groups. The dashboard user has different policies from the one your script authenticates as. GET /security/users/me/policies shows what the current token is actually allowed to do, which is faster than guessing.

429 Too Many Requests

The API rate-limits, and nearly every endpoint in the specification declares a 429 response. Bulk work should use the endpoints’ own list parameters rather than a loop of single-item calls. Upgrading a group in one call is both faster and less likely to be throttled than a hundred individual calls.

A 200 response that did not do what you asked

Check total_failed_items and the failed_items array. Bulk operations report per-item failures inside a successful response. This is the single most common cause of a provisioning script that reports success and leaves half the estate unconfigured.

404 on /sca, /syscheck or /syscollector

Those paths do not exist in the 5.0 API. Query the corresponding wazuh-states-* index on the indexer API instead.

SSL verification failures against a fresh install

Expected, because the installation assistant issues its own CA. Point verify at that CA bundle rather than turning verification off.

Where to take this

The client in the repository is deliberately small: authenticate, page, handle errors, and two worked calls on each API. That is the part worth owning. Everything else in a real automation, onboarding a new group of endpoints, exporting a weekly findings summary, driving a staged upgrade, is composition on top of those four behaviours.

Next: Wazuh Upgrade, Backup and Index Retention.

Files for this lesson

Every file this lesson refers to, exactly as used. Open a file to read it in the browser, or download the set as a zip. Download wazuh-api-python.zip. The full set for all lessons is at wazuh-tutorial-files.zip.

Frequently asked questions

How do I authenticate to the Wazuh API?

POST to /security/user/authenticate on port 55000 with HTTP Basic credentials. The response carries a JWT, which you then send as a Bearer token on every other request. The token expires after auth_token_exp_timeout seconds, 900 by default, and you change that with PUT /security/config.

What is the default Wazuh API user?

There are two. The user wazuh is the default administrator for the manager API. The user wazuh-wui handles communications between the dashboard and the manager API. Both should have their passwords rotated at install time with the Wazuh passwords tool.

Which Wazuh API endpoints were removed in 5.0?

The 5.0 API specification has no paths under /manager, /sca, /syscheck, /rootcheck, /syscollector, /tasks, /rules, /decoders, /lists, /logtest, /active-response, /vulnerability or /events. Agent state that used to come from those endpoints now lives in the indexer state indices, queried through the indexer API on port 9200.

Does POST /agents still return the agent key?

Yes. Add agent returns a data object containing id and key, with the key base64 encoded. That is what makes scripted enrolment and 4.x key import possible. GET /agents/{agent_id}/key retrieves the key for an existing agent.

What is the difference between the manager API and the indexer API?

The manager API on port 55000 manages the deployment: agents, groups, cluster, MITRE reference data and RBAC. The indexer API on port 9200 is where the data lives: events, findings, state indices, detection content, alerting monitors and notification channels. Automation usually needs both.