# Public search API (`POST /api/search`)

First-class contract for Hidden Arrow first-party clients (web + iOS) and external
callers (route.bible, future apps). Query in → verses and lexicon evidence out.
Ranking and pipeline strategy stay private.

`/api/verdict` is observe / doctor only: with `HIDDEN_ARROW_OBSERVE` off it returns
the same scrubbed envelope; with observe on it may include strategy-adjacent fields
for local perf eval. Production clients must call `POST /api/search`.

## Call

```bash
curl -sS -X POST https://hidden-arrow.up.railway.app/api/search \
  -H 'content-type: application/json' \
  -H 'x-api-key: <HIDDEN_ARROW_SEARCH_KEYS>' \
  -H 'origin: https://route.bible' \
  -d '{"query":"peace"}'
```

Old Testament only:

```bash
curl -sS -X POST https://hidden-arrow.up.railway.app/api/search \
  -H 'content-type: application/json' \
  -H 'x-api-key: <HIDDEN_ARROW_SEARCH_KEYS>' \
  -d '{"query":"peace","testament":"ot"}'
```

Preview (same binary family once main deploys):

```bash
curl -sS -X POST https://hidden-arrow-preview-production.up.railway.app/api/search \
  -H 'content-type: application/json' \
  -H 'x-api-key: <HIDDEN_ARROW_SEARCH_KEYS>' \
  -d '{"query":"John 3:16"}'
```

### Request

| Field | Type | Notes |
| --- | --- | --- |
| `query` | string | Preferred. Max length matches the product search guard. |
| `claim` | string | Alias of `query` (legacy). |
| `testament` | `"ot"` \| `"nt"` | Optional. Omit for the whole canon. `ot` is Genesis–Malachi and `nt` is Matthew–Revelation. Retrieval and ranking run inside that testament, so the result is a full set rather than whatever remains after dropping the other canon. Unknown values are treated as omitted. |
| `scope` | `"all"` \| `"ot"` \| `"nt"` | Legacy alias. `"all"` matches an omitted `testament`. When both are sent, `testament` wins. |
| `verseContext` | `{ book, chapter, verse }` | Optional; lexicon tray path (no remote judge). |

The result cache key is the normalized query plus the resolved testament (`all` when omitted). An Old Testament search does not reuse a whole-canon or New Testament entry. The same `x-api-key` or `x-ha-page-token` gate applies either way.

A cache hit still counts toward the per-minute limit. The hit's `ms` is the time spent serving that request, and its `request_id` is this request's id (`x-request-id` when the client sent one, otherwise a new `ha_…` id). The stored body is not replayed as-is. Results that took a fallback, including a search that hit the deadline, are not stored.

Headers:

- `x-api-key` — one way through the gate. The server reads `HIDDEN_ARROW_SEARCH_KEYS` (comma-separated), trims each entry, and ignores empties. It compares the header to every entry in constant time. Any match passes. An unset or empty list fails the key path closed.
- `x-ha-page-token` — the other way through the gate. Hidden Arrow's own page embeds a short-lived token. API clients do not use it.
- `x-request-id` — optional. Echoed as `request_id` when present; otherwise the server mints an opaque `ha_…` id. A cache hit mints a new id when this header is absent.
- `x-client-version` — optional, observe-only (Dylan / doctor); never required.

### Response (200)

```json
{
  "request_id": "ha_…",
  "query": "peace",
  "ms": 712,
  "evidence": [
    {
      "book": "John",
      "chapter": 14,
      "verse": 27,
      "displayRef": "John 14:27",
      "href": "https://route.bible/jhn.14.27?src=hidden-arrow",
      "text": "…",
      "html": "…",
      "xrefsByVerse": {},
      "lexiconByVerse": {},
      "highlightRange": null
    }
  ],
  "notices": [],
  "attribution": [
    "Topics and cross references from openbible.info (CC BY 4.0)"
  ]
}
```

Definition cards use `kind: "definition"` plus a `lexicon` object (Strong's,
lemma, gloss, definition). User-facing `notices` (crisis / thin-result copy)
and CC BY `attribution` may appear. `attribution` is omitted when empty.

Errors (`400` / `401` / `429` / `503` / `502`) return `{ "error": "…", "request_id": "…" }`
on `/api/search` (a 401 from the gate is `{ "error": "Unauthorized." }` and may omit `request_id`).
They never name internal backends, stages, or the search key.

## Intentionally hidden

Public responses **do not** include:

