---
title: "List Users"
method: GET
path: "/users"
tags: ["users"]
---

# List Users

`GET /users`

## Request

- Base URL: `https://api.b2metric.com` (the document declares no server; this is the origin it was published from)
- URL: `https://api.b2metric.com/users`
- Auth: none declared

One page of users, decorated with realm roles and tenant groups.

The window used to be hard-coded to the first 20 with no offset, so a realm
with more users than that simply could not be browsed — and nothing said
so, which is what made a truncated list look like the whole one.

Whether more rows exist is reported in the `X-Has-More` header, and it is
derived by asking Keycloak for one row beyond the page and throwing it
away. That is deliberate, and replaces a /users/count route that lied:
Keycloak's user SEARCH excludes service accounts while its user COUNT
includes them, so on this realm the list returned 8 and the count said 14.
The screen claimed 14 users, six of which it could never show, and offered
a next page that resolved to nothing. Over-fetching asks the same endpoint
both questions, so the two cannot disagree — no service-account naming
convention to encode, and nothing to keep in sync when Keycloak changes.

The defaults reproduce the pre-paging behaviour: console-web resolves an
email through this route and so does the sso-admin MCP, and neither passes
a window. The body is still a bare array for the same reason — the extra
fact rides in a header so no caller has to change.

**Filters and a total.** `enabled`, `role` and `group` narrow the list, and
`total=true` asks for an exact `X-Total-Count`. Both need the whole set: a
role or group filter is an intersection (Keycloak cannot search by either)
and a count is only honest when it is the length of the set the page is cut
from. So those requests enumerate the search once (up to `ENUMERATION_CAP`)
and slice it; the total is never Keycloak's COUNT, which is the lie above.
A request that only wants a total on a realm past the cap falls back to the
window with no total (cut from the rows the enumeration already holds when it
can be, from a second search when it cannot); a role/group filter there is
refused, because filtering one page of a bigger set would show a short list
that reads as complete. Everything else keeps the cheap windowed path exactly
as it was.

**`X-Filters-Applied`.** When at least one of `role`, `group`, `enabled` was
requested, a 200 carries the names of the filters it honoured, comma-separated
in the fixed order `role,group,enabled` (`role,enabled`, or just `enabled`);
with none requested the header is absent. It is sent on the enumerated path
and the plain window alike. Absence from an older API means "not confirmed":
that API ignores the parameters, and a missing `X-Total-Count` cannot tell the
caller so, because a status filter past the cap legitimately has no total.

## Query parameters

- `q` string
- `offset` integer
- `limit` integer
- `role` string, nullable
- `group` string, uuid, nullable
- `enabled` boolean, nullable
- `total` boolean

## Headers

- `authorization` string, nullable

## Response `200`

Successful Response

- UserOut[]
  - `id` string, required
  - `username` string, nullable
  - `email` string, nullable
  - `enabled` boolean
  - `roles` string[]
  - `tenants` string[]
  - `groups` GroupRef[]
    - `id` string, required
    - `name` string, required

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/b2metric/apis/b2metric-license-api.md) - [All operations](https://skmtc.dev/b2metric/apis/b2metric-license-api/llms.txt) - [OpenAPI document](https://skmtc.dev/b2metric/apis/b2metric-license-api/revisions/5ae75adf784e?raw)
