# HTTP API Reference Every server-side route is a Vite middleware hook declared in `vite.config.ts` (see the server hook system section of `docs/overview.md`), so the endpoints below behave identically in dev (`vite`) and in the production preview (`vite preview`) that the Docker image runs. These endpoints exist for the browser app rather than as a public API: they are unversioned, and their shapes are the ones the client happens to need. They are documented here because the owner of an instance can reach them, and because reading middleware should not be the only way to learn what a request has to carry. | Method | Path | Token | Rate limit | Purpose | | --- | --- | --- | --- | --- | | `GET` | `/search/text` | required | shared | Text results from SearXNG, reranked | | `GET` | `/search/images` | required | shared | Image results from SearXNG, reranked | | `GET` | `/page-content` | required | shared | Readable text from result pages, for answer grounding | | `GET` | `/thumbnail` | required | own budget | One image-result thumbnail, fetched server-side | | `POST` | `/inference` | required | shared | Streaming chat completion from the internal API | | `GET` | `/status` | none | none | Uptime, counters, and service health | | `GET` | `/api/config` | none | none | Runtime client configuration, including the search token | | `POST` | `/api/validate-access-key` | none | shared | Access key check | | `GET` | `/dictation-models//` | none | none | Speech-to-text model files, served from the instance | A path that matches no hook falls through to Vite's static handler, which is also what a method mismatch does on `/api/config` and `/api/validate-access-key`: those two answer only their documented method and call `next()` otherwise, so a `GET` on the validation endpoint returns the app shell, not a 405. Those two compare the whole request target, query string included, so `/api/config?v=2` falls through as well. The `GET` endpoints in the table above do not check the method at all: a `POST` to `/search/text` is served like a `GET`. Only `/inference` answers `405`. ## Authentication ### Search token The token is CSRF protection, not authorization: it proves a request came from a page this server served. `server/searchToken.ts` generates it on first use in each process and holds it for that process's life, and `/api/config` hands it out. It is not a build artifact: a token written during an image build would be shared by every container of that build. A client never sends the raw token. It hashes it with argon2id using the parameters in `shared/argon2Parameters.ts` (`m=512, t=16, p=1`, 32-byte output, encoded form) and sends the resulting hash as the `token` query parameter: ```bash TOKEN=$(curl -s http://localhost:7860/api/config | jq -r .searchToken) # hash $TOKEN with argon2id using the parameters above; see # client/modules/searchTokenHash.ts curl "http://localhost:7860/search/text?q=hello&token=$ARGON2_HASH" ``` The hash is salted per client, so it differs between browsers and every one of them verifies. The server checks the encoded parameter block before running `argon2Verify`, so a hash carrying different parameters is refused for free. The whole lifecycle is in the search token lifecycle section of `docs/security.md`. Verification runs before parameters are parsed, so a malformed request from an unauthenticated caller still costs a rate-limit point. The exception is `/inference`, which answers `405` and `415` before it looks at the token, so those two rejections need no token and spend no rate-limit point. The failures below are the same on every token-gated endpoint: | Status | Body | When | | --- | --- | --- | | `429` | `{"error":"Too many requests."}` | The bucket for this client is empty | | `400` | `{"error":"Missing token."}` | No `token` parameter | | `401` | `{"error":"Invalid token."}` | The hash does not verify against this server's token | A 401 that persists across reloads usually means two processes hold different tokens: the server logs that once, on the way out. The `400` and `401` rejections have a second shape for a person rather than a program. The token rides in the query string, so a search URL can be bookmarked, shared or set as a browser's search engine, and every such URL dies when the instance rotates its token. When the request's `Accept` header names `text/html`, which is what a browser sends when it navigates to a URL, the same status comes back with `Content-Type: text/html; charset=utf-8` and a short static page that says the token was rotated and links to `/`, where the app takes a current token through `/api/config`. The page carries no token, sets no cookie and redirects nowhere, and nothing from the request is echoed into it. Any other `Accept`, including `*/*` and none at all, gets the JSON body above, so API clients see no change; `429` is JSON either way. The rejection is counted the same whichever shape it is answered with. ### Access keys `ACCESS_KEYS` gates the app's UI, not these endpoints. The client asks `/api/validate-access-key` before rendering and stores the accepted hash locally; nothing about that key travels on a later search or inference request. An instance that must not answer unauthenticated callers needs something in front of it. ### Rate limits `server/verifyTokenAndRateLimit.ts` keeps two in-memory buckets, both keyed by client IP: | Bucket | Budget | Consumed by | | --- | --- | --- | | shared | 10 requests / 10 s | `/search/text`, `/search/images`, `/page-content`, `/inference`, `/api/validate-access-key` | | thumbnail | 60 requests / 10 s | `/thumbnail` | `/thumbnail` has its own budget because one image search fans out into up to 30 tile loads, which would otherwise consume a user's whole search budget. The point is consumed before token verification and before any argon2 work, so a flood of bogus tokens is bounded too. The key is the TCP peer address. `X-Forwarded-For` and `X-Real-IP` are honored only when `TRUST_PROXY` is `true` or `1`; on a directly-exposed instance they are client-controlled, and trusting them would hand every request a fresh identity. ## Endpoints ### `GET /search/text` Query parameters: | Name | Required | Description | | --- | --- | --- | | `q` | yes | Search query, trimmed, 1 to 2000 characters | | `token` | yes | Search token hash | | `limit` | no | How many of SearXNG's results to consider, default and maximum 30; a value that is not a positive integer falls back to the default. SearXNG is not sent this number: it caps a single SearXNG response after duplicate URLs are dropped, and the score filter below can return fewer | Responds with a JSON array of tuples, in the order the client renders them: ```json [ ["Result title", "Snippet text", "https://example.com/page", 3.7] ] ``` Between SearXNG and the response the server drops duplicate URLs, keeps the first `limit` of what remains, and discards results with no title or no snippet. Index 0 is the first result that survives that. The order is not a score sort. That first surviving result is pinned at index 0 and is the one result the score filter never drops; the next nine are the first nine survivors in SearXNG's order, sorted by score among themselves; everything after them is sorted by score too. So a result SearXNG ranked eleventh can outscore index 1 and still arrive at index 10 (`server/rankSearchResults.ts`, and the preserve top results section of `docs/reranking.md`). Reranking drops results rather than only reordering them. Every score is first shifted by the absolute value of the lowest score in the batch, which puts the lowest at zero only when it is negative; everything below `mean - 0.3 * standardDeviation` on that shifted scale is filtered out. If that leaves fewer than 40% of the batch, the threshold becomes 40% of the highest shifted score instead, so the fallback keeps far more of an all-positive batch than of one whose lowest score is negative. The response carries only the survivors, so neither threshold can be recomputed from it. On `/search/text` the filter sees results 2..N only, since index 0 is exempt; on `/search/images` it sees all of them. Fewer results than `limit` is therefore normal on both, not a sign of an outage. The fourth element is the reranker's raw relevance logit, deliberately not passed through a sigmoid (see `docs/reranking.md`). It is absent when the reranker is unhealthy or a rerank call fails, in which case SearXNG's own order is returned unranked and unfiltered, so a client must treat the score as optional. Failures: | Status | Body | When | | --- | --- | --- | | `400` | `{"error":"Missing query parameter"}` | `q` missing or blank | | `400` | `{"error":"Query parameter must not exceed 2000 characters"}` | `q` too long | | `502` | `{"error":"Search service unavailable"}` | SearXNG unreachable, so an outage is distinguishable from a search with no matches | | `500` | `{"error":"Internal server error"}` | Anything else | ### `GET /search/images` Same parameters and failures as `/search/text`. The hook claims the whole `/search/` prefix and treats every path that does not start with `/search/text` as an image search, so `/search/anything` is an image search. The ordering above does not carry over: image results are a plain score sort, nothing is pinned at index 0, and no result is exempt from the score filter (`preserveTopResults` is only passed for text, see `docs/reranking.md`). Responds with a JSON array of four-element tuples: ```json [ [ "Image title", "https://example.com/page-that-embeds-it", "https://example.com/thumb.jpg", "https://example.com/full.jpg" ] ] ``` In order: the title, cut to 100 characters because that is the length the reranker sees, the page the image was found on, the thumbnail URL exactly as SearXNG returned it, and the full image. The last is the embeddable player URL for a video result, and an empty string when there is nothing to link to. The response never waits on a thumbnail host; the client loads each tile through `/thumbnail`. A result SearXNG returned without a thumbnail URL is dropped before the response, since the grid would have nothing to show for it. ### `GET /page-content` Reads the pages behind a handful of results and returns the passages that match the query. Unlike `/search/`, this endpoint fetches URLs the caller chose, so it is SSRF-guarded, byte-capped and timed out per page (see `docs/page-content.md`). The path is matched exactly, so `/page-content/anything` falls through. | Name | Required | Description | | --- | --- | --- | | `q` | yes | Query the passages are ranked against, 1 to 2000 characters | | `token` | yes | Search token hash | | `url` | yes | Page to read, `http` or `https`, up to 2048 characters. Repeat for more; at most 6 per request, counted before duplicates are removed, so 7 URLs are refused even when two of them are the same | Responds with an object keyed by URL: ```json { "https://example.com/page": "The passage that best covers the query.\nThe next best passage from the same page." } ``` A page that could not be read, was refused by the SSRF guard, or yielded too little text is simply absent from the object; `{}` means nothing was read. That is not an error, and the client degrades to snippet-only answers for those results. Failures: `400` with the first validation message (`Missing query parameter`, `Query parameter must not exceed 2000 characters`, `Missing url parameter`, `Invalid URL parameter`, `No more than 6 URLs can be read per request`), or `500` `{"error":"Internal server error"}`. A `url` over 2048 characters is answered `Invalid URL parameter`, the same as any other unusable URL. ### `GET /thumbnail` Fetches one image-result thumbnail server-side, so the browser never requests a search-result URL directly. | Name | Required | Description | | --- | --- | --- | | `u` | yes | Thumbnail URL, up to 2048 characters | | `token` | yes | Search token hash | A hit responds with the image bytes and the upstream's content type, restricted to `image/avif`, `image/bmp`, `image/gif`, `image/jpeg`, `image/png`, `image/webp`, `image/x-icon` and `image/vnd.microsoft.icon`. SVG is refused because it would be a scriptable document on this origin. Responses carry `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, and `Cache-Control: private, max-age=3600`. The fetch is bounded by a 3 s deadline covering DNS and every hop, at most 3 redirects, each of them re-validated, and 500 KB of body. Successful fetches are held in an in-process LRU (100 entries, 50 MB); failures never are. | Status | Body | When | | --- | --- | --- | | `400` | `{"error":"Missing thumbnail URL"}` | `u` missing | | `400` | `{"error":"Thumbnail URL too long"}` | `u` over 2048 characters | | `403` | `{"error":"Refusing to fetch a thumbnail from a non-public or unresolvable address"}` | The host is in private space, does not resolve, or `u` is not a parseable `http`/`https` URL. `/page-content` answers `400` for that last case; this endpoint does not distinguish it | | `502` | `{"error":"Thumbnail could not be fetched"}` | Upstream failed, timed out, redirected more than 3 times, answered with a type outside the list, or sent an empty body | | `500` | `{"error":"Internal server error"}` | Anything the hook itself threw, caught so Vite's connect stack does not see an unhandled rejection | Error responses carry `Cache-Control: no-store`, since neither a refusal nor an upstream failure is a stable property of the URL. ### `POST /inference` Streams a chat completion from the API configured through `INTERNAL_OPENAI_COMPATIBLE_API_*` (see `docs/configuration.md`), so an instance can offer a model without publishing its key. Requires `Content-Type: application/json` and the token as the `token` query parameter. The body is at most 1 MiB: ```json { "messages": [{ "role": "user", "content": "Hello" }], "temperature": 0.7, "top_p": 0.9, "max_tokens": 512 } ``` `messages` needs at least one entry, each with a `role` of `system`, `user` or `assistant` and a string `content`. `temperature` is clamped to 0-2, `top_p` to 0-1, and `max_tokens` to the server's `defaultMaxTokens` (`server/config/modelConfig.ts`). The response is an OpenAI-compatible SSE stream of `chat.completion.chunk` objects, ending with a chunk whose `finish_reason` is `"stop"` and then `data: [DONE]`: ``` data: {"id":"chatcmpl-1730000000000","object":"chat.completion.chunk","created":1730000000,"model":"some-model","choices":[{"index":0,"delta":{"content":"Hel"},"finish_reason":null}]} data: {"id":"chatcmpl-1730000000000","object":"chat.completion.chunk","created":1730000000,"model":"some-model","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} data: [DONE] ``` When a model stalls or fails before the first token, the server retries with another model from the provider's list, up to 5 attempts. That ladder needs the list: with `INTERNAL_OPENAI_COMPATIBLE_API_MODEL` pinned there is no second model to try, so the first failure is the last attempt. Once bytes have been written the status line is already sent, so a later failure arrives as a data frame carrying an `error` field, followed by `[DONE]`, rather than as a status code. The stream carries `Content-Type: text/event-stream`, `Cache-Control: no-cache` and `Connection: keep-alive`. Failures before the stream starts: | Status | Body | When | | --- | --- | --- | | `405` | `{"error":"Method Not Allowed"}` | Not a `POST`; the response carries `Allow: POST`. Checked before the token, so it needs no token and spends no rate-limit point | | `415` | `{"error":"Unsupported Media Type"}` | `Content-Type` is not JSON; also checked before the token | | `400` | `{"error":"Invalid request body"}` or `{"error":"Invalid request body: "}` | Unparseable or schema-invalid body | | `413` | `{"error":"Request body too large"}` | Body over 1 MiB | | `500` | `{"error":"OpenAI API configuration is missing"}` | `INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL` or `_API_KEY` unset | | `500` | `{"error":"Failed to fetch available models"}` | No model configured and the provider's listing failed | | `500` | `{"error":"No model available"}` | The listing succeeded but was empty | | `503` | `{"error":"Service unavailable - all models failed","lastError":"..."}` | Every attempt failed before the first token | | `500` | `{"error":"Internal server error","message":"..."}` | Anything else the hook threw | ### `GET /status` The hook claims the `/status` prefix, so `/status/anything` answers the same way. Unauthenticated and not rate-limited: uptime, the counters accumulated since the last restart, and the health of the reranker, the bi-encoder and SearXNG. The field reference is the `/status` section of `docs/overview.md`. Nothing in the response is per-user: queries, URLs and client addresses are never recorded, only aggregate outcomes (see the privacy section of `docs/security.md`). ### `GET /api/config` Unauthenticated by design: the client needs it before it can prove anything, and the access key page depends on it. Served with `Cache-Control: no-store`, so a restart with different environment variables takes effect on the next reload. ```json { "accessKeysEnabled": false, "accessKeyTimeoutHours": 0, "wllamaDefaultModelId": "...", "internalApiEnabled": false, "internalApiName": "...", "defaultInferenceType": "...", "searchToken": "..." } ``` The shape is `ServerConfig` in `shared/serverConfig.ts`. It reports whether a feature is on plus its display defaults, and never `ACCESS_KEYS`, `INTERNAL_OPENAI_COMPATIBLE_API_KEY`, or any other secret: a field added to that interface is published to anyone who can reach the instance (see the `/api/config` exposure section of `docs/security.md`). ### `POST /api/validate-access-key` Checks a client-hashed access key against `ACCESS_KEYS`. Consumes a point from the shared bucket before the argon2 loop, since a wrong hash costs one full verification per configured key. ```json { "accessKeyHash": "$argon2id$v=19$m=512,t=16,p=1$..." } ``` Responds `{"valid":true}` or `{"valid":false}`. A hash whose parameter block differs from `shared/argon2Parameters.ts` is answered `{"valid":false}` without any verification running. The body is capped at 4 KiB, which one encoded hash is nowhere near. Past that the answer is a `413` sent while the caller is still uploading, so it carries `Connection: close` and the socket is dropped once it has flushed. | Status | Body | When | | --- | --- | --- | | `429` | `{"error":"Too many requests."}` | Rate limited, kept distinct from a wrong key so the UI can say "try again" | | `400` | `{"valid":false,"error":"Invalid request"}` | Body is not JSON | | `413` | `{"error":"Request body too large"}` | Body over 4 KiB | ### `GET /dictation-models//` Serves the pinned speech-to-text model, so the page never contacts `download.moonshine.ai`. The version segment is `DICTATION_MODEL_VERSION` from `shared/dictationModel.ts`, currently `quantized_26_07_30`. Only seven filenames resolve: `frontend.ort`, `encoder.ort`, `adapter.ort`, `cross_kv.ort`, `decoder_kv.ort`, `streaming_config.json` and `tokenizer.bin`. Anything else is a `404`, so the route cannot be used as a proxy against the upstream host. No token and no rate-limit budget: the whitelist is what bounds it, and every file is on disk after the first request. The first request for a file fetches it from the upstream, checks it against a pinned SHA-256, and writes it under `DICTATION_MODELS_DIR` in a subdirectory named after the version. Concurrent first requests share one transfer. Responses are streamed and carry `Cache-Control: public, max-age=31536000, immutable`, which the version segment makes safe. | Status | Body | When | | --- | --- | --- | | `404` | `Unknown dictation model file` | The filename is not one of the seven | | `502` | `Could not serve : ` | The upstream failed, sent no body, exceeded the 64 MiB cap, or the bytes did not match the pinned digest | A failure once the body has started cannot be reported, because the headers are already gone; the response is ended rather than having an error appended to a truncated file. ## Related Topics - **Overview**: `docs/overview.md` - Server hook system and the `/status` field reference - **Security**: `docs/security.md` - Token lifecycle, access control, and privacy model - **Configuration**: `docs/configuration.md` - Environment variables the endpoints read - **Page Content**: `docs/page-content.md` - What `/page-content` does with the pages it reads - **Reranking**: `docs/reranking.md` - Where the search score comes from