| Hidden | Why |
| --- | --- |
| `stages_ms` (`route` / `jev` / `rerank` / `expansion`) | Pipeline timing recipe |
| `cost`, token counts, model names | Vendor / spend internals |
| `fallback` strings (`rerank failed`, …) | Teaches recovery paths |
| Proof route labels (`free_search`, `thin_hop`, `lexicon_tray`, …) | Algorithm vocabulary |
| `searchScore` / `confidence` / `relation` | Ranking signals |
| `questionKind` / `verdict` | Legacy polar envelope |
| TypeSafe / Jev / corpus recipe | Strategy |

Observe logs (`HIDDEN_ARROW_OBSERVE`) and the service doctor still record
`stages_ms` and cost for Dylan only. They never ride on the public HTTP body.
With observe on, `POST /api/verdict` may still return ranking diagnostics for
local doctor / verify-api; HA web and iOS do not call that route.

## CORS

Allowlist (browser clients):

- `https://route.bible`
- `https://www.route.bible`
- plus any origins in `HIDDEN_ARROW_CORS_ORIGINS` (comma-separated)

Methods: `GET`, `POST`, `OPTIONS`. Headers: `content-type`, `authorization`,
`x-api-key`, `x-ha-page-token`, `x-client-version`, `x-request-id`.

Same-origin Hidden Arrow web and native URLSession do not need CORS.

## Locked routes

These POST routes require a listed `x-api-key` or a valid `x-ha-page-token`. A missing or wrong credential is HTTP 401 before search, Jev, chip-rank, or topic suggestion work:

| Route | What it runs |
| --- | --- |
| `POST /api/search` | Public search |
| `POST /api/verdict` | Same search path; observe diagnostics only when `HIDDEN_ARROW_OBSERVE` is on |
| `POST /api/chip-rank` | Chip ranking (Jev when history is present; shuffle when it is not) |
| `POST /api/suggest-topics` | 3–5 next topics from the OpenBible graph and the lexicon |

Search and chip-rank are the routes that run a full Jev answer. Suggest-topics may make one choice call to order a surplus of topics, and it does not run search. These stay open without the key:

- `GET /health`, `GET /api/health`, and `GET /metrics`
- `GET /api/og` (share-card PNG; no search and no Jev)
- `GET /` and the rest of the static web app
- `GET /.well-known/apple-app-site-association` and `GET /apple-app-site-association`

API keys live in the server env `HIDDEN_ARROW_SEARCH_KEYS`. Do not commit a real key. Do not put `TYPESAFE_API_KEY` or any search key on a client. The old single name `HIDDEN_ARROW_SEARCH_KEY` is not read by the server.

`mise run health:prod` sends `x-api-key` from `HIDDEN_ARROW_DOCTOR_KEY`. That value is one entry of `HIDDEN_ARROW_SEARCH_KEYS`. The doctor reads it from the process environment, then a gitignored `.env`. Store the value in Proton Pass as `HIDDEN_ARROW_DOCTOR_KEY`. See `docs/service-doctor.md`.

The Hidden Arrow page still posts to `POST /api/search` and `POST /api/chip-rank` from the browser. When the server serves that page it mints `x-ha-page-token`: HMAC-SHA256 over an expiry timestamp about 10 minutes out, keyed by the server-only env `HIDDEN_ARROW_PAGE_TOKEN_SECRET`. The token is embedded as `<meta name="ha-page-token">`. The browser sends it back on those POSTs. The HMAC secret and the API keys stay off the page, the bundle, and the response. `Sec-Fetch-Site` is not a credential; a spoofed `same-origin` value does not open the routes.

If `HIDDEN_ARROW_PAGE_TOKEN_SECRET` is unset or blank, every page token is rejected and only a listed `x-api-key` works. Cross-origin callers (route.bible, iOS, curl) send `x-api-key` themselves.

## Auth

A locked route accepts the request when either credential is valid:

- `x-api-key` matches one entry of `HIDDEN_ARROW_SEARCH_KEYS`. An unset or empty list fails that path closed.
- `x-ha-page-token` is a token this server minted, the HMAC matches `HIDDEN_ARROW_PAGE_TOKEN_SECRET`, and the expiry is still in the future. A missing secret fails that path closed.

If both paths fail, the route returns 401 and does not run search, Jev, chip-rank, or topic suggestion. Rate limits and the result cache still apply after the gate, including on cache hits. Rotate keys and the page-token secret out of band.

## Guards

The per-minute limit is 10 requests per client. The client address is `X-Real-IP`, which Railway's public HTTP edge overwrites with the connecting address. `CF-Connecting-IP` is ignored. `X-Forwarded-For` is not parsed, because that header's hop count is not a stable Railway contract.

Each API key has 4 in-flight slots. A page token has no key, so those calls use one slot bucket per `X-Real-IP`. One key's stalled calls do not occupy another key's slots.

`POST /api/chip-rank` uses the same per-minute limit and an in-process cache of successful rankings. A ranking that fails upstream is not stored.

`GET /health`, `GET /api/health`, and `GET /metrics` report cache hit and miss counters. `/metrics` is `{"cache_hits","cache_misses"}`. Health adds the same counts under `cache`. Each finished search also logs one `search_cost` line: Jev calls, candidate count, and estimated dollars at the Jev list rate.

## Monthly quota

`HIDDEN_ARROW_QUOTA` defaults off. Leave it unset and these routes match the build without a monthly cap: no quota JSON, no `RateLimit-*` headers, no `usage` log line.

Set it to `on` to enforce the caps in `config/plans.json` (or `HIDDEN_ARROW_PLANS` / `HIDDEN_ARROW_PLANS_FILE`). A key in that file is `{"id","plan","secret"}`. A key that is allowed by `HIDDEN_ARROW_SEARCH_KEYS` but missing from the file uses `default_plan`. A page token is not an API key and is not counted. There is no overage charge.

A successful keyed call on `/api/search`, `/api/verdict`, `/api/chip-rank`, or `/api/suggest-topics` consumes one unit, including a cache hit and an exact reference. The counter is the calendar month in US Eastern. Past the cap the route returns 429:

```json
{
  "error": "Monthly search quota reached.",
  "code": "quota_exceeded",
  "plan": "indie",
  "limit": 10000,
  "used": 10000,
  "reset": "2026-11-01T04:00:00Z",
  "resets_at": "2026-11-01T04:00:00Z",
  "next_plan": "pro",
  "upgrade": {"plan": "pro", "price": "$99/mo", "limit": 35000, "url": "https://hiddenarrow.app/pricing#pro"},
  "request_id": "ha_…"
}
```

`reset` is the next US Eastern month boundary, as UTC. Each counted call also logs `usage` with `key_id`, `plan`, `month`, `used`, and `limit`. The line does not include the secret.

Counts are process memory unless `HIDDEN_ARROW_USAGE_PATH` points at a JSON file. That file disappears with the container unless the path is on a Railway volume. This repo does not create that volume.

## Pay per call

`HIDDEN_ARROW_X402` defaults off. While it is off, `POST /x402/v1/search` is not registered. The key routes do not read `PAYMENT-SIGNATURE`.

Set the flag to `on` to register the route. The price is $0.005 (`amount` `5000`) in USDC on Base Sepolia (`eip155:84532`) only. Mainnet is not accepted. The receiving address is `HIDDEN_ARROW_X402_PAY_TO`. Leave it unset: the process still starts, and the route answers `503` with `x402_unconfigured`.

A missing payment header is `402` with `PAYMENT-REQUIRED`. The search runs only after the payment checks out. A successful search settles once and returns `PAYMENT-RESPONSE`. A failed or degraded search does not settle. The same `PAYMENT-SIGNATURE` retries the stored response and does not settle again. The payment log records payer, nonce, amount, transaction, and `request_id`. It does not record the query.

The facilitator in this build is local. It does not use CDP keys and it does not submit a transaction.

## Suggest topics

`POST /api/suggest-topics` takes the caller's recent searches and returns related topics that are ready to submit to `POST /api/search`.

```bash
curl -sS -X POST https://hidden-arrow.up.railway.app/api/suggest-topics \
  -H 'content-type: application/json' \
  -H 'x-api-key: <HIDDEN_ARROW_SEARCH_KEYS>' \
  -d '{"recent":["anxiety","John 3:16","forgiveness"]}'
```

The body is `{ "recent": string[] }`. The server trims each entry, drops blanks and passage references, dedupes, and keeps the first 10. The response is `{ "topics": [{ "label", "query", "why"? }] }` with 3 to 5 topics when the graph has them. `label` is the chip: sentence case, with proper nouns and apostrophes restored (`God's peace`). `query` is the string to send as `/api/search`'s `query`, and it keeps the OpenBible wording when that is what search matches. Topics repeat neither the recent searches nor those passage references. A subject is suggested only when at least one passage has 10 or more votes (the cross-reference hop floor) and at least 3 passages have 3 or more votes (the cross-reference loader floor). At most one suggestion shares a stem with each recent search.

Suggestions come from the OpenBible subject graph already loaded for search, with lexicon glosses as a second signal. When more than five topics qualify and a Jev client is configured, one choice call orders them. Jev does not write the labels. The same normalized input is cached by its hash. The server does not store the searches. The health doctor records only the recent-search count, the topic count, and latency.

## OpenAPI snippet

Live description: `https://hiddenarrow.app/openapi.json` (OpenAPI 3.1).

