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").
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):
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)
- 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. 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).
- 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. Signout only revokes JWTs when Redis is present.
- 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.
- SCIM is a separate universe: static
SCIM_TOKEN bearer, not a JWT/API key.
- 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.
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.loadses strings; raw dicts work on the wire but render as [object Object] in the UI editor and get corrupted on UI save.
POST /api/v1/models/model/update 500s when access_grants is omitted — send it explicitly ("access_grants": [] to keep the model private). ModelForm.access_grants is typed list[dict | None] but defaults to None (models/models.py:180); the omitted field survives request parsing, then the handler re-validates it at routers/models.py:752 and pydantic raises list_type. Verified live on 0.11.0: omitted → 500, [] → 200. No need for the old delete+create workaround.
- 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.
1---2name: open-webui-api3description: 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.4---56# Open WebUI REST API administration — operator reference78Target: 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").910## The API in 30 seconds1112Four surfaces, one Bearer header (`Authorization: Bearer <jwt-or-api-key>`):1314| Surface | What lives there | Examples |15|---|---|---|16| `/api/v1/*` | Resource CRUD — 27 routers (+2 feature-gated) | users, groups, auths, configs, models, knowledge, files, retrieval, tools, functions, prompts, chats, evaluations |17| `/api/*` | Runtime endpoints in main.py | `chat/completions`, `models`, `config`, `version`, `events` (webhooks), `tasks`, `usage` |18| `/ollama/*`, `/openai/*` | Authenticated proxies to backends | inference = user-level; model lifecycle + config = admin |19| `/ws` | socket.io | invisible to openapi.json |2021Conditional mounts (404 when off): `/api/v1/scim/v2` needs `ENABLE_SCIM=true` (+restart), `/api/v1/analytics` needs `ENABLE_ADMIN_ANALYTICS` (default on).2223Preflight before any scripted work (`$B` = base URL, `$T` = token):2425```bash26curl -s $B/api/version | jq -e .version # real version; jq failing = HTML came back (SPA trap — check the path)27curl -s -H "Authorization: Bearer $T" $B/api/v1/auths/ | jq -e .role # expect "admin" for admin endpoints; 401 = key disabled/expired28```2930For 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.3132Full endpoint inventory with per-route auth levels: `references/endpoint-map.md`.3334## Auth — the trap zone (read before first request)35361. **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. 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*).372. **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. Signout only revokes JWTs when Redis is present.383. **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.394. SCIM is a separate universe: static `SCIM_TOKEN` bearer, not a JWT/API key.405. Behind proxies that eat `Authorization`: send the key in `x-api-key` (`CUSTOM_API_KEY_HEADER`).4142## The 0.10.0 break — unlearn the old shapes4344Everything 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:4546| Concept | Dead (≤0.9.x) | Current (0.10.x) |47|---|---|---|48| 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** |49| Config export | one nested JSON blob | flat dot-keyed dict (`{"ui.default_models": ...}`); import = partial upsert |50510.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`.5253## Task → reference routing5455| Task | Read |56|---|---|57| Find an endpoint, check required role | `references/endpoint-map.md` |58| User/group lifecycle, model GitOps (`models/sync`), knowledge upload pipeline, backups | `references/admin-workflows.md` (verified curl sequences) |59| Config-as-code, PersistentConfig precedence, connections, per-model request params | `references/config-system.md` |60| "Worked before upgrade, broken now" | `references/breaking-changes.md` |61| IdP provisioning (SCIM), audit/event webhooks | `references/events-scim.md` |62| Verify a claim / freshen | `references/sources.md` |6364## Gotchas that cost hours (all verified in v0.11.0 source or live)6566- **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`.67- **`/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.68- **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.69- **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`.70- **`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.71- **`POST /api/v1/models/model/update` 500s when `access_grants` is omitted** — send it explicitly (`"access_grants": []` to keep the model private). `ModelForm.access_grants` is typed `list[dict | None]` but defaults to `None` (`models/models.py:180`); the omitted field survives request parsing, then the handler re-validates it at `routers/models.py:752` and pydantic raises `list_type`. Verified live on 0.11.0: omitted → 500, `[]` → 200. No need for the old delete+create workaround.72- **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`).73- **Workspace-model list**: use `GET /api/v1/models/list` — bare `/api/v1/models` is the OpenAI-compat alias (collision, renamed 0.6.35).74- No general rate limiting (only signin: 15/3min/email). `CORS_ALLOW_ORIGIN` defaults `*`.75- 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.7677## Debugging pattern that works7879When 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.