git:20261027.cf31f30 to git:20261027.48dac88

1 added, 1 removed. Audit A to A.

---
name: open-webui-api
description: |-
Administer Open WebUI entirely via its REST API (v0.11.x): user/group lifecycle, permissions, model catalog GitOps (export/import/sync), knowledge/RAG pipelines, config-as-code, SCIM provisioning, event webhooks, and backup surfaces. Grounded in the v0.11.0 source — covers the 476-path surface the official docs leave ~96% undocumented, the auth bootstrapping traps (ENABLE_API_KEYS default-off, JWT 4-week expiry, one unscoped key per user), and the 0.10.0 breaking changes (access_control→access_grants with inverted public/private defaults, flat dot-keyed config) that silently break every pre-0.10 script and most LLM training-data knowledge.
when_to_use: |-
Trigger on "open-webui api", "openwebui rest", "open-webui automation", "provision open-webui users", "open-webui api key", "access_grants", "access_control open-webui", "models/sync", "open-webui config export", "configs/import", "PersistentConfig", "open-webui scim", "custom_params", "chat_template_kwargs open-webui", "open-webui webhook events", "/api/v1/", "api_type responses", or any task scripting/automating an Open WebUI instance (create users, manage groups, sync model catalog, upload knowledge, read analytics) rather than clicking the UI. Also trigger on symptoms: "API returns HTML", "403 with valid API key", "env var change ignored", "custom parameter shows [object Object]", "reasoning missing from API response". NOT for Open WebUI RAG/embedding wiring (open-webui-embeddings), multi-pod scaling (open-webui-valkey-websocket), or generic OpenAI-API client questions.
---
# Open WebUI REST API administration — operator reference
Target: operators who script Open WebUI (create users, reconcile model catalogs, drive RAG pipelines, export configs) instead of clicking the admin UI. Grounded in v0.11.0 source (2026-07-27); the API is officially "experimental" with no versioning policy, so every claim here is version-stamped. **First step on any instance: `GET /api/version`** — never trust `openapi.json`'s `info.version` (always says "0.1.0").
## Security floor: v0.11.1 published, latest release in practice
**Run the latest release (v0.12.4 as of 2026-10-27); v0.11.1 is the published
floor.** Note what that means here: this file is grounded in **v0.11.0 source**,
which is *below* the floor. The file:line claims still describe 0.11.0
accurately; the instance you point them at should not be on it. Every published
advisory (feed checked 2026-10-27, newest published 2026-09-10) is patched at
0.11.1. **But v0.12.0 and v0.12.4 ship security fixes their notes say are not
listed, with advisories to follow after CVE assignment** — re-check the
advisory feed when new GHSAs appear and expect the published floor to rise.
Four of the advisories defeat things this skill teaches, so knowing the version
comes before trusting the permission model:
| CVE | Affected | Defeats |
|-----|----------|---------|
| CVE-2026-87016 | `>= 0.6.41, < 0.11.1` | **Sign in as another user** — wildcard characters in the OAuth subject claim, **on SQLite** |
| CVE-2026-70482 | `>= 0.8.0, < 0.11.0` | **Account takeover** — OAuth token exchange accepts tokens issued to *any* client |
| CVE-2026-87998 | `>= 0.10.0, < 0.11.1` | A non-admin deletes **admin-owned** external knowledge connections — i.e. the `access_grants` model below does not hold |
| CVE-2026-87999 | `< 0.11.1` | Any authenticated user reaches an internal platform channel via server-side web fetch |
The first two mean **user identity itself is not trustworthy below the floor**,
so `/api/v1/users` and group membership reconciliation cannot be audited on such
a build — provisioning is only as sound as the identity it keys on. The third
means a permissions audit written against this file's `access_grants` section
gives the wrong answer on `0.10.0`–`0.11.0`, because deletion did not honour it.
Check first, before any scripting: `GET /api/version` (already the recon step
above) — and treat anything below `0.11.1` as "fix the version, then script".
**Derivation:** take the floor from the highest `vulnerable_version_range`
ceiling, not from `first_patched_version` — that field is filled on almost all
current advisories but still `null` on some, and withheld fixes appear in
neither. The volume is large enough that it is worth re-deriving rather than
re-reading this table. `open-webui-valkey-websocket` §"Security floor" carries
the reason an authenticating reverse proxy does **not** cover most of these.
## The API in 30 seconds
Four surfaces, one Bearer header (`Authorization: Bearer <jwt-or-api-key>`):
| Surface | What lives there | Examples |
|---|---|---|
| `/api/v1/*` | Resource CRUD — 27 routers (+2 feature-gated) | users, groups, auths, configs, models, knowledge, files, retrieval, tools, functions, prompts, chats, evaluations |
| `/api/*` | Runtime endpoints in main.py | `chat/completions`, `models`, `config`, `version`, `events` (webhooks), `tasks`, `usage` |
| `/ollama/*`, `/openai/*` | Authenticated proxies to backends | inference = user-level; model lifecycle + config = admin |
| `/ws` | socket.io | invisible to openapi.json |
Conditional mounts (404 when off): `/api/v1/scim/v2` needs `ENABLE_SCIM=true` (+restart), `/api/v1/analytics` needs `ENABLE_ADMIN_ANALYTICS` (default on).
Preflight before any scripted work (`$B` = base URL, `$T` = token):
```bash
curl -s $B/api/version | jq -e .version # real version; jq failing = HTML came back (SPA trap — check the path)
curl -s -H "Authorization: Bearer $T" $B/api/v1/auths/ | jq -e .role # expect "admin" for admin endpoints; 401 = key disabled/expired
```
For multi-call scripts, source `scripts/owui-curl.sh` (live-tested): `owui <METHOD> <path> [json]` wraps every call with the HTTP-2xx and JSON-content-type checks (the HTML-200 trap detector), and `owui_preflight [role]` runs the probes above.
Full endpoint inventory with per-route auth levels: `references/endpoint-map.md`.
## Auth — the trap zone (read before first request)
- 1. **API keys are off by default** since 0.6.37: `ENABLE_API_KEYS=true` required (the old `ENABLE_API_KEY` name is **silently ignored** — no fallback), plus admin role or `features.api_keys` permission (also default false). One unnamed, non-expiring `sk-` key per user; regenerate replaces it, and `DELETE /api/v1/auths/api_key` revokes it outright with no replacement (endpoint present since v0.11.0; v0.11.1 just added an account-settings button for it, #28874). Keys inherit the owner's full power — an admin's key can do everything, no scoping. Best practice: dedicated non-admin service accounts + `API_KEYS_ALLOWED_ENDPOINTS` allowlist (warning: restrictions on + empty list = keys can reach *nothing*).
+ 1. **API keys are off by default** since 0.6.37: `ENABLE_API_KEYS=true` required (the old `ENABLE_API_KEY` name is **silently ignored** — no fallback), plus admin role or `features.api_keys` permission (also default false). One unnamed, non-expiring `sk-` key per user; regenerate replaces it, and `DELETE /api/v1/auths/api_key` revokes it outright with no replacement (endpoint present since v0.11.0; v0.11.1 just added an account-settings button for it, open-webui/open-webui#28874). Keys inherit the owner's full power — an admin's key can do everything, no scoping. Best practice: dedicated non-admin service accounts + `API_KEYS_ALLOWED_ENDPOINTS` allowlist (warning: restrictions on + empty list = keys can reach *nothing*).
2. **JWTs expire after 4 weeks** by default since 0.6.34 (was: never). Long-lived automation on a captured session token dies silently mid-quarter. **v0.12.0+: a password change or admin `POST /api/v1/users/{id}/sessions/revoke` kills every JWT of that account** (per-account `session_stamp`, no Redis needed) while its `sk-` key keeps working, so automation should run on an API key, not a session token. Single-session signout still revokes only when Redis is present — and **as of v0.11.4, a Redis error during that check fails open**: the token is accepted rather than the request failing, so a sign-out or password change may not actually cut access until Redis is reachable again (`utils/auth.py:is_valid_token`, commit `c1615bec`). Don't treat "signed out" as enforced during a Redis outage.
3. **Headless bootstrap**: `WEBUI_ADMIN_EMAIL` + `WEBUI_ADMIN_PASSWORD` create the first admin at startup; or the first signup auto-promotes to admin and disables signup. **With `ENABLE_MFA` (v0.12.0+)** `POST /auths/signin` returns an authenticator challenge instead of a token and `POST /auths/add` returns no token — mint service-account API keys before turning MFA on, or enroll the account in TOTP via `/api/v1/auths/mfa/*` (human session only; API keys get 403).
4. SCIM is a separate universe: static `SCIM_TOKEN` bearer, not a JWT/API key.
5. Behind proxies that eat `Authorization`: send the key in `x-api-key` (`CUSTOM_API_KEY_HEADER`).
## The 0.10.0 break — unlearn the old shapes
Everything the community (and LLM training data) teaches about two core shapes died in 0.10.0 (2026-06-29). Old payloads are **silently ignored** (pydantic `extra='ignore'`) — scripts appear to succeed while doing nothing:
| Concept | Dead (≤0.9.x) | Current (0.10.x) |
|---|---|---|
| Resource ACLs | `access_control: {read:{group_ids,user_ids},...}`, `null`=public, `{}`=private | `access_grants: [{principal_type:"user"\|"group", principal_id:"<id>"\|"*", permission:"read"\|"write"}]` — **absent = private; public needs explicit `user:*` read grant** |
| Config export | one nested JSON blob | flat dot-keyed dict (`{"ui.default_models": ...}`); import = partial upsert |
0.11.0 adds a second break class — responses that **silently redact fields by access level** (`models/list` drops `params`, `tools` drops `content`), which can round-trip an emptied model catalog back through `import`/`sync`. Full ledger 0.6.19→0.11.0 with dates, issue numbers and both translation guides: `references/breaking-changes.md`.
## Advisory class that matters to API callers
The largest advisory cluster since 2026-06 is cross-tenant and privilege issues
— endpoints that check ownership at the wrong granularity, trusting a
client-supplied `knowledge_id`, folder parent or model metadata instead of
re-deriving access server-side. That class matters most to anyone driving this
API, because it is reachable by **any authenticated caller**, which is what an
API token is. Full class breakdown and why an internal-only deployment behind an
authenticating proxy does **not** cover most of it:
`open-webui-valkey-websocket` §"Security floor".
## Task → reference routing
| Task | Read |
|---|---|
| Find an endpoint, check required role | `references/endpoint-map.md` |
| User/group lifecycle, model GitOps (`models/sync`), knowledge upload pipeline, backups | `references/admin-workflows.md` (verified curl sequences) |
| Config-as-code, PersistentConfig precedence, connections, per-model request params | `references/config-system.md` |
| "Worked before upgrade, broken now" | `references/breaking-changes.md` |
| IdP provisioning (SCIM), audit/event webhooks | `references/events-scim.md` |
| Verify a claim / freshen | `references/sources.md` |
## Gotchas that cost hours (all verified in v0.11.0 source or live)
- **HTML-200 trap**: the SPA catch-all serves `index.html` with HTTP 200 for any unknown path. A typo'd endpoint "succeeds" with HTML. Always check `Content-Type: application/json`.
- **`/docs` and `/openapi.json` need `ENV=dev`** — the Docker image ships `ENV=prod`, so production instances return the SPA for both. Flipping to `dev` also skips the prod-only default `OLLAMA_BASE_URL` Docker rewrite — safe on external-connection setups, breaking on default-Ollama containers.
- **PersistentConfig**: env vars seed the DB on first boot only; thereafter the DB value wins and env changes are silently ignored (`ENABLE_PERSISTENT_CONFIG=true` default). Exception: `oauth.*` stays env-driven unless `ENABLE_OAUTH_PERSISTENT_CONFIG=true`. Change runtime config via the API, not the deployment env.
- **Connection `api_type: "responses"` changes the whole wire contract**: Open WebUI converts requests to the Responses API. vLLM-recipe knobs like `chat_template_kwargs` get dropped by the upstream (LiteLLM `/v1/responses` drops them); use the Responses-native `reasoning: {"effort": ...}` param instead. Also: `convert_responses_result()` discards reasoning items for **non-streaming** API callers — test reasoning with `stream: true`.
- **`custom_params` values must be JSON strings**, not nested objects: `{"custom_params": {"reasoning": "{\"effort\": \"medium\"}"}}`. The backend `json.loads`es strings; raw dicts work on the wire but render as `[object Object]` in the UI editor and get corrupted on UI save.
- **Change sharing only through the resource's access endpoint.** On v0.12.4+ `POST /api/v1/models/model/update` (and every other resource's update) **ignores `access_grants`**, so a grant change sent there 200s and does nothing. Use `POST /api/v1/models/model/access/update` `{id, access_grants}`, or the matching `…/access/update` route for knowledge, prompts, tools, skills, notes, folders, channels and calendars (`references/endpoint-map.md`). There, an audience the caller may not share with gets a **403**; on create it is silently stripped. Older versions: v0.11.2–v0.12.3 `update` replaces grants when the field is present (`[]` = private, omitted = kept), and v0.11.0–v0.11.1 return 500 when it is omitted.
- **Pagination is inconsistent**: `/api/v1/users/` is hard-capped 30/page and `/api/v1/chats/?page=` 60/page, neither with a `limit` param; `GET /api/v1/chats/` **without `page` returns everything unbounded** (verified 0.11.0: 371 rows unpaged vs 60 with `page=1`).
- **Workspace-model list**: use `GET /api/v1/models/list` — bare `/api/v1/models` is the OpenAI-compat alias (collision, renamed 0.6.35).
- No general rate limiting (only signin: 15/3min/email). `CORS_ALLOW_ORIGIN` defaults `*`.
- Filter `outlet()` hooks run on direct API calls (streaming included) **since 0.10.0, on by default** — API response bodies can be rewritten by installed filter functions.
## Debugging pattern that works
When a parameter "doesn't arrive" at the backend, bisect the chain with an error probe: send the parameter with an **invalid type** (e.g. a string where a dict belongs) at each hop — direct to the upstream, then through Open WebUI. A hop that errors is forwarding; a hop that succeeds silently is dropping. This located a LiteLLM Responses-path drop in two requests that log-reading could not have found.