```yaml
openapi: 3.0.3
info:
  title: Hidden Arrow Public Search
  version: "1.0"
paths:
  /api/search:
    post:
      summary: Search Scripture (obfuscated strategy)
      parameters:
        - in: header
          name: x-api-key
          required: false
          schema: { type: string }
          description: One entry of server env HIDDEN_ARROW_SEARCH_KEYS. Do not put a value in the repo. Required when x-ha-page-token is absent.
        - in: header
          name: x-ha-page-token
          required: false
          schema: { type: string }
          description: Short-lived HMAC minted into the Hidden Arrow page. Required when x-api-key is absent.
        - in: header
          name: x-request-id
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                query: { type: string }
                claim: { type: string, description: Alias of query }
                testament:
                  type: string
                  enum: [ot, nt]
                  description: Optional. Omit for the whole canon. ot is Genesis–Malachi; nt is Matthew–Revelation. Included in the result cache key.
                scope:
                  type: string
                  enum: [all, ot, nt]
                  description: Legacy alias of testament. all matches an omitted testament.
                verseContext:
                  type: object
                  properties:
                    book: { type: string }
                    chapter: { type: integer }
                    verse: { type: integer }
              anyOf:
                - required: [query]
                - required: [claim]
      responses:
        "200":
          description: Verses / definitions without strategy fields
          content:
            application/json:
              schema:
                type: object
                required: [request_id, query, ms, evidence, notices]
                properties:
                  request_id: { type: string }
                  query: { type: string }
                  ms: { type: integer }
                  evidence:
                    type: array
                    items: { type: object }
                  notices:
                    type: array
                    items: { type: string }
                  attribution:
                    type: array
                    items: { type: string }
        "400":
          description: Missing query or search not configured
        "401":
          description: Missing or wrong x-api-key and no valid x-ha-page-token
        "429":
          description: Rate limited
        "502":
          description: Upstream answer failure
        "503":
          description: Busy
  /api/verdict:
    post:
      summary: Observe / doctor search. Same key or page-token gate as /api/search.
      parameters:
        - in: header
          name: x-api-key
          required: false
          schema: { type: string }
        - in: header
          name: x-ha-page-token
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Scrubbed search envelope, or observe diagnostics when HIDDEN_ARROW_OBSERVE is on
        "401":
          description: Missing or wrong x-api-key and no valid x-ha-page-token
  /api/chip-rank:
    post:
      summary: Rank home-query chips. Same key or page-token gate.
      parameters:
        - in: header
          name: x-api-key
          required: false
          schema: { type: string }
        - in: header
          name: x-ha-page-token
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Ordered chip queries
        "401":
          description: Missing or wrong x-api-key and no valid x-ha-page-token
  /api/suggest-topics:
    post:
      summary: Suggest next Scripture topics from recent searches. Same key or page-token gate.
      parameters:
        - in: header
          name: x-api-key
          required: false
          schema: { type: string }
        - in: header
          name: x-ha-page-token
          required: false
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recent]
              properties:
                recent:
                  type: array
                  items: { type: string }
      responses:
        "200":
          description: 3 to 5 topics whose query can be posted to /api/search
        "401":
          description: Missing or wrong x-api-key and no valid x-ha-page-token
servers:
  - url: https://hidden-arrow.up.railway.app
  - url: https://hidden-arrow-preview-production.up.railway.app
```

## Quality

`/api/search` runs the same `search_scoped` path as the observe verdict handler.
Ranking and recall are unchanged; only the HTTP envelope is scrubbed.

## MCP

`POST /mcp` is Streamable HTTP (MCP `2026-07-28`, with `initialize` still answered for earlier versions). It is read-only.

Send `Authorization: Bearer <API key>`. The key is one entry of `HIDDEN_ARROW_SEARCH_KEYS`, the same list as `x-api-key` on `POST /api/search`. Anonymous access is off unless `HIDDEN_ARROW_MCP_ANONYMOUS=on`.

Tools:

- `search_scripture` — `query` and optional `limit` (1–10). Same search pipeline and `search:` cache as `POST /api/search`. Each verse has `reference`, exact BSB `text`, and a `https://route.bible/` link.
- `get_passage` — `reference` such as `John 3:16`. Exact BSB text. No model call.

Discovery: `GET /.well-known/ai-catalog.json` points at `GET /mcp/server-card`. `GET /llms.txt` names the endpoint.

The same per-minute windows (client IP from `X-Real-IP`, and the API key) and the same per-key concurrency limit apply to each `POST /mcp`. `HIDDEN_ARROW_QUOTA` defaults off. When it is `on`, a keyed `tools/call` counts against that key's monthly cap and, once the cap is reached, returns the same 429 body as `POST /api/search`.
