---
title: HTTP endpoints
description: Every HTTP route Hubzoid 1.0.1 serves on the edge, bridge, MCP server, Console API, artifact viewer and webhook receiver, with method, authentication and purpose.
canonical_url: https://hubzoid.com/docs/reference/http-endpoints
last_updated: 2026-09-27
---

# HTTP endpoints

Every HTTP route Hubzoid 1.0.1 serves on the edge, bridge, MCP server, Console API, artifact viewer and webhook receiver, with method, authentication and purpose.

A running Hub listens on a small set of ports. Only the edge is meant to face users. Everything else binds `127.0.0.1` and is reached through the edge or by processes on the same machine.

Browser or client → Edge (PORT) → Bridge (loopback) → Hub runtime

The edge forwards a short list of path prefixes to the bridge and sends everything else to the web chat.

| Process | Default address | Exposure |
| --- | --- | --- |
| Edge | `127.0.0.1:3080` (`HUBZOID_HOST`, `PORT`) | The public front door. |
| Web chat (Open WebUI) | `127.0.0.1:43080` (`PORT` plus 40000, or `HUBZOID_OWUI_PORT`) | Reached through the edge. |
| Bridge | `127.0.0.1:8000` (`BRIDGE_PORT`) | Loopback only. Selected paths are reachable through the edge. |
| Inbound receiver | `127.0.0.1:8100` (`HUBZOID_INBOUND_PORT`) | Loopback only. `/webhooks/<hub>` is reachable through the edge. |

The Slack adapter uses Socket Mode and opens no port. In a gateway, each Hub's bridge gets its own loopback port and the edge routes by Hub, as described in [gateway routes](https://hubzoid.com/docs/reference/http-endpoints#gateway-routes).

## Edge

The edge binds the public port, streams responses and relays websockets. For a single Hub (`hubzoid run`) it forwards:

| Path prefix | Goes to | Present when |
| --- | --- | --- |
| `/artifacts` | bridge | always |
| `/portal` | bridge | always |
| `/mcp` | bridge | `MCP_SERVER=true` |
| `/webhooks/<hub>` | inbound receiver | `hubzoid run` was started with `--whatsapp`, `--telegram` or `--webhook` |
| everything else | web chat | always |

`<hub>` is the slug of the Hub folder name, or `HUBZOID_HUB_SLUG`. `/portal` covers the Console and its API, the artifact viewer, public links and connection pages. The bridge's `/v1`, `/uploads` and `/healthz` routes are never forwarded. The edge also serves `/hubzoid-portal-navigation.js`, a small script that links the web chat to the Console.

Rules the edge applies before it forwards a request:

- A path with a `.` or `..` segment is refused with `400`, so no path can climb out of a forwarded prefix.
- Client-sent `X-Hubzoid-*` and `X-OpenWebUI-*` headers are removed before forwarding. Only trusted local callers set identity headers.
- In a gateway, a request that changes a migrated Hub's model access in the web chat (`POST`, `PUT`, `PATCH` or `DELETE` under `/api/v1/models/`) is refused with `403`. Access for that Hub is managed in the Console.
- With `HUBZOID_LOCK_OWUI_ACCESS_UI=true`, writes to `/api/v1/groups` (or the prefixes in `HUBZOID_OWUI_LOCKED_PREFIXES`) are refused with `403`.
- With `HUBZOID_HIDE_OWUI_USERS` on, the web chat's user list redirects to the Console's People page, its Admin Panel opens on Settings > Integrations, and browser writes to its account admin API are refused with `403`. When every agent is managed in the Console, its whole Users section, Groups included, opens Settings > Integrations.
- The web chat's model list (`GET /api/models`) is filtered by Hubzoid's entry decision for the signed-in person, read from `/portal/api/chat-access`, including for chat administrators. When that check cannot be made, the list is refused rather than shown unfiltered.
- During a connection journey, the edge sends the browser back to the journey's `/portal/connect/<id>/done` page after the provider's consent, instead of the web chat's home page.
- An upstream that is not reachable returns `502`.

With `HUBZOID_DISABLE_EDGE=1` there is no edge: the web chat binds the public port and none of the bridge paths are reachable from outside.

### Gateway routes

`hubzoid gateway` runs one edge for all Hubs:

| Path prefix | Goes to | Notes |
| --- | --- | --- |
| `/b/<hub>/artifacts` | that Hub's bridge | The `/b/<hub>` prefix is removed before forwarding. |
| `/b/<hub>/mcp` | that Hub's bridge | Hubs with `MCP_SERVER=true` only. |
| `/webhooks/<hub>` | that Hub's inbound receiver | Hubs whose `.env` configures an inbound surface. |
| `/portal` | the first Hub's bridge, then the next | Any bridge can serve the Console because they share one operational store. When a bridge refuses the connection, the edge tries the next one. This is not high availability for the gateway or the chat app. |
| everything else | the shared web chat |  |

## Bridge

The bridge serves one Hub. Its API key check compares `Authorization: Bearer <key>` with `BRIDGE_API_KEYS`.

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| `GET` | `/healthz` | none | Liveness. Returns `{"status": "ok", "hub": "<folder>", "agent": "<name>"}`. |
| `GET` | `/v1/models` | bridge key | One model, the Hub, in OpenAI list format. |
| `POST` | `/v1/chat/completions` | bridge key | Chat with the Hub's agent. OpenAI request and response shape, streaming or not. |
| `GET` | `/artifacts/{chat_id}/{filename}` | signed link or bridge key | Download a file the agent saved. Public through the edge. |
| `POST` | `/uploads/{chat_id}/{filename}` | bridge key | Upload a file into a chat so the agent can read it. |
| `POST` | `/otel/v1/traces` | none (loopback) | Present only with `HUBZOID_OTEL_NORMALIZE=true`, `HUBZOID_OTEL_ENDPOINT` set and `claude-local`. Receives the `claude` process's traces, normalizes them and forwards them. Always answers `200`. |
| `POST` | `/mcp` | personal API key | The Hub's MCP server, when `MCP_SERVER=true`. See [MCP server](https://hubzoid.com/docs/reference/http-endpoints#mcp-server). |
| `GET` | `/portal/` | none | The Console web app. |
| various | `/portal/api/*` | Console session or Open WebUI API key | The Console API. See [Console API](https://hubzoid.com/docs/reference/http-endpoints#console-api). |
| various | `/portal/artifacts/*`, `/portal/p/*` | web chat session, or a public link | The artifact viewer and public links. See [artifact viewer](https://hubzoid.com/docs/reference/http-endpoints#artifact-viewer). |
| various | `/portal/connect/*` | web chat session of the person who asked | Connection journey pages. See [connection pages](https://hubzoid.com/docs/reference/http-endpoints#connection-pages). |

### POST /v1/chat/completions

The endpoint the web chat, Slack adapter and inbound receiver use. It is loopback only, so a client elsewhere reaches the Hub through one of those surfaces or through MCP.

- **Body.** `messages` is required. `stream: true` returns server-sent events in the OpenAI chunk format, ending with a usage chunk and `data: [DONE]`. Without it the reply is one JSON `chat.completion` object with `usage`. Attachments may be sent as `data:` URLs in message content, and each is saved to the chat's uploads.
- **Chat id.** Files and history are scoped to a chat id taken from, in order: `chat_id`, `metadata.chat_id`, `metadata.conversation_id`, the `X-Hubzoid-Chat-Id` header, `user`, or a hash of the first user message.
- **Identity headers.** `X-OpenWebUI-User-Email` or `X-Hubzoid-User` names the caller, `X-Hubzoid-Surface` names the surface (default `owui`), and `X-Hubzoid-Groups` adds groups. They are trusted because only holders of the bridge key can call the bridge.

| Status | Meaning |
| --- | --- |
| `400` | Invalid JSON, no messages, or an empty prompt. |
| `401` | Missing or wrong bridge key. |
| `403` | The caller is blocked, or the Hub's access is managed in the Console and the caller has no `use_hub` grant or no verified identity. |
| `413` | An attachment is larger than `HUBZOID_MAX_UPLOAD_BYTES`. |
| `503` | The access check could not be completed. |

Using this API from your own code is covered in [OpenAI-compatible API](https://hubzoid.com/docs/chat/openai-compatible-api).

### GET /artifacts/\{chat\_id}/\{filename}

Links written by `write_artifact` carry a signature in `?t=`, plus an expiry in `?e=` when `HUBZOID_ARTIFACT_LINK_TTL` is set. A request without a valid signature must present a bridge key, and the public default key `dev` is refused on this route. Files are served inline.

### POST /uploads/\{chat\_id}/\{filename}

The raw request body is the file. The type comes from `Content-Type`, or from the file name when that header is missing or generic. A body over `HUBZOID_MAX_UPLOAD_BYTES` returns `413`. The response is `{"chat_id": ..., "filename": ..., "size": ...}`.

## MCP server

With `MCP_SERVER=true`, the bridge serves the Hub over MCP Streamable HTTP at `/mcp`. The edge exposes it at `/mcp` for a single Hub and at `/b/<hub>/mcp` in a gateway. Each request is self-contained (stateless).

| Aspect | Behaviour |
| --- | --- |
| Auth | `Authorization: Bearer <key>`, where the key is the caller's personal API key from the web chat. Bridge keys are not accepted. |
| Gate | On a Hub whose access is managed in the Console, the caller needs `use_hub` and must not be blocked. On a Hub with group-based access, `MCP_ACCESS_GROUP`, when set, limits access to members of that web chat group. A failed check answers `401`. |
| Tools | The Hub's tools except the chat-scoped ones, `remember` and delegates. Restricted tools are listed only to callers who may use them, and are checked again on every call. |
| Instructions | The `mcp_instructions` value from `AGENTS.md` frontmatter, or else the `AGENTS.md` body, sent when the client connects. |
| Identity | Calls run as the key's owner on the `mcp` surface, and restricted tool decisions are audited. |

Connecting Claude Code, Cursor and other clients is covered in [connect an assistant](https://hubzoid.com/docs/guides/connect-an-assistant).

## Console API

The Console's web app is served at `/portal/` and calls a JSON API under `/portal/api`. The same API is the management API for scripts. A caller authenticates in one of two ways, and a client-sent identity header is never trusted:

- **The web chat's session.** The bridge verifies the `token` cookie with the web chat and uses the verified email. Requests that change anything must come from the same origin, checked with the `Origin` or `Referer` header.
- **An Open WebUI API key**, sent as `Authorization: Bearer sk-...`. It is checked against the web chat's key table, the caller acts as the key's owner under the same rules, and no `Origin` is needed. Any other `Authorization` value is ignored. Keys can be created where Open WebUI API keys are turned on, which `MCP_SERVER=true` does.

Most endpoints need `manage_access` for the organization or for at least one Hub, and every response is limited to the Hubs the caller manages. One authorization service decides every change. A refusal has a safe message and a stable code, for example `{"detail": "Outside your access: ...", "code": "outside_ceiling"}`.

| Method | Path | Who | Purpose |
| --- | --- | --- | --- |
| `GET` | `/portal/api/me?brief=` | admin | The signed-in admin, the Hubs they manage, what they can grant, and how a new account can sign in. `brief=true` returns only the first three fields. |
| `GET` | `/portal/api/hubs` | admin | Manageable Hubs, whether each one's access is managed in the Console, and whether the caller can chat with it. |
| `GET` | `/portal/api/permissions?hub=` | admin | The capability catalog of a Hub, with labels, groups and configuration status. |
| `GET` | `/portal/api/access?hub=&q=&offset=&limit=` | admin | People and services with access to a Hub, their direct and effective permissions, account status, what the caller may grant, and the policy revision. |
| `POST` | `/portal/api/access/grant` | admin | Grant one permission. Body: `subject`, `hub`, `permission`, optional `expected_revision`. |
| `POST` | `/portal/api/access/revoke` | admin | Revoke one permission. Same body. |
| `POST` | `/portal/api/access/apply` | admin | Apply a set of grants and revokes for one person in one Hub, all or nothing. Body: `subject`, `hub`, `operations` (each `action` and `permission`), optional `expected_revision`. |
| `GET` | `/portal/api/people?q=&status=&role=&agent=&offset=&limit=` | admin | People and services with account status and access per Hub. |
| `POST` | `/portal/api/people/refresh` | organization admin | Re-read accounts from the web chat. |
| `POST` | `/portal/api/people/block` | organization admin | Block or unblock a person. Body: `subject`, `suspended` (`true` to block, `false` to unblock). The Console no longer offers Block or Reactivate, so this endpoint is how a user blocked earlier is unblocked. Blocking removes every grant, and unblocking does not restore them. |
| `POST` | `/portal/api/accounts` | admin who may create accounts | Create an account with its first access. Body: `email`, `name`, `sign_in` (`password` or `google`), `password` for `password`, and `grants` (each `hub` and `permission`). A duplicate email changes nothing. A partial result keeps the account and answers `502` with code `partial`. |
| `POST` | `/portal/api/accounts/grant` | admin | Give an existing account access. Body: `email`, `grants`. Never creates an account. |
| `GET` | `/portal/api/accounts/{subject}` | organization admin | A person's account read live: name, chat role, sign-in method and both halves of the Administrator role. |
| `POST` | `/portal/api/accounts/{subject}/password` | organization admin | Reset a password. Body: `password`. Refused with `google_managed` for a Google-only user. |
| `POST` | `/portal/api/accounts/{subject}/approve` | organization admin | Approve a pending sign-up. |
| `POST` | `/portal/api/accounts/{subject}/role` | organization admin | Set `role` to `user` or `admin`, which sets Hubzoid organization administration and the chat app's admin role together. A change that sets only one side answers `502` with `role_partial`. Repeating the request finishes it. |
| `DELETE` | `/portal/api/accounts/{subject}` | organization admin | Delete a user: every grant, then the chat account and its chats. Body: `confirm_email`, which must equal the account's email. |
| `GET` | `/portal/api/change-requests/{id}` | the person who proposed it | A change proposed by the [management tools](https://hubzoid.com/docs/reference/built-in-tools#management-tools). |
| `POST` | `/portal/api/change-requests/{id}/confirm` | the person who proposed it, with a web session | Apply the proposal exactly as proposed. Body: `plan_hash`, and `password` for a proposed account. |
| `POST` | `/portal/api/change-requests/{id}/reject` | the person who proposed it | Reject the proposal. |
| `GET` | `/portal/api/workflows?hub=` | admin | Workflows and markdown tasks with state, next run and the account each runs as. |
| `GET` | `/portal/api/runs?hub=&workflow=&run_id=&status=&since=&until=&limit=&offset=` | admin | Run history. `status` accepts `succeeded`, `failed`, `running` and `cancelled`. A run's result and step outputs are returned only to the account it acted as. |
| `GET` | `/portal/api/audit?hub=&user=&outcome=&tool=&surface=&since=&until=&limit=&offset=` | admin | Restricted tool decisions. |
| `GET` | `/portal/api/access-changes?hub=&user=&actor=&action=&since=&until=&limit=&offset=` | admin | Grant, revoke and account changes, with who made them. |
| `POST` | `/portal/api/sync` | organization admin | Re-mirror each person's visible Hubs into the web chat. |
| `GET` | `/portal/api/summary?period=` | admin | Usage for `24h`, `7d` or `30d`: chats, messages, active people, tokens, estimated cost, denials, runs and missed slots per Hub, plus `user_accounts`, the sign-in accounts in the caller's scope whatever the period. |
| `GET` | `/portal/api/overview` | admin | Counts of Hubs, grants and people, and how many Hubs are managed in the Console. |
| `GET` | `/portal/api/chat-access` | any signed-in person | Which agents the caller may enter. The edge uses it to filter the web chat's agent picker. Accepts the web chat's bearer token as well as its cookie. |

A change on a Hub with group-based access is refused with code `legacy`. An `expected_revision` that no longer matches returns `409`, so an edit built on stale data is never applied. New access for everyone signed in (`*`) is refused. Page sizes are capped at 200. What each screen shows is described in the [Console overview](https://hubzoid.com/docs/console/overview).

## Artifact viewer

Published artifacts are served under `/portal` by any bridge. Access is decided on every request, so a removed share or link stops working at once. No Console rights are involved: owners manage their own artifacts here.

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| `GET` | `/portal/artifacts/<id>` | web chat session | The viewer page. A signed-out visitor is sent to sign in and back to the same artifact. |
| `GET` | `/portal/artifacts/<id>/content` | web chat session | The file, shown inline and isolated. |
| `GET` | `/portal/artifacts/<id>/download` | web chat session | The file, as a download. |
| `GET` | `/portal/artifacts/api/<id>` | web chat session | Metadata, plus sharing settings for the owner. |
| `POST` | `/portal/artifacts/api/<id>/audience` | owner, same origin | Share with only the owner, named people or groups, or everyone who can use the agent. |
| `POST` | `/portal/artifacts/api/<id>/link` | owner, same origin | Create or turn off a public link. Body: `action` (`create` or `revoke`), optional `days` from 1 to 90. Needs **Share artifacts publicly**. |
| `DELETE` | `/portal/artifacts/api/<id>` | owner, same origin | Delete the artifact. |
| `GET` | `/portal/p/` | none | The public link page. The link's token travels in the URL fragment, which the browser does not send with the page request. |
| `POST` | `/portal/p/open` | the link's token | Exchange the token for a short-lived viewing cookie. A link that no longer works answers `404`. |
| `GET` | `/portal/p/<id>/content`, `/portal/p/<id>/download` | that cookie | The file, inline or as a download. |

HTML artifacts are served with a sandbox policy that gives them their own opaque origin, so their scripts cannot read the session or call the app. They make no network requests unless `HUBZOID_ARTIFACT_ALLOW_ORIGINS` names origins they may load from. File types the viewer does not preview are only downloaded. The chat download route `/artifacts/{chat_id}/{filename}` cannot reach published artifacts.

## Connection pages

With `HUBZOID_CONNECT_JOURNEY=true`, the `connect_account` tool sends a person a link to `/portal/connect/<id>`. Every page needs the web chat session of the person who asked. A signed-out person is sent to sign in and back to the link, and another account gets `403`, which is recorded in the access log.

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/portal/connect/<id>` | The connection page. |
| `POST` | `/portal/connect/<id>/start` | Continue to the provider's consent screen. Same origin only. |
| `POST` | `/portal/connect/<id>/cancel` | Cancel the journey. Same origin only. |
| `GET` | `/portal/connect/<id>/done` | The result, checked against the connection the web chat stored. Parameters on the return address are never read. |
| `GET` | `/portal/connect/<id>/status` | The journey's state as JSON, polled by the done page. |

## Inbound receiver

The receiver runs with `hubzoid inbound run` or with `hubzoid run --whatsapp`, `--telegram` or `--webhook`. Each surface is served only when its variables are set.

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| `GET` | `/webhooks/<hub>/whatsapp` | `WHATSAPP_VERIFY_TOKEN` | Meta's webhook verification challenge. |
| `POST` | `/webhooks/<hub>/whatsapp` | `X-Hub-Signature-256` signed with `WHATSAPP_APP_SECRET` | Incoming WhatsApp messages. Acknowledged at once and answered in the background. |
| `POST` | `/webhooks/<hub>/telegram` | `X-Telegram-Bot-Api-Secret-Token` equal to `TELEGRAM_WEBHOOK_SECRET` | Incoming Telegram updates. |
| `POST` | `/webhooks/<hub>/<name>` | shared secret or HMAC | Generic webhook. `<name>` is `WEBHOOK_INBOUND_NAME`, default `webhook`. |

A request that fails verification gets `403` before anything else runs. WhatsApp and Telegram senders must also be in the Hub's roster, `identity/access.csv` or `identity/access.py`.

### Generic webhook

- **Shared secret mode** (default). Send the secret as `Authorization: Bearer <secret>` or `X-Webhook-Secret: <secret>`. A `?token=` query parameter is also accepted, but it lands in access logs, so prefer a header.
- **HMAC mode** (`WEBHOOK_INBOUND_HMAC=true`). Send `X-Signature-256: sha256=<hex>` or GitHub's `X-Hub-Signature-256`, the HMAC-SHA256 of the raw body with the secret.
- **Storage.** Each verified delivery is written to `<hub>/.inbound/webhooks/<name>/` as one JSON file with the surface, name, receive time, query, content type and body. A non-JSON body is kept as text. The response is `200 ok` once stored and `500` when storage failed, so the sender retries.
- **Duplicates.** A delivery is recognized by `X-GitHub-Delivery`, `X-Delivery-Id`, `X-Webhook-Id`, `Idempotency-Key` or `X-Request-Id`, or else by an identical body in the same or the previous ten-minute window. A repeat answers `200 duplicate` and is not stored again. A copy that arrives while the first is still being stored gets `503`, so the sender retries later.

A markdown task with `on_webhook: <name>` runs when events wait in that inbox. See [webhooks](https://hubzoid.com/docs/guides/webhooks).

## Next steps

- [Architecture](https://hubzoid.com/docs/concepts/architecture): How the edge, bridge, web chat and workflow engine fit together.
- [Security model](https://hubzoid.com/docs/deploy/security-model): What is public, what stays on loopback, and why.
- [OpenAI-compatible API](https://hubzoid.com/docs/chat/openai-compatible-api): Call the Hub's agent from your own code.
- [Connect an assistant](https://hubzoid.com/docs/guides/connect-an-assistant): Use the MCP endpoint from Claude Code, Cursor and others.
