# FlintTrade API Reference FlintTrade owns the terminal’s authenticated HTTP transport. A broker session cannot be started from the terminal today. Practice mode uses the local sandbox. Broker writes return 503 and broker reads return 409. | Surface | Base URL (production) | Base URL (Vite dev proxy) | Purpose | |---|---|---|---| | FlintTrade backend | `http://:5100/v1/` and `http://:5100/api/v1/` | `/ft-api/v1/` and `/ft-api/api/v1/` | FlintTrade-specific endpoints. Blueprints mount at `/v1` *or* `/api/v1` — match the prefix the frontend uses. Operations, native brokers, and orders live under `/api/v1`. | > **WSGI prefix strip.** Blueprints mount at `/v1/*` *or* `/api/v1/*` — > match the prefix the frontend uses. The Vite dev proxy and a production > reverse proxy add `/ft-api` before forwarding, and WSGI middleware strips > that prefix before URL dispatch. A client path `/ft-api/v1/X` is `/v1/X` > inside the backend; `/ft-api/api/v1/X` is `/api/v1/X`. Never double-prefix. --- ## 2. FlintTrade backend (`/ft-api/v1/*`) ### Order submission Four routes submit an order. Every order FlintTrade submits goes through admission when it's placed. Nothing else does, including `POST /api/v1/orders/place-smart`, `POST /api/v1/orders/open-position`, and `POST /api/v1/orders/close-position`, which are not mounted. The Practice book under `/v1/sandbox/` can cancel or modify a resting order. It has no place route and no square-off route. Practice square-off is an opposite order on `POST /api/v1/orders/place`. Settings → Practice adjusts virtual capital and square-off times; it does not place. | Route | What it does | |---|---| | `POST /api/v1/orders/place` | Practice and Live single-leg place. The server admits the body through Laya. A client flag cannot choose reduce-only. Practice then fills or rests in the sandbox and does not enter SafetySystem. Live then runs SafetySystem, `gate_order`, and `BrokerRouter`. `"variety": "gtt"` is HTTP 422 `gtt_unsupported` before that admission. Example data that calls this route is HTTP 403 `mode_blocked`: `Orders are not available for Example. Switch to Practice or Live to trade.` The Terminal Order Pad records `Example order placed` (id starting `SAMPLE-`) on the client for that click. | | `POST /api/v1/orders//place` | Live only. `` is the adapter id. The same dispatcher admits through Laya and then runs SafetySystem on that place. `"variety": "gtt"` is HTTP 422 `gtt_unsupported` before admission, including when the variety spelling differs only by case or separators. A non-Live session is HTTP 400 (`The routed order path serves live mode only. Use /api/v1/orders/place for explore/practice.`). | | `POST /api/v1/positions/exit-all` | Live, PIN-unlocked. `"variety": "gtt"` is HTTP 422 `gtt_unsupported` before the live check, the reduce-only proof, and any broker call. Body must include boolean `"confirm": true` or the route returns HTTP 400. The server classifies every open contract and records a reduce-only proof before the gated `exit_all_positions` verb. A row that is not an exit stops the request with HTTP 409 and `Square-off stopped because a position is not a reduce-only exit.` An unreadable broker order book cannot qualify a live reduce-only close, so open positions stop here and no square-off is sent. Unavailable position evidence is HTTP 503 `Position evidence unavailable; no square-off was sent.` Ambiguous position evidence is HTTP 409 `Position evidence is ambiguous; no square-off was sent.` Neither sends a square-off. Known empty or flat positions retain the gated, acknowledgement-only operation. | | `POST /api/v1/orders/bracket` | Live, PIN-unlocked. Entry plus exactly one of a stop-loss or a target. Each leg is admitted through Laya, then placed through SafetySystem, `gate_order`, and `BrokerRouter`. Success is HTTP 201. Practice is HTTP 403 `practice_unsupported`. `"variety": "gtt"` is HTTP 422 `gtt_unsupported` before that admission. A broker-held variety is HTTP 422 `broker_held_unsupported` (`Not placed. Broker-held bracket legs aren't supported. Use one stop-loss or one target.`). A stop-loss and a target together are HTTP 422 `oco_unsupported`. A trailing stop is HTTP 422 `trailing_unsupported`. | `POST /api/v1/orders/cancel-all` cancels open orders. Practice cancels pending sandbox orders. Live uses the gated `cancel_all_orders` verb and does not place. A Live body with `strategy` set is HTTP 400, because that verb cannot narrow by strategy. Reduce-only is decided on the server. A close qualifies when it is the same contract, the opposite side, and the quantity is no more than the open quantity minus pending exits. On Live, the position book and the broker order book must both be readable. Pending exits include the broker's open orders and this desk's own pending exits. When the broker order book cannot be read, a reduce-only close is refused and the order takes the full check. There is no unreadable-book fallback. When the position book cannot be read, the place is not classified as a close. A second exit on the same broker account, while one of this desk's exits on that contract is still unfilled, is HTTP 409 `exit_pending`, with `message` and `reason` both `Not placed. An exit for SYMBOL is already pending. Wait for it to fill, or cancel it and try again.` Practice uses this code on the Practice book. On Live it is the code when the broker order book can be read. The Live hold is for that broker account. When a retained exit exists and the broker order book cannot be read, the code is `exit_orders_unreadable`, and `message` and `reason` are both `Not placed. Broker orders for SYMBOL are unavailable. Reconcile them before another exit.` The Positions row shows **Exit pending** for the unfilled exit. The label is the symbol, or `this contract` when the symbol is empty. Before Laya, place refuses a quantity that is not a positive multiple of the lot, or that is above the freeze limit, with HTTP 400 and `code: "quantity_admission"`. The Order Pad shows the lot sentence once, inline: `Quantity must be a multiple of the lot size (N).` `N` is that contract's lot. An unverified lot is `Not placed. The lot size for isn't verified.` `` is the contract's display name, shown once, inline. The Practice sandbox returns that sentence as HTTP 400 with `status: error` and no `code` field. An order over the per-order freeze limit is refused with HTTP 400 and code `quantity_admission`. The message gives the limit, names the instrument, and asks for smaller orders. The order is not split. The limit is the broker `freeze_qty` when the body carries one, otherwise the exchange figure. When the request sends `X-FlintTrade-Mode` and that value differs from the JWT `mode` claim, place, the broker place route, and the extended gated order routes return HTTP 403 `{"status":"error","message":"X-FlintTrade-Mode does not match the authenticated mode"}`. That body has no `code` field. A missing header, or a header equal to the claim, is not a mismatch. While Laya is Down, a new order is paused and a qualifying close is still admitted. Live still runs SafetySystem after that record. The desk line is `Laya is Down. New orders are paused until it's Ready. You can still close positions.` While Laya is not Ready, Positions, the Practice Order Pad and the Live Order Pad show `Exit requested. Exits are allowed while Laya is Down. Check positions and orders for the outcome.` Exit-all shows `Exit-all requested. Check positions and orders for the outcome.` A position whose sign flips after the broker book has loaded keeps its row, tagged **Unexpected**, and the Positions book shows `Position changed after your broker's orders loaded. You're now . Close it if that wasn't intended.` until dismissed. Layer 5 (`POST /api/v1/safety/kill-switch`) cancels resting orders and then flattens through the emergency `exit_all_positions` verb. Ditto execution endpoints, including `/api/v1/ditto/kill-all`, remain unavailable until native cutover. ### Analysis (`/api/v1/*`; Vite proxy `/ft-api/api/v1/*`) Powered by `packages/services/screener/`. The analysis blueprint mounts at `/api/v1`, not `/v1`. POST endpoints expect a JSON body (`symbol`, `exchange`, and `expiry` / `expiry_date` for option-chain tools). GET endpoints are marked. | Endpoint | Purpose | |---|---| | `gex` (**POST**) | FlintTrade analysis route: Gamma Exposure dashboard data, computed locally on historical chains. | | `volsurface` (**POST**) | Volatility surface across strikes and expiries. | | `ivsmile` (**POST**) | IV smile curve. | | `straddlepnl` (**POST**) | Live straddle P&L for an at-the-money pair. | | `oiprofile` (**POST**) | OI profile data. | | `maxpain` (**POST**) | Max-pain calculation. | | `gammadensity` (**POST**) | Gamma density surface across strikes and expiries. | | `screener/fii-long-short` (**GET**) | FII long/short ratio from participant-wise derivative positions. | | `screener/fii-dii` (**GET**) | FII/DII cash-market activity summary. | | `screener/arbitrage` (**POST**) | Cash-future and cross-exchange arbitrage scan. | | `candlestick-patterns` (**POST**) | Candlestick pattern detection over OHLCV history. | | `/v1/index-contribution` (**GET**) | Index constituent contribution — `breadth_bp` at `/v1`, not `analysis_bp`. | ### Chart preferences (`/api/v1/chart`) | Endpoint | Purpose | |---|---| | `chart` (**GET/POST**) | Chart-preference get/set. | ### Native broker connect and reads (`/api/v1/native/*`) Source: `packages/core/core/src/flinttrade_core/native_account_routes.py`. These endpoints are the first-party native broker HTTP surface the terminal still calls from Setup → Brokers and Settings → Brokers. **They are not a working operator path on this unreleased line.** Account-management writes (POST / DELETE / PUT / PATCH, plus the GET OAuth callback) return `503` `broker_account_cutover_unavailable`. Account and market-data reads return `409` with zero provider calls. Broker writes and broker reads are not available in the terminal. Exact reads that already exist use the in-process `BrokerReadPort`. Exact broker reads that already exist are that in-process port, not this HTTP family and not terminal UX. Brokers that are built but not cleared for activation stay `connectable=false` while any declared `native_connect_blocker` remains. Legacy gateway `/v1` account/auth mutations are likewise unavailable (`503`) and still reject native broker ids with `data.native_connect_blockers` when a catalogued native broker is evidence-gated. | Endpoint | Current behaviour | |---|---| | `native/brokers` (**GET**) | Native broker catalogue with connectability, native-connect blocker reasons, static-outbound-IP requirement, login-method schemas, OAuth/postback URLs, and MCP metadata. | | `native/accounts` (**GET**) | Vault-backed native account list (metadata only). Not a live session refresh. | | `native/accounts` (**POST**) | Broker connect is unavailable. Returns `503`. Body shape remains `{adapter_id, account_id, label?, credentials, is_primary?}`. | | `native/oauth/start` (**POST**) | Unavailable. Returns `503`. | | `native/oauth/callback` (**GET**) | Unavailable before state consumption. Returns `503`. | | `native/postbacks/` (**POST**) | Bounded, redacted broker postback intake for diagnostics/order-update evidence; not an order execution path. Not an account-mutation cutover route. | | `native/accounts///login` (**POST**) | Re-authentication is unavailable. Returns `503`. | | `native/accounts///` (**GET**) | Native HTTP read is unavailable. Returns `409` with zero provider calls. Documented kinds (`funds`, `limits`, `positions`, `holdings`, `profile`, `orders`, `orderstatus`, `orderhistory`, `ordertrades`, `trades`, `ltp`, `quotes`, `quote_details`, `ohlc`, `depth`, `margin`, `scrip_master`, `holidays`, `timings`, `optiongreeks`, `history`, `expiry`, `optionchain`, `search`, `search_scrip`) are the intended surface once broker sessions exist, not a current live-session API. | | `native/accounts///set-primary` (**POST**) | Unavailable. Returns `503`. When broker sessions exist, this remains the write-default gate for a connected, non-read-only native session. | | `native/accounts//` (**DELETE**) | Removal is unavailable. Returns `503`. | Broker-hosted MCP setup catalogue: `broker/mcp` (**GET**). Metadata only; FlintTrade does not proxy MCP tool calls. ### Broker capability metadata (`/api/v1/broker/*`, GET) Source: `packages/integrations/gateway/src/flinttrade_gateway/capabilities_routes.py`. | Endpoint | Purpose | |---|---| | `broker/capabilities` (**GET**) | Per-broker capability matrix (order types, segments, depth, rate limits), with native connectability, blocker reasons, and static-outbound-IP requirement where catalogued. | | `broker/recommendations` (**GET**) | Filter broker capability metadata for an operator-selected use case, including display name, connectability, native-connect blocker reasons, and static-outbound-IP requirement. `?use_case=` for one job (for example `low_cost_execution`, `market_depth`); `?brokers=a,b` restricts the response to connected brokers. | ### Service providers and connections (`/ft-api/v1/services/*`) Source: `packages/core/core/src/flinttrade_core/service_provider_routes.py`, `service_connection_routes.py`, `service_providers.py`, and `service_connections.py`. This is a static, non-invoking control plane. Listing a provider or persisting a connection does not resolve, probe, authenticate to, or start that provider. The catalogue is composed at app startup from the AI, historical, and gateway contributor descriptors. Catalogue `service_kinds` values include `broker_execution`, `market_data_live`, `market_data_historical`, `news`, `llm`, `forecast`, `agent_runtime`, `embedding`, and `decision`. `decision` entries are catalogue providers for place admission, not LLM chat profiles. Their default evidence scope is offline qualification until a Live qualification record exists. Listing them does not probe or start the host. | Endpoint | Purpose | |---|---| | `services/providers` (**GET**) | Static provider catalogue, returned as `{"status": "success", "data": {"catalogue_digest": …, "count": …, "providers": […]}}`. Requires `admin.observability.read` on a session JWT; an API-key request with no session token is treated as holding every scope. Returns 503 if the catalogue is unavailable. | | `services/connections` (**GET**) | List every redacted inert LLM connection. Loopback only. Session JWT with `admin.services.read`, or `X-API-Key` / Bearer API key for GET/HEAD. | | `services/connections` (**POST**) | Persist one inert LLM connection (`provider_id`, `label`, optional `model`, and provider-dependent `endpoint` / `auth_mode` / `credential`). Authenticated profiles require a supported `auth_mode`; host-based profiles require `endpoint`, while fixed or managed profiles reject endpoint overrides. Unauthenticated profiles reject credentials. Does not call the provider. Session JWT with `admin.services.write` required — an API key cannot mutate. | | `services/connections/` (**GET**) | One redacted connection by canonical UUID4. Same read auth as the collection. | | `services/connections/` (**PATCH**) | Update label / model / endpoint / auth_mode (and optional credential rotation). `provider_id` is immutable. Same write auth as create. | | `services/connections/` (**DELETE**) | Delete one inert connection. Same write auth as create. JSON body must be an empty object. | Connection reads and writes are loopback-only. Non-loopback peers receive 403 `forbidden` before route dispatch. Mutations require `If-Match` (collection ETag) and `Idempotency-Key` (UUID4); missing `If-Match` is 428 `connection_revision_required`. Mutation endpoints share a 10-per-minute limit. Individual redacted connection objects include `schema_version`, `provider_id`, `connection_id`, `label`, `model`, `endpoint`, `auth_mode`, `credential_configured`, `created_at`, and `updated_at` — never the secret material. The collection read wraps those objects in `connections`; a delete returns only `{"deleted": true}`. Every service-connection-family response is `Cache-Control: no-store`. The in-process `BrokerReadPort` (quotes, depth, history, account books, and related exact reads) is not this HTTP surface and is not terminal UX. Native HTTP reads stay `409`. Callers use the in-process port, not this HTTP family. See [ARCHITECTURE.md](ARCHITECTURE.md#broker-reads-versus-gated-writes). ### News (`/api/v1/news`) Source: `packages/core/core/src/flinttrade_core/operations_routes.py`. The operations blueprint mounts at `/api/v1`, so the Vite/dev-proxy form is `/ft-api/api/v1/news`. The terminal News widget calls this route only. | Endpoint | Purpose | |---|---| | `news` (**GET**) | Server-side fetch of the static RSS publisher profiles (MoneyControl, ET Markets, LiveMint). There is no browser-side RSS or CORS-proxy fallback. | ### AI (`/api/v1/advisor*`, `/api/v1/ai/*`, `/api/v1/signals/*`; Vite proxy `/ft-api/api/v1/…`) Source: `packages/services/ai/`. Blueprints mount at `/api/v1`. GET unless noted. Advisor Chat uses `/api/v1/advisor` (not `/ai/advisor`). Managed local inference (`/v1/ai/local-runtime`) is the exception — that blueprint stays under `/v1`. | Endpoint | Purpose | |---|---| | `advisor/status` (**GET**) | LLM readiness for Chat Connected honesty. `data.source` is `env` (explicit `LLM_PROVIDER`), `stored` (Settings → AI), or `default` (empty→ollama implicit default — not Connected). `configured` is true only for `env` or `stored`. | | `advisor` (**POST**) | Chat completion. Analysis only — does not place Live or Practice orders. Returns 503 when no explicit LLM is configured. | | `advisor/stream` (**POST**) | SSE variant of `advisor`. Same body; tokens then `done: true`. | | `signals/recent` (**GET**) | Recent ML/indicator signals (the live signal source). | | `ai/sentiment/summary` (**GET**) | Market-wide sentiment summary; neutral when no feed is connected. | | `ai/sentiment/tickers` (**GET**) | Per-ticker sentiment from news feeds. | | `ai/regime?symbol=` (**GET**) | ADX/ATR/BB market regime for a symbol (requires connected market data). | | `sentiment/analyse` (**POST**) | Sentiment for a text snippet or symbol (LLM, rule-based fallback). | | `ai/refine-strategy` (**POST**) | AI improvement suggestions for a backtested strategy. | | `rag/query` (**POST**) | Knowledge-base RAG query (when RAG is enabled). | Managed local inference is controlled through authenticated, localhost-only routes under `/ft-api/v1/ai/local-runtime`. Runtime and model downloads never start at boot and require an explicit confirmation payload. Every mutating POST except `stop` also requires a client-generated `admission_id` matching `adm_[0-9a-f]{32}`. The status operation echoes that admission ID, so a caller can retry the same request idempotently and reconcile an HTTP timeout. `stop` instead accepts only the exact ID of a currently running operation; terminal operation IDs are rejected. Detailed receipts are size- and count-bounded; IDs compacted from the detailed journal remain fail-closed in a fixed-size spent-ID filter. An `indeterminate` receipt means the mutation outcome cannot be proved: clients must not issue a replacement admission ID. Further mutations remain blocked until the operator explicitly acknowledges that exact operation and its original admission ID through `operations/reconcile`. Acknowledgement does not replay the mutation or change its outcome to success; it records only that the unknown result was reviewed. The terminal validates every successful status, model-list and direct-result payload before changing local state, and clears a pending admission only when a direct receipt has the exact shape required by that action. | Endpoint | Purpose | |---|---| | `ai/local-runtime/status` (**GET**) | Report the managed release, target and rollback versions, ownership, readiness, integrity, progress and teardown state. | | `ai/local-runtime/install` (**POST**) | Download, hash-verify and install the pinned Ollama release after confirmation. | | `ai/local-runtime/update` (**POST**) | Stage the preferred release while stopped, then retain the previously active verified release for rollback. | | `ai/local-runtime/repair` (**POST**) | Recover invalid version metadata or replace a corrupt managed release while no Ollama listener is active. | | `ai/local-runtime/rollback` (**POST**) | Rehash and activate the retained previous release while stopped. | | `ai/local-runtime/uninstall` (**POST**) | Remove recognised managed runtime releases while preserving models and model-trust metadata. | | `ai/local-runtime/start` · `ai/local-runtime/stop` (**POST**) | Start or stop only the Ollama process owned by this backend. | | `ai/local-runtime/operations/reconcile` (**POST**) | Explicitly acknowledge one indeterminate receipt using its exact operation and original admission IDs, without replaying it or inferring success. | | `ai/local-runtime/models` (**GET**) | Return the live bounded model catalogue after reconciling FlintTrade trust metadata. | | `ai/local-runtime/models/pull` (**POST**) | Download one validated model identifier after confirmation. | | `ai/local-runtime/models/delete` (**POST**) | Delete one exact unselected model name after confirmation. | | `ai/local-runtime/models/prune` (**POST**) | Delete only unreferenced `flinttrade/sha256-*:locked` aliases; never walk arbitrary model blobs. | | `ai/local-runtime/models/digests/accept` (**POST**) | Confirm one exact live digest and create a digest-derived inference alias. | | `ai/local-runtime/models/digests/reset` (**POST**) | Remove invalid FlintTrade trust metadata without deleting Ollama model data. | ### Practice sandbox (`/ft-api/v1/sandbox/*`) Practice simulated fills. The session claim for this book is `practice`. Source: `packages/core/data/src/flinttrade_data/sandbox_routes.py`. | Endpoint | Purpose | |---|---| | `sandbox/status` (**GET**) | Combined status: current + initial capital, P&L, trade count. | | `sandbox/capital` (**GET**) | Full capital state (initial / current / available / used margin). | | `sandbox/capital/adjust` (**POST**) | Add or remove virtual capital (`{amount}`). | | `sandbox/positions` · `sandbox/orders` · `sandbox/trades` · `sandbox/pnl` (**GET**) | Book, fill, and P&L reads. Orders are placed through `POST /api/v1/orders/place`. | | `sandbox/order/` (**DELETE** / **PATCH**) | Cancel or modify one pending Practice order. | | `sandbox/orders/cancel-all` (**POST**) | Cancel every pending Practice order. Does not place. | | `sandbox/reset` (**POST**) | Clear all paper data (returns a backup). | | `sandbox/export` (**GET**) · `sandbox/import` (**POST**) | Export sandbox state, or restore it. Restore requires a Practice session JWT. Any other session, including an API key, is HTTP 403 `Restore is available in Practice Mode only.` Restored fills are stored with strategy `Restored from backup`. They are not sent to a broker and do not re-enter the order path. The desk shows a **Restored** tag whose tooltip is `Restored from backup. Not sent to a broker or checked by Laya.` Laya, strategy, benchmark, and training readers omit them. P&L still counts them. Performance shows `Excludes 1 restored fill` or `Excludes N restored fills`, and hides that line when the count is 0. | ### Strategies (`/api/v1/strategies/*`; Vite proxy `/ft-api/api/v1/strategies/*`) Source: `packages/services/engine/src/flinttrade_engine/strategy_routes.py`. The blueprint mounts at `/api/v1/strategies`. Backed by the `STRATEGY_RUNNER` + `CRON_SCHEDULER` wired at app creation. | Endpoint | Purpose | |---|---| | `strategies` (**GET**) | List uploaded user strategies (engine runner). Lab also lists the same runner via `GET /api/v1/backtest/strategies/uploaded`. There is no `/api/v1/strategies/uploaded` route. | | `strategies/upload` (**POST**) | Upload + validate a strategy. | | `strategies//start` · `…/stop` (**POST**) | Start / stop a running strategy. | | `strategies//logs` (**GET**) | Tail a strategy's logs. | | `strategies//schedule` (**POST**) · `strategies/scheduled` (**GET**) | Cron-schedule a strategy. | ### Trade journal (`/ft-api/api/v1/trades/*`) Source: `packages/core/core/src/flinttrade_core/operations_routes.py`. The journal reads recorded executions from the shared DuckDB store. A successful order submission is **not** a fill: manual, webhook and autonomous-agent acknowledgements retain their submission audit events but do not insert the requested price and quantity as an executed trade. Authoritative native fill ingestion is not connected to this store yet, so new live submissions do not populate the execution journal. Existing rows are retained unchanged; older submission-derived rows are not retroactively verified as fills. Practice fills remain owned by the local sandbox. Live P&L is computed client-side in the MTM Monitor widget from real positions. | Endpoint | Purpose | |---|---| | `trades/journal` (**GET**) | Recorded trades. No params → today; `start_date`+`end_date` → history window across all strategies; `+strategy` → that strategy only. `limit` defaults to 100 and caps at 1000 (oldest-first). `data.total` is the untruncated match count. Rows are keyed `timestamp` (ISO, IST), with `symbol`, `action`, `quantity`, `price`, `pnl`, `strategy`, `orderid`. | ### Safety (`/api/v1/safety/*`) Source: `packages/core/core/src/flinttrade_core/operations_routes.py`. The operations blueprint mounts at `/api/v1`, so the Vite/dev-proxy form is `/ft-api/api/v1/safety/…` and a direct backend call is `http://:5100/api/v1/safety/…`. These are not under `/v1/`. | Endpoint | Purpose | |---|---| | `safety/config` (**GET** / **POST**) | Read or update local safety parameters and the current kill-switch / Layer 4 pause state. | | `safety/l4` (**DELETE**) | Clear one account's latched Layer 4 daily-loss pause or hard stop. Requires both `broker` and `account_id` (query string or JSON body); the backend builds the exact selector `{broker}:{account_id}`. A PIN-unlocked Live JWT is required, and that selector must be in the operator's account ACL. Missing selector → 400 `"L4 reset requires an exact account selector"`; unauthorised selector → 403. Does not activate or reset Layer 5. | | `safety/kill-switch` (**POST**) | Latch Layer 5. Body `{ "reason": "…" }`. Cancels open orders and requests supported flatten. | | `safety/kill-switch` (**DELETE**) | Reset Layer 5 after emergency actions complete. Incomplete flatten keeps the latch. | ### Cron / Schedules (`/api/v1/cron/*`) Source: `packages/core/core/src/flinttrade_core/operations_routes.py`. The operations blueprint mounts at `/api/v1`, so the Vite/dev-proxy form is `/ft-api/api/v1/cron/…` and a direct backend call is `http://:5100/api/v1/cron/…`. These are not under `/v1/`. | Endpoint | Purpose | |---|---| | `cron/jobs` (**GET**) | List registered cron jobs with status (`name`, `description`, `trigger_type`, `status`, `last_run`, `run_count`, `error_count`). | | `cron/jobs//pause` (**POST**) | Pause a job by name. Sample-data writes (JWT `mode` claim or `X-FlintTrade-Mode: explore`) return HTTP 403 with `code: "mode_blocked"`. Operators see example data, not a Mode in the menu. Practice and Live are not blocked by this gate. CronManager missing → 503 (`CronManager not available`). Unknown name → 404 (`Job '' not found`). | | `cron/jobs//resume` (**POST**) | Resume a paused job by name. Same sample-data `mode_blocked` gate, 503, and 404 as pause. | ### Telegram (`/api/v1/telegram`) | Endpoint | Purpose | |---|---| | `telegram` (**POST**) | Send a Telegram test message. Body requires `message`. Optional one-shot `bot_token` and `chat_id` are accepted together and are never persisted. Otherwise the route uses env/workspace bot config; disabled config → 400. Send failure → 502. Sample-data sends (JWT `mode` claim or `X-FlintTrade-Mode: explore`) return HTTP 403 with `code: "mode_blocked"`. The desk helper is `Telegram tests are blocked for Example. Switch to Practice or Live with Telegram configured to send a real test.` | ### Ditto (`/api/v1/ditto/*`) Source: `packages/core/core/src/flinttrade_core/operations_routes.py`. The operations blueprint mounts at `/api/v1`, so the Vite/dev-proxy form is `/ft-api/api/v1/ditto/…` and a direct backend call is `http://:5100/api/v1/ditto/…`. Native copy trading is unavailable pending its native safety design. Linking returns 501; the default factory creates no copy runtime. Runtime operations return 503 after their authentication, body and mode checks. These routes do not enable a broker bridge or Live mirroring. | Endpoint | Contract | |---|---| | `ditto/accounts` (**GET**) | Read non-secret native copy-account references, including both `adapter_id` and `account_id`. | | `ditto/accounts` (**POST**) | Account linking unavailable (501). | | `ditto/accounts///enable` · `…/disable` (**POST**) | Session-authenticated account metadata only. Select the exact native broker/account pair. Disable drains any participating runtime generation before changing metadata; a failed drain returns 503. These routes do not start copying or place orders. | | `ditto/accounts//` (**DELETE**) | Requires a session; native account HTTP mutations remain unavailable (503) by default. | | `ditto/mirror/status` (**GET**) | Runtime unavailable (503). | | `ditto/mirror/start` (**POST**) | Requires a complete body, Live session and PIN; runtime unavailable (503). Example is refused (403). | | `ditto/mirror/stop` (**POST**) | Requires a session; runtime unavailable (503). | | `ditto/risk` (**GET**) | Runtime unavailable (503). | | `ditto/kill-all` (**POST**) | Requires a Live session; runtime unavailable (503). Example is refused (403). | Legacy account-only metadata routes accept only an unambiguous account ID. If two native brokers share that ID, they return HTTP 400 `account_selector_required` and change neither account. An explicit adapter never falls back to an account at another broker; malformed selectors return HTTP 400 `account_selector_invalid`. ### Auth (`/ft-api/v1/auth/*`) JWT-based. Source: `packages/core/core/src/flinttrade_core/auth_routes.py`. | Endpoint | Purpose | |---|---| | `GET auth/status` | First-run probe. Returns `is_setup`, `is_locked`, `has_pin`, and `totp_enabled`. `data.migration_blocked` is `two_operators` when more than one operator account is present, and null otherwise. | | `GET /api/v1/operator/status` | Authenticated operator status document. Session required (or an API key, which does not select an operator). `GET auth/status` stays the public first-run probe. The field list is under Monitoring. | | `POST auth/setup` | First-run enrolment (Create operator). Body `{ "username", "email", "password", "pin"? }`. The server generates TOTP and returns `totp_uri`, backup codes, and a setup-session JWT (`setup_session`) with `mode` `practice`. That token is a Practice session. It is what `POST auth/setup/vault` accepts. Authenticator enrolment is optional for example data and Practice; Live still needs a confirmed authenticator plus PIN. It does not accept a caller-supplied TOTP secret. A second create, including one that overlaps the first, raises `Account already set up` in the account service. The route answers HTTP 409 with `code: "operator_exists"` and message `Request conflicts with the current state`. The setup screen maps that code to **This machine already has an operator. Sign in to finish setup.** A 409 without `operator_exists` keeps the generic message. | | `POST auth/setup/resume` | Public. A reload mid-setup drops the setup-session JWT that lived only in the browser tab, so this proves the password and mints a setup-session JWT again. It mints a Practice setup session, the same as `POST /v1/auth/setup` (`setup_session` true, Live not unlocked). Body `{ "password", "totp_code"? }`. Once an authenticator is enrolled, that code is required as well. No operator yet is HTTP 409 `Create an operator before continuing setup.` A finished setup is HTTP 409 `Setup is already complete. Sign in.` A wrong password is HTTP 401 `Invalid credentials.` It does not require a session that is already in the browser. | | `POST auth/setup/vault` | Open the credential vault during first-run Setup. Requires the account-create setup-session JWT. Daily-login tokens are rejected. Body `{ "master_password" }` (at least 8 characters when the vault file is missing). Persists the secret when it is missing and leaves an existing secret untouched. Success is `{ "opened": true, "already_present": bool }` under `data`. The response never returns the secret. | | `POST auth/setup/complete` | Session-bound. Records that first-run setup has finished. Requires the operator's session JWT; the setup-session JWT qualifies, and an API key does not. The operator must already exist and the vault must be open. Success is `{ "setup_finished": true }` under `data`. A missing session is HTTP 401 `Sign in to continue setup.` | | `POST auth/setup/reset` | Wipe local enrolment so Setup can run again. Before authenticator enrolment, the account-create setup JWT can start over with an empty body, and a session plus the password can wipe the account. Once an authenticator is enrolled, recovery requires an active session, the password, and the current authenticator code (`totp_code`). An API key is not a session. A signed-out request on a finished account changes nothing. When an authenticator is enrolled the response is HTTP 403 `Sign in to reset this account. You'll need your password and authenticator code.` Otherwise it is HTTP 401 `Sign in to reset this account. You'll need your password.` The body includes `authenticator_enrolled`. A successful wipe bumps the account epoch, so other session tokens stop working. | | `POST auth/setup/regenerate-2fa` | Rotate the login TOTP secret and clear `totp_enabled` until a live code is confirmed again. Before enrolment, a session and the password are enough. Once an authenticator is enrolled, the current authenticator code is required as well. A signed-out request on a finished account returns the same sign-in message as reset and changes nothing. | | `POST auth/login` | Sign in with password (argon2id-hashed). `totp_code` (or a backup code) is required only after authenticator enrolment (`totp_enabled`). Issues a Practice JWT (`mode` `practice`). A fresh login opens Practice. When `migration_blocked` is `two_operators`, this returns HTTP 409 with message `FlintTrade couldn't finish updating.` and does not issue a token. | | `POST auth/totp/enable` | Confirm optional authenticator enrolment. Session-bound. Body `{ "totp_code" }`. Sets `totp_enabled`; later logins then require a TOTP or backup code. | | `POST auth/pin` | Quick Unlock with the 6-digit PIN. Requires an existing session JWT. Body `{ "pin" }`. Reopens the Mode already on that session and never changes it. Practice stays Practice. An Example (sample-data) session stays that session. A session that is already Live stays Live and keeps the authenticator enrolment check (403 `totp_required` until enrolled). Connected (read) is a broker status, not a session Mode. A successful unlock revokes the presented session and returns a new token. The previous token stops working. There is no `/auth/me`. | | `POST auth/live` | Explicit Live switch, and the only route that enters Live. Requires an existing session JWT. Body `{ "pin" }`. Requires authenticator enrolment and mints a Live JWT with `live_mode_unlocked=true`, after revoking the presented session. Refuses 403 `totp_required` until the authenticator is enabled. | | `POST auth/pin/set` | Set or change the PIN (password re-confirm). Requires an existing session JWT. Does not change Mode. | | `POST auth/mode` | **Downgrade only** to `practice`. Requires an existing session JWT. A body of `{ "mode": "practice" }` issues a fresh JWT and revokes the old `jti`. Any other value, including `explore`, `live`, and a missing `mode`, returns HTTP 400 before the current session is revoked. The body is `{ "status": "error", "message": "Only a downgrade to practice is allowed here. Switch to Live via POST /v1/auth/live with PIN verification." }` with no `code`. Live is entered only through `POST /v1/auth/live`. | | `POST auth/logout` | Revoke the current JWT by `jti`. Requires an existing session JWT. | | `POST auth/forgot-password` | JWT-token email reset. Body `{ "email" }`. Reads Flask-Mail `MAIL` from the Flask app config. A normal backend start never assigns `MAIL` (only tests inject it), so this returns 503 (`Email service not configured.`) on a stock process. SMTP/SES env vars do not enable this pair. Missing email → 400. When `MAIL` is injected and `email` is present, always returns 200 (`If the email is registered, a reset link has been sent.`) so the address is not enumerated. Rate-limited to 3 requests per hour per client. | | `POST auth/reset-password` | Consume a reset JWT from `forgot-password`. Body `{ "token", "new_password" }`. The token lasts 1 hour. Password minimum 8 characters. Missing fields or an invalid / expired token → 400. Rate-limited to 5 requests per minute per client. A stock backend never issues these tokens because `forgot-password` stays 503. | | `POST auth/forgot-password-otp` | Welcome **Forgot your password?** path. Body `{ "email" }`. Sends a 6-digit OTP via `EmailTransport` (Amazon SES is tried first when `AWS_SES_REGION` or `AWS_DEFAULT_REGION` is set, then SMTP). Missing email → 400. When `email` is present, always returns 200 (`If the email is registered, a reset OTP has been sent.`) unless the per-email cap is hit (3 OTP requests per hour → 429). Also limited to 5 requests per minute per client. | | `POST auth/reset-password-otp` | Welcome path. Body `{ "email", "otp", "new_password" }`. The OTP is 6 digits with a 10-minute TTL. Password minimum 8 characters. Missing fields or an invalid / expired OTP → 400. Rate-limited to 10 requests per minute per client. | Welcome's **Forgot your password?** uses the OTP pair (`forgot-password-otp` / `reset-password-otp`). The JWT-token pair (`forgot-password` / `reset-password`) is present in the handler but is not a working operator path until something injects Flask-Mail `MAIL`; SMTP/SES configure `EmailTransport` only. ### Execution sessions (`/api/v1/execution-sessions`) Loopback only, with an origin check. Responses use `Cache-Control: no-store`. GET needs `admin.accounts.read`. Other methods need `admin.accounts.write`. | Route | What it does | |---|---| | `GET /api/v1/execution-sessions` | List runs for the caller. | | `POST /api/v1/execution-sessions` | Body is exactly `run_id`, `kind`, and `config`. Creates account `paper:run:` with environment `paper` and returns HTTP 201. A Testing run uses that account id. It is an API identifier, not a Mode name. Any other set of fields is HTTP 400 `exact_session_fields_required`. | | `GET /api/v1/execution-sessions/` | Read that run. | | `POST /api/v1/execution-sessions//start`, `…/rearm`, `…/close` | Body is exactly `account`, `environment`, and `expected_generation`. HTTP 202. | | `POST /api/v1/execution-sessions//stop` | Same body. HTTP 200. | An unknown operation is HTTP 404 `unknown_session_operation`. A control body with any other fields is HTTP 400 `exact_session_target_required`. A refused scope or generation is HTTP 409 `execution_scope_or_generation_refused`. If the owner cannot be read, the response is HTTP 503 `execution_owner_unavailable` with message `Read the exact run; never replay an uncertain command.` ### Protection grants (`/api/v1/protection`) Loopback operator session, with both `admin.accounts.read` and `admin.accounts.write`. Responses use `Cache-Control: no-store`. | Route | What it does | |---|---| | `POST /api/v1/protection/grants` | Body is exactly `run_id`, `expected_revision`, and `policy`. HTTP 201. | | `GET /api/v1/protection/grants/` | Read that grant. | | `POST /api/v1/protection/grants//revoke` | Body is exactly `expected_revision`. HTTP 200. | An invalid body is HTTP 400 `protection_request_invalid`. An operator error is HTTP 403. Custody that cannot be read is HTTP 503 `protection_custody_unavailable`. ### Diagnostics, incidents, and datasets | Route | What it does | |---|---| | `GET /api/v1/system/diagnostics` | Scope `admin.health.read`. Body is `status: success` and `data` from the diagnostics snapshot. `Cache-Control: no-store`. | | `GET /api/v1/system/incidents` | Scope `admin.health.read`. Query `limit` (default 50) and optional `before`. | | `GET /api/v1/system/incidents/deliveries/` | Scope `admin.health.read`. | | `GET /api/v1/system/incidents/channels/` | Scope `admin.services.read`. | | `PUT /api/v1/system/incidents/channels/` | Scope `admin.services.write`. Body has boolean `enabled`. When enabled, it also has `recipient` and `provider`. | | `POST /api/v1/system/incidents/deliveries//` | Scope `admin.observability.run`. `claim`, `receipt` with body exactly `claim` and `state`, or another delivery action. | | `GET /api/v1/data/datasets` | Signed-in session. `Cache-Control: no-store`. HTTP 401 `operator_session_required`, HTTP 403 `data_scope_unavailable`, HTTP 503 `history_store_unavailable`. | An incident conflict is HTTP 409. Incident storage that cannot be read is HTTP 503 `incident_storage_unavailable`. A bad incident body is HTTP 400 `invalid_incident_request`. An authority change uses `error: incident_authority_changed`. ### Knowledge and learning (`/v1/knowledge`, `/v1/learning`) Loopback operator session. Responses use `Cache-Control: no-store`. From outside the process these are `/ft-api/v1/knowledge` and `/ft-api/v1/learning`. | Route | What it does | |---|---| | `GET /v1/knowledge/skills` and `GET /v1/knowledge/skills/` | Catalogue, or one skill. | | `GET /v1/knowledge/versions/` | One version. | | `POST /v1/knowledge/versions//retire` | Body is exactly `reason`. | | `POST /v1/learning/permissions/preview` | Body is exactly `scope`, `recipient`, `purpose`, `operations`, `valid_from`, `valid_until`, `limits`, `max_rows`, `max_trials`, `retention`, `derivatives`, `objective`, `rights`, and `supersedes`. Returns a confirmation token. `expires_in_seconds` is 300. | | `POST /v1/learning/permissions/grants` | The preview fields plus `confirmation`. The token is consumed once. | | `GET /v1/learning/permissions/grants/` | Read a grant. | | `POST /v1/learning/permissions/grants//revoke` | Empty body. | | `GET /v1/learning/permissions/grants//manifests/` | Manifest for that grant. | | `GET /v1/learning/permissions/receipts/` | Receipt. | ### Monitoring And Observability GET endpoints. Source: `packages/core/core/src/flinttrade_core/monitoring_routes.py`, `health_routes.py`, `infra_routes.py`, and the scoped audit/activity routes in `packages/core/data/src/flinttrade_data`. The terminal has two development proxy namespaces: - backend `/api/v1/*` routes are requested as `/ft-api/api/v1/*`; - backend `/v1/admin/*` routes are requested as `/ft-api/v1/admin/*`. | Endpoint | Purpose | |---|---| | `GET /api/v1/versions` | Authenticated (session JWT or API key), read-only version information. Responses use `Cache-Control: no-store`. | | `GET /api/v1/versions/ollama` | Authenticated (session JWT or API key), read-only. Returns `configured`, `reported`, and `status`. It does not start Ollama or run a model. Responses use `Cache-Control: no-store`. | | `/api/v1/health` | Aggregated backend health used by the Settings monitoring panel. | | `/api/v1/traffic/stats` | Request count, request rate, error rate, average latency, and top paths. | | `/api/v1/traffic/recent` | Recent request records for operator forensics. | | `/api/v1/latency/stats` | Per-broker order latency percentiles. Fed by the gated order dispatch, which records each order's round-trip latency. | | `/api/v1/latency/recent` | Recent latency records. | | `/api/v1/reconciliation/outcomes` | Unresolved broker-write outcomes, including the exact selector, business date, non-secret persisted intent, fresh-snapshot evidence and any retryable `PENDING_AUDIT` or `PENDING_ROUTER_CLEAR` decision. Requires an authenticated session with `admin.observability.read`; results and remaining-outcome counts are filtered through the current router's account ACL. | | `/api/v1/reconciliation/outcomes//resolve` (**POST**) | Record `confirmed_applied`, `confirmed_not_applied`, or basket-only `confirmed_partial` after broker verification. Requires an authenticated, PIN-unlocked Live JWT, session scope `admin.observability.run`, current-router selector ACL, exact `CONFIRM ::` confirmation, a newly adopted exact-selector reconciliation generation, and a durable hash-chained audit receipt. Snapshots are monotonic; same-time conflicts and malformed reports fail closed, and historical observations remain evidence. Applied placement IDs must be first observed after invocation and match every persisted material identity field; basket requests map applied IDs to `broker_order_item_indexes` and partition all remaining children in `not_applied_item_indexes`. Modify and cancel recovery require operation-specific evidence. A `PENDING_AUDIT` retry requires newer evidence, archives the prior revision and receives a new resolution ID; a `PENDING_ROUTER_CLEAR` retry resumes the committed decision without another broker read. Success and structured-error responses carry the exact attempt and canonical decision; the terminal runtime-validates identity, status and primitive types before updating state. Ambiguous and unsupported cases remain blocked; this route performs no broker write. | | `/health`, `/health/detail` | Process health. Not public: a session JWT or `FLINTTRADE_API_KEY` is required. `/health` returns `status` (`healthy`, `degraded`, or `unhealthy`) and `timestamp`. `/health/detail` includes per-check detail. | | `/healthz`, `/readyz` | Public process probes. The body is status only: `/healthz` is HTTP 200 `{"status": "ok"}`; `/readyz` is HTTP 200 `{"status": "ready"}` or HTTP 503 `{"status": "not_ready"}`. Signed-out probes use these, not `/health`. | | `GET /api/v1/ping` | Process liveness. The body includes Live-facing `laya`, sidecar `laya_practice`, and `laya_live_qualified`. | | `GET /api/v1/operator/status` | Authenticated, read-only status for the signed-in operator. `Cache-Control: no-store`. Query `exchange` selects the session (NSE when omitted). Optional `symbol` is passed to that same session decision. Connected (read) is a broker status, not a mode. The document reports `mode` and `live_unlocked` from the session, `connected_read` from broker read status, the decision model (`laya.status`, `laya.reason_code`, `laya.live_qualified`), the kill switch (`active`, `reason`), the session (`exchange`, `phase`, `order_entry`, `order_close`, `square_off`, `reason`), broker account status per selector, dispatch counts (`outcome_unknown`, `non_terminal`), strategy rows (`kind`, `id`, `status`, `reason`, `lifecycle`, `ownership_retained`), and `migration_blocked`. A section whose source cannot be read is `unknown` with reason `unavailable` or `unscoped`. The other sections are still returned. Unknown is never filled in as Ready, unlocked, connected, or an empty healthy count. Account rows and dispatch counts include only that operator's selectors. An API key with no session does not receive account or dispatch detail. The route does not place or cancel an order, latch or reset the kill switch, start or stop a strategy, write the order ledger, or refresh a broker token. | | `/v1/admin/system` | CPU, memory, disk, network, uptime, and process metrics for the Admin system panel. | | `/v1/audit/*` | Scoped audit trail (`admin.audit.read` where required). | | `/api/v1/admin/activity` | Operator activity feed. | | `/api/v1/audit/logs` | Audit-log read on the operations blueprint (not `/v1/operations/…`). | ### Errors (`/ft-api/v1/errors`, `/ft-api/v1/changelog`) | Endpoint | Purpose | |---|---| | `errors` | Front-end error reporting sink. The terminal posts unhandled errors here. | | `changelog` | Read the bundled changelog.md programmatically (used by the "What's new" widget). | ### Support diagnostics (`/ft-api/v1/support/*`) Source: `packages/core/core/src/flinttrade_core/support_routes.py`. The route is covered by the backend's global authentication and additionally requires the `admin.errors.read` session scope. Responses use `Cache-Control: no-store`. | Endpoint | Purpose | |---|---| | `support/diagnostics` (**GET**) | Return bounded app/runtime metadata plus at most 50 aggregated recent error groups. The DuckDB projection excludes raw bodies, messages, tracebacks, entry/user ids and account identifiers; concrete paths are reduced to registered route patterns or safe client-screen names. | There are roughly 20 FlintTrade-specific endpoint families across the 13 Python packages. The complete list of registered Flask blueprints appears in `packages/core/core/src/flinttrade_core/app.py` — search for `register_blueprint`. --- ## 4. Authentication ### JWT (FlintTrade backend) `require_auth` accepts a session JWT **or** an API key on every mounted route that is not on the public allowlist in `flinttrade_core.public_routes`. That allowlist is the only exemption, and it is checked before the handler runs, so a new route is protected until it is added there. `OPTIONS` is always public. `HEAD` follows `GET`. The SPA shell (`/` and `/`) is public only for a non-API path; unknown `/v1/`, `/api/`, and `/ft-api/` paths that fall through stay authenticated. Broker account-management writes still require the operator's session JWT after that check. A non-public route behaves as follows. | Credential | Result | |---|---| | `Authorization: Bearer` session JWT (`type` `session`) | The global check allows the request. Handlers may still require a mode, a Live unlock, or an operator scope. | | `X-FlintTrade-Token` session JWT (`type` `session`) | The global check allows the request. An API key in this header does not. Handlers may still require a mode, a Live unlock, or an operator scope. | | Missing, revoked, or non-matching credential | HTTP 401 `{"status": "error", "message": "Unauthorized"}`. When an API key is configured, a presented credential that fails is recorded as an auth failure. When no key is configured, a missing session is still HTTP 401 and is not recorded as a ban event. | The public allowlist, method and rule, is exactly: | Method | Rule | |---|---| | GET | `/healthz` | | GET | `/readyz` | | GET | `/api/v1/ping` | | GET | `/v1/auth/status` | | POST | `/v1/auth/login` | | POST | `/v1/auth/setup` | | POST | `/v1/auth/setup/resume` | | POST | `/v1/auth/setup/vault` | | POST | `/v1/auth/setup/reset` | | POST | `/v1/auth/setup/regenerate-2fa` | | POST | `/v1/auth/forgot-password` | | POST | `/v1/auth/reset-password` | | POST | `/v1/auth/forgot-password-otp` | | POST | `/v1/auth/reset-password-otp` | | POST | `/v1/errors` | | POST | `/api/v1/errors` | | GET | `/v1/changelog` | | GET | `/v1/docs/search` | | GET | `/v1/docs/document` | | GET | `/v1/docs/changelog` | | POST | `/v1/test-connection` | | POST | `/v1/webhook/` | | POST | `/v1/webhook//` | | POST | `/csp-report` | | GET | `/v1/auth/oauth/callback` | | GET | `/api/v1/native/oauth/callback` | | POST | `/api/v1/native/postbacks/` | `POST /v1/auth/setup/resume` is on that list. A reload mid-setup proves the password (and the authenticator code once one is enrolled) and receives a setup-session JWT. It does not require a session that is already in the browser. `POST /v1/auth/setup/complete` is not on the list: it needs the operator's session JWT. `POST /v1/auth/setup/vault` is on that list so the setup wizard can reach it, and the handler still requires the setup-session JWT and rejects a daily-login token. `POST /v1/auth/setup/reset` and `POST /v1/auth/setup/regenerate-2fa` stay reachable during first-run. Once an authenticator is enrolled, account recovery requires an active session, the password, and the current authenticator code (`totp_code`). A signed-out reset or authenticator change on a finished account returns `Sign in to reset this account. You'll need your password and authenticator code.` when an authenticator is enrolled, and `Sign in to reset this account. You'll need your password.` when it is not. The body includes `authenticator_enrolled`. A successful wipe bumps the account epoch. `POST /v1/auth/totp/enable`, `/pin`, `/pin/set`, `/mode`, and `/logout` are not on the list: they need an existing session JWT and return 401 without one. `POST /csp-report` is public because the browser cannot attach a session. The shared content-type gate accepts `application/csp-report` and `application/reports+json` (and any type whose name contains `json`). The handler reads a legacy `csp-report` object or a Reporting API list and answers HTTP 204. A non-empty body of any other type is HTTP 415 `Content-Type must be application/json` before the handler runs. ``` Authorization: Bearer ``` ### Token claims The JWT carries three claims you care about: | Claim | Meaning | |---|---| | `sub` | User identifier. | | `exp` | Expiry timestamp. **Every token expires at 8 AM IST the next day.** Refresh by signing in again. | | `mode` | One of `explore` (example data; not a menu Mode), `practice`, `live`. Server-enforced on every order path. Password login and account setup default to `practice`. | | `live_mode_unlocked` | `true` on a Live session issued by the Live switch `POST /v1/auth/live`. Required for live order paths. Quick Unlock (`POST /v1/auth/pin`) keeps it when that session is already Live, and leaves it false on every other session. Both calls revoke the presented `jti` and return a new token. | | `oid`, `epoch` | Operator id and account epoch. Reset and operator re-creation bump the epoch, which ends other sessions. | A `jti` (JWT ID) is included so the server can revoke individual tokens when the user logs out or switches mode. The revocation blocklist lives in `packages/core/core/src/flinttrade_core/auth_state.py`. ### Backend API Keys --- ## 5. Rate limits Native broker throttles are applied by `BrokerRouter` using each adapter’s capabilities and workspace overrides. Every Live write still needs a one-shot `SafetyContext` from `gate_order` or `gate_broker_write`. --- ## 6. Mode system `explore | practice | live` — server-side JWT-claim enforcement. These strings are the claim, not the visible labels. The Mode chip is taken from this claim when the session token has one. An opaque sample session has no claim to apply and stays **Example**. | Claim `mode` | Visible label | |---|---| | `explore` | **Example**. Sample data. Not a menu Mode. The signed-in name is **Guest**. | | `practice` | **Practice**. Simulated fills. | | `live` | **Live**. `live_mode_unlocked` is a separate claim, set only by `POST /v1/auth/live`. | A JWT whose `mode` is missing, or is not one of those three, shows **Unavailable** and never Example. **Connected (read)** is not a claim. A token that carries that string does not select a Mode. Idle lock, broker connectivity, and the decision model are not `mode`. Laya is the shipped name of the admission-readiness row. Operators see **Example**, not the claim `explore`. The guard lives at `packages/services/engine/src/flinttrade_engine/mode_guard.py`. Every order-path endpoint asks the guard whether the current JWT permits live orders; the guard returns one of three verdicts: | Verdict | Behaviour | |---|---| | `explore` | Example data. `POST /api/v1/orders/place` and the shared order dispatcher both reject the order with HTTP 403 and `code: "mode_blocked"`. The message on both is `Orders are not available for Example. Switch to Practice or Live to trade.` No broker is contacted. The Terminal Order Pad records a sample fill on the client for that click (`Example order placed`, id starting `SAMPLE-`). This HTTP refusal is what the route returns when a client calls it. | | `practice` | Admitted orders execute in the local sandbox. Practice never calls a broker adapter. | | `live` | Requires a PIN-unlocked Live session, an exact native account target, safety admission and `BrokerRouter`. Evidence-gated native availability still applies; choosing this mode does not activate a broker. | On the desk, choosing **Practice** while signed in as **Guest** opens Setup and does not call `POST /v1/auth/mode`. That route is the downgrade from a session that already has a full login token. `POST /v1/auth/mode` accepts **only** a downgrade to `practice`. That call issues a fresh JWT and revokes the previous `jti`. Any other value, including `explore`, `live`, and a missing `mode`, returns HTTP 400 before the current session is revoked, with message `Only a downgrade to practice is allowed here. Switch to Live via POST /v1/auth/live with PIN verification.` Live is entered only through `POST /v1/auth/live`, with the 6-digit PIN, after the authenticator is enrolled (`totp_enabled`). Without enrolment that call refuses 403 with `code: "totp_required"`. Quick Unlock (`POST /v1/auth/pin`) reopens the Mode already on the session. Its body is `{ "pin" }`. It keeps that Mode. Authoritative coverage: `packages/core/core/tests/test_order_routes.py` asserts Example-data rejection, Practice routing, and Live gate / fail-closed behaviour. Engine routes that bypass the core order proxy use `packages/services/engine/src/flinttrade_engine/mode_guard.py`. --- ## 7. Example request / response The five most-used endpoints, each shown twice: a `curl` command for bash/zsh, and an `Invoke-RestMethod` equivalent for Windows PowerShell (where `curl` is an alias for `Invoke-WebRequest`, `\` is not a line continuation, and environment variables are read as `$env:NAME`). ### 7.1 Exercise the practice order path This example is for a locally issued **Practice-mode** FlintTrade session JWT. ```bash curl -X POST http://127.0.0.1:5100/api/v1/orders/place \ -H "Authorization: Bearer $FLINTTRADE_PRACTICE_JWT" \ -H "Content-Type: application/json" \ -d '{ "symbol": "NIFTY", "exchange": "NSE", "action": "BUY", "quantity": 50, "price": 0, "product": "MIS", "order_type": "MARKET" }' ``` ```powershell $params = @{ Method = "Post" Uri = "http://127.0.0.1:5100/api/v1/orders/place" Headers = @{ Authorization = "Bearer $env:FLINTTRADE_PRACTICE_JWT" } ContentType = "application/json" Body = '{ "symbol": "NIFTY", "exchange": "NSE", "action": "BUY", "quantity": 50, "price": 0, "product": "MIS", "order_type": "MARKET" }' } Invoke-RestMethod @params ``` Sandbox response shape — the handler returns the sandbox dict as-is (`COMPLETE` / `PENDING`). A `REJECTED` sandbox result is HTTP 400 with `status: "error"`. There is no `status: "success"` envelope on this path. ```json { "order_id": "sandbox-...", "status": "COMPLETE", "message": "Practice order executed: BUY 50 NIFTY @ 0.00" } ``` ### 7.5 Compute Gamma Exposure (FlintTrade-side) ```bash curl -X POST http://127.0.0.1:5100/api/v1/gex \ -H "Authorization: Bearer $FLINTTRADE_JWT" \ -H "Content-Type: application/json" \ -d '{"symbol":"NIFTY","exchange":"NFO","expiry":"28MAY26"}' ``` ```powershell $params = @{ Method = "Post" Uri = "http://127.0.0.1:5100/api/v1/gex" Headers = @{ Authorization = "Bearer $env:FLINTTRADE_JWT" } ContentType = "application/json" Body = '{"symbol":"NIFTY","exchange":"NFO","expiry":"28MAY26"}' } Invoke-RestMethod @params ``` Successful responses include `status`, `symbol`, `exchange`, `expiry`, `spot`, `lot_size`, `is_sample_data`, and a `data` object with `underlying`, `spot_price`, `atm_strike`, `strikes`, `net_gex`, `total_call_gex`, `total_put_gex`, `dealer_zone`, and `gamma_flip_strike` (the last is `null` unless a true flip level was computed). Via the Vite proxy the same route is `POST /ft-api/api/v1/gex`. --- ## 8. Error responses Every endpoint returns one of two shapes. **Success:** ```json { "status": "success", "data": { … } } ``` **Error:** ```json { "status": "error", "message": "Human-readable explanation.", "code": "optional_code" } ``` Most handlers return only `status` + `message`. The core `/api/v1/orders/*` proxy rejects a sample-data session (`/orders/place`, modify, cancel, `cancel-all`, and the other verbs that share that mode gate) with HTTP 403 and `code: "mode_blocked"`. The message is `Orders are not available for Example. Switch to Practice or Live to trade.` That refusal runs before `Laya.admit`. Core operator place, after the mode guard, admits before SafetySystem and before the Practice sandbox. A client `source` field is ignored, so the request cannot present itself as chat or as automate. A refusal is HTTP 403 `laya_denied`. A quantity clamp is HTTP 409 `laya_clamp` and places neither size. It applies only when the requested quantity is greater than the allowed one. A decision with no proof, while the chip can stay Ready, is HTTP 409 `laya_unverified`. `http_status` is not part of the JSON body. A Live JWT without PIN unlock on that same proxy is still message-only: HTTP 403 with "Live mode not unlocked — verify PIN first". A `code` field is also emitted on `mode_guard`-decorated engine routes (brackets and other executor-direct paths), on `POST /api/v1/telegram` sample-data refusals, on `POST /api/v1/ditto/mirror/start` and `POST /api/v1/ditto/kill-all` sample-data refusals, and on `POST /api/v1/cron/jobs//pause` and `…/resume` sample-data refusals. Not every endpoint emits `code`: | Code or status | Meaning | |---|---| | `quantity_admission` | Place refused a lot multiple or a freeze breach before Laya — HTTP 400. For a lot multiple, `message` is `Quantity must be a multiple of the lot size (N).` The Order Pad shows that sentence once, inline. An order over the per-order freeze limit uses the same HTTP 400 and code `quantity_admission`. The message gives the limit, names the instrument, and asks for smaller orders. The order is not split. An unverified Practice lot is a separate HTTP 400 with the message `Not placed. The lot size for isn't verified.` and no `code` field. | | Mode header mismatch | `X-FlintTrade-Mode` is present and differs from the JWT `mode` claim — HTTP 403. Body is `{"status":"error","message":"X-FlintTrade-Mode does not match the authenticated mode"}` and has no `code` field. | | `mode_blocked` | A sample-data session (or another blocked session) tried a blocked action — HTTP 403. Covers the core `/api/v1/orders/*` proxy refusals, `mode_guard` order-capable engine routes, FlintTrade `POST /api/v1/telegram` when JWT `mode` or `X-FlintTrade-Mode` is `explore`, `POST /api/v1/ditto/mirror/start` and `POST /api/v1/ditto/kill-all` sample-data refusals, and `POST /api/v1/cron/jobs//pause` plus `…/resume` sample-data refusals (same header/claim gate). Example-data place stays on this code, with message `Orders are not available for Example. Switch to Practice or Live to trade.` | | `laya_denied` | Operator place was refused by `Laya.admit` before SafetySystem or the Practice sandbox — HTTP 403. Body: `status: "error"`, `code: "laya_denied"`, `message` and `reason` (the same server text). The desk shows that `reason` under the denial on a line named Laya decision, inside one alert (`role="alert"`), the only live region. That line is not its own status. Down is "Laya is Down. New orders are paused until it's Ready. You can still close positions." and omits `limits`. Unqualified Live, while Ready or Degraded, is "Laya isn't qualified for Live yet. Practice orders are available." An empty Live note is "Laya is uncertain. Live stays closed." Other denials include `limits.max_quantity`. There is no `applied_quantity`. | | `laya_unverified` | One decision carried no proof. The chip can stay Ready. This is not a chip reason — HTTP 409. Body: `status: "error"`, `code: "laya_unverified"`, `message` and `reason` both "Not placed. Laya's decision couldn't be verified. Try again." The decision log records `identity_absent` and does not store `proof`. There is no `limits` field. | | `laya_clamp` | Operator place was not placed — HTTP 409. The requested quantity is greater than the allowed quantity. Body: `status: "error"`, `code: "laya_clamp"`, `message` (`Not placed. Laya allows up to .`), `reason`, `limits.max_quantity`, and `applied_quantity`. An empty Practice note that reduces the quantity sets `reason` to "Laya is uncertain. Quantity stays inside the tighter limit." A ceiling clamp can leave `reason` empty. Nothing is placed until the desk sends that quantity through admit again. Place 1 on "Not placed. Laya allows up to 1." is admitted, because that request is already at the allowed quantity. An allow then continues into SafetySystem and gate_order. A request already at the allowed quantity is an allow, including when an uncertain note tightened the ceiling without shrinking the number. Live uncertain, including an empty note, is `laya_denied`, not this code. | | `exit_pending` | A second reduce-only exit on the same broker account while one of this desk's exits on that contract is still unfilled — HTTP 409. Practice uses this code on the Practice book. On Live it is the code when the broker order book can be read. The Live hold is for that broker account. `message` and `reason` are `Not placed. An exit for is already pending. Wait for it to fill, or cancel it and try again.` The Positions row shows **Exit pending**. The label is the symbol, or `this contract` when the symbol is empty. | | `exit_orders_unreadable` | On Live, a retained exit exists and the broker order book cannot be read — HTTP 409. `message` and `reason` are `Not placed. Broker orders for SYMBOL are unavailable. Reconcile them before another exit.` The label is the symbol, or `this contract` when the symbol is empty. | | `gtt_unsupported` | `"variety": "gtt"` (any case or separator spelling) on place, routed place, exit-all, or a bracket — HTTP 422. `message` is `Not placed. GTT orders aren't supported right now.` The refusal is before Laya, SafetySystem, and any broker call. | | `practice_unsupported` | Practice JWT hit an executor-direct route with no sandbox parity — HTTP 403. | | `live_locked` | A `mode_guard` Live path requires `live_mode_unlocked=true`, issued by the Live switch `POST /v1/auth/live`. | | HTTP 429, message `Rate limit exceeded` | FlintTrade `@rate_limit` on the order proxy. No `RATE_LIMIT_EXCEEDED` enum. | | Safety `message` | A safety layer rejected the order; the message names the layer. There is no `SAFETY_LAYER_BLOCK` code. Operator and automate place reach this only after `Laya.admit` allows the requested quantity. | Automate place admits before SafetySystem with the same verdict. A webhook place puts `code`, `reason`, `limits`, and (on a clamp) `applied_quantity` on the dispatcher result; the webhook HTTP receiver wraps a dispatcher error as HTTP 422 with that result under `data`. A strategy dispatch raises the server `message` and does not place the reduced quantity. Modify, cancel, and cancel-all are not admitted as place. Forever, basket, split, and conditional-trigger place do not submit. `POST /api/v1/orders/forever` returns HTTP 501 `Orders are placed through /api/v1/orders/place.` and does not call a broker. A Live bracket with exactly one stop-loss or one target does submit on `POST /api/v1/orders/bracket`. A GTT body on place, routed place, exit-all, or that bracket is HTTP 422 `gtt_unsupported` before that admission. Chat is not an admission source. Details of the place path are in [ORDER_SAFETY.md](ORDER_SAFETY.md). --- ## 9. Versioning The HTTP surface uses URL-segment versioning (`/api/v1/`, `/ft-api/v1/`). Breaking changes go to `/v2/` and the `/v1/` surface stays alive for at least one minor release. The WebSocket protocol carries a `version` field on the handshake; current value is `2`. See [releases/](releases/) for per-version change notes.