openapi: 3.1.0
info:
  title: MirroAPI
  version: 1.0.0
  summary: Trading connections, prop-firm workflows and Mira AI.
  description: |
    MirroAPI is a cloud trading API covering MetaTrader 4, MetaTrader 5, cTrader and TradeLocker through one
    REST surface, one bounded WebSocket state stream and signed webhooks. MetaTrader 4 and 5 accounts run on a hosted
    terminal of the matching version; cTrader and TradeLocker need no terminal.

    Read `/capabilities` for the running release and key limits. The quickstart and typed client are at https://mirroapi.com/docs/quickstart/.
    Historical orders, candle or tick history, depth of market, computed statistics and automatic cross-host failover are not served by this release.
    Native quote and margin reads require a compatible, connected MT4/MT5 terminal. Other platforms have different capabilities.

    This document is the v1 contract as the gateway serves it; the gateway lives in the MIRRO
    monorepo (`packages/api`) and `GET /v1/health` reports the version it runs. A path tagged
    `x-not-served: true` has no handler yet and answers `404 NOT_FOUND`; the reference at
    https://mirroapi.com/docs/ hides those paths by default and shows them at `/docs/?all=1`.
    The `x-phase` marks are history (B opened; C is the tagged set). Field shapes may still change
    before `1.0.0`.

    Conventions
    - Base URL `https://api.mirroapi.cloud/v1`. Stream `wss://api.mirroapi.cloud/v1/stream` (the same host; see `/stream`).
    - Authentication: `Authorization: Bearer <api_key>`. Keys carry scopes.
    - Money is a decimal string with `currency`. Never a float.
    - Times marked `timeBasis: utc` are RFC 3339 UTC. Unknown broker offsets remain explicitly labelled `timeBasis: broker`. A position's `openedAt` and an order's `placedAt` (and an order's `expiresAt`) come from the broker's clock and say how they were converted in `timeBasis` (`utc` or `broker`). On cTrader, TradeLocker and DXtrade the broker's own API timestamps are UTC, so `timeBasis` is always `utc` there; on MetaTrader it is `utc` once the terminal has reported its broker's UTC offset.
    - Most mutating requests accept `Idempotency-Key`; a replay returns the original response (`Idempotent-Replayed: true`). Same key with a different body → `422 IDEMPOTENCY_MISMATCH`. Password, 2FA, billing and margin (`POST …/margin`, a read) do not; do not automatically retry them.
    - Trade submissions support durable `Idempotency-Key` replay for 24 hours. Reuse the same key and body after a timeout. A replay returns the same command, potentially with its updated state.
    - Every HTTP response carries `X-Request-Id`. The console request log (`GET /requests`) is bounded and process-local; the Audit API contains domain events, not every HTTP response.
    - Every error is `{ "error": { "code", "message", "docs", "details"? } }`. Branch on `code`, never on
      `message`; `docs` is `https://mirroapi.com/docs/errors#CODE`, the code's entry in the catalogue.
    - Rate limits are per key with separate read and trade buckets, returned in `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` (Unix seconds) and `Retry-After`. Limits are per gateway process. Concurrent overload returns `503 API_BUSY` with `Retry-After`; do not change a trade’s idempotency key when retrying.
    - Trades are **asynchronous**: `POST …/trades` returns `202` with a `Command`; poll it, subscribe to the stream’s `commands` snapshots, or receive signed `command.resolved` webhooks. Acceptance is not a broker fill.
  contact:
    name: MirroAPI
    url: https://mirroapi.com
servers:
  - url: https://api.mirroapi.cloud/v1
security:
  - apiKey: []
tags:
  - name: Mira
    description: Session-only developer and firm building agent with durable reviews and AI budgets
  - name: Mira API
    description: Embed Mira with an isolated business workspace, prepaid credit and an ai-scoped API key
  - name: Keys
  - name: Platforms
  - name: Accounts
  - name: State
  - name: History
  - name: Market data
  - name: Trade
  - name: Copy
  - name: Stats
    x-not-served: true
  - name: Risk
  - name: Audit
  - name: Webhooks
  - name: Signals
  - name: cTrader
    description: |
      Connecting a cTrader account the trader already has — without MirroAPI ever learning its
      password. The trader signs in at cTrader itself and authorises this application; the gateway
      keeps only the authorisation, sealed under the tenant's key. Session-only: the authorisation
      is the person's, never an API key's.

      `POST /accounts` still refuses `ctrader` with `USE_OAUTH`, because it takes a login and a
      password and there is neither. `GET /platforms` names this door in `selfOnboard.via`.
  - name: Telegram
    description: |
      A Telegram channel as a signal source. The gateway reads the channel through the PERSON'S OWN
      Telegram (MTProto), because the channels people pay for do not accept bots, and turns each message
      into the very same alert lines a TradingView alert produces — there is no second execution path.
      A person's Telegram is a personal credential, so every route here needs a signed-in session and
      refuses an API key. Nothing is posted, joined or left; only the channels the person chose are read.
      Separate from the Telegram under Signals → notifications, which is a bot that messages the person.
  - name: Firm
    description: 'A proprietary trading firm as a MirroAPI customer: its traders trade under its ceilings; its staff run the control room. Selected by the x-mirro-tenant header.'
  - name: Demo accounts
    description: 'Demo accounts opened for any signed-in person at a catalogue broker, in their own name, through the broker''s own web terminal.'
  - name: Ops
    description: 'MirroAPI''s own operators: firm applications from the public page, and every firm''s package, mode, status and sender. Signed-in vendor staff only (users.role admin); never a firm, never an API key.'
  - name: Stream
  - name: Billing
  - name: Support
  - name: Health

paths:
  /mira/models:
    get:
      tags: [Mira API]
      operationId: miraModels
      summary: Read the configured Mira API models and screenshot support
      description: |
        Requires an active self-service business workspace or approved legacy workspace, and a dedicated API key with explicit ai scope.
        An admin-only key, signed-in session or HQ workspace cannot use this API. Test keys may call this route.
        Configuration does not prove credit, availability or coding performance. Also answers the standard model list shape
        (object list, data [{id: mira}, {id: mira-deep}]) so a Chat Completions client library lists Mira's models. /mira/responses does not stream;
        /mira/chat/completions does.
      responses:
        '200':
          description: Configured modes, provider model identifiers and capabilities
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: AI scope or workspace is not allowed }
        '503': { description: MIRA_NOT_ENABLED for a workspace without API access }
  /mira/responses:
    post:
      tags: [Mira API]
      operationId: createMiraResponse
      summary: Generate an answer or application tool requests
      description: |
        A live ai-scoped API key is required. Persist a UUID requestId before submitting. Reuse that UUID and the identical body
        after a lost response; generation runs once. Idempotency-Key is ignored. An expired request never starts again.
        Poll the response URL using its id or the original requestId. Responses are available for 24 hours; expired payloads
        are cleared on later tenant access. The request identity and usage record remain for accounting and duplicate prevention.
        Instructions and tools are inherited when previousResponseId is supplied. Keep the same mode and key. Return exactly
        one text result for every requested tool call. Tool definitions use an object JSON schema. The caller must validate
        arguments, authorize each action and execute its tools; MIRRO does not execute application code or grant workspace access.
        Images are optional raster screenshots. Read model vision support first. Private provider reasoning is never returned.
        Usage contains cost estimates, not a customer invoice. AI calls and polling do not enter trading API usage billing.
        Unknown outcomes retain their reservation; do not create a new request to recover an uncertain generation.
        Pilot capacity is two concurrent responses per workspace and one provider call per response, with a three-minute provider timeout.
        Context and transport limits still apply. Workspace daily and shared monthly AI allowances are enforced before generation.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MiraInput' }
      responses:
        '202':
          description: Saved and running; poll the response id
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraResponse' }
        '200':
          description: Previously completed request with the same identity and body
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraResponse' }
        '400': { description: Invalid input or incomplete tool results }
        '403': { description: Key, environment or workspace is not permitted }
        '409': { description: Request identity conflict, expired response or incompatible continuation }
        '503': { description: MIRA_BUSY or MIRA_NOT_ENABLED; retry the same request after resolving access or capacity }
  /mira/responses/{id}:
    get:
      tags: [Mira API]
      operationId: miraResponse
      summary: Recover a Mira response without generating again
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: Saved response id or original requestId }
      responses:
        '200':
          description: Current state and public output; private reasoning and conversation storage are excluded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraResponse' }
        '403': { description: AI key or workspace is not permitted }
        '404': { description: No response belonging to this key and user }
        '409': { description: Response expired; the original request will not run again }
        '503': { description: Mira API is unavailable }
  /mira/workspace:
    get:
      tags: [Mira API]
      operationId: readMiraWorkspace
      summary: Read the signed-in owner’s Mira workspace and credit summary
      description: Console session only. Uses the signed-in owner mapping; application AI keys cannot call this route. No caller-selected tenant or owner is accepted. The overview field reports saved setup, knowledge counts, current human-support counts and recorded model-call usage over the last seven days (including console tests and tool steps). These are operational counts, not accuracy scores, customer resolutions or proof that an integration can perform actions. Pending costs are excluded from recorded spend and remain reserved in the wallet.
      responses:
        '200': { description: Current owner-scoped workspace result }
        '400': { description: Invalid input }
        '403': { description: Console owner session or verified email required }
        '404': { description: Owned workspace or resource not found }
        '409': { description: Workspace state conflicts with this request }
        '503': { description: Workspace or payment service unavailable }
    post:
      tags: [Mira API]
      operationId: createMiraWorkspace
      summary: Create the signed-in owner’s Mira business workspace
      description: Console session only. Uses the signed-in owner mapping; application AI keys cannot call this route. No caller-selected tenant or owner is accepted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name], additionalProperties: false, properties: { name: { type: string, minLength: 2, maxLength: 100 }, website: { type: string, format: uri } } }
      responses:
        '200': { description: Current owner-scoped workspace result }
        '400': { description: Invalid input }
        '403': { description: Console owner session or verified email required }
        '404': { description: Owned workspace or resource not found }
        '409': { description: Workspace state conflicts with this request }
        '503': { description: Workspace or payment service unavailable }
  /mira/workspace/keys:
    post:
      tags: [Mira API]
      operationId: createMiraWorkspaceKey
      summary: Create a server-side AI key for the owned Mira workspace
      description: Console session only. Uses the signed-in owner mapping; application AI keys cannot call this route. No caller-selected tenant or owner is accepted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [name], additionalProperties: false, properties: { name: { type: string, minLength: 1, maxLength: 80 } } }
      responses:
        '200': { description: Current owner-scoped workspace result }
        '400': { description: Invalid input }
        '403': { description: Console owner session or verified email required }
        '404': { description: Owned workspace or resource not found }
        '409': { description: Workspace state conflicts with this request }
        '503': { description: Workspace or payment service unavailable }
  /mira/workspace/keys/{id}:
    delete:
      tags: [Mira API]
      operationId: revokeMiraWorkspaceKey
      summary: Revoke an owned Mira workspace AI key
      description: Console session only. Uses the signed-in owner mapping; application AI keys cannot call this route. No caller-selected tenant or owner is accepted.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Current owner-scoped workspace result }
        '400': { description: Invalid input }
        '403': { description: Console owner session or verified email required }
        '404': { description: Owned workspace or resource not found }
        '409': { description: Workspace state conflicts with this request }
        '503': { description: Workspace or payment service unavailable }
  /mira/workspace/topups:
    post:
      tags: [Mira API]
      operationId: createMiraWorkspaceTopup
      summary: Prepare a Mira credit checkout for the signed-in owner
      description: Console session only. Uses the signed-in owner mapping; application AI keys cannot call this route. No caller-selected tenant or owner is accepted.
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [amount, requestId, provider, returnUrl], additionalProperties: false, properties: { amount: { type: string, description: USD amount from 5.00 to 100.00 }, requestId: { type: string, format: uuid }, provider: { type: string, enum: [stripe, paystack] }, returnUrl: { type: string, format: uri } } }
      responses:
        '200': { description: Current owner-scoped workspace result }
        '400': { description: Invalid input }
        '403': { description: Console owner session or verified email required }
        '404': { description: Owned workspace or resource not found }
        '409': { description: Workspace state conflicts with this request }
        '503': { description: Workspace or payment service unavailable }
  /mira/workspace/topups/{reference}/confirm:
    post:
      tags: [Mira API]
      operationId: confirmMiraWorkspaceTopup
      summary: Check the payment provider for an owned Mira credit checkout
      description: Console session only. Uses the signed-in owner mapping; application AI keys cannot call this route. No caller-selected tenant or owner is accepted.
      parameters:
        - { name: reference, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Current owner-scoped workspace result }
        '400': { description: Invalid input }
        '403': { description: Console owner session or verified email required }
        '404': { description: Owned workspace or resource not found }
        '409': { description: Workspace state conflicts with this request }
        '503': { description: Workspace or payment service unavailable }
  /mira/audio/transcriptions:
    post:
      tags: [Mira API]
      operationId: transcribeMiraAudio
      summary: Transcribe a short PCM voice note
      description: Requires live AI access and the configured Mira speech service; usage remains metered under the workspace allowance.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, audio]
              additionalProperties: false
              properties:
                model: { type: string, enum: [mira-transcribe] }
                audio: { type: string, contentEncoding: base64, description: 16 kHz mono 16-bit PCM WAV with a canonical 44-byte header; up to 65 seconds including encoding tail }
      responses:
        '200':
          description: Transcribed text and audio usage
          content:
            application/json:
              schema: { type: object, properties: { text: { type: string }, model: { type: string }, usage: { type: object, additionalProperties: true } } }
        '400': { description: Invalid audio or request }
        '403': { description: Live AI key required }
        '429': { description: Workspace allowance reached }
        '503': { description: Speech service unavailable }
  /mira/search:
    post:
      tags: [Mira API]
      operationId: searchWeb
      summary: Search MIRRO's own public web index
      description: |
        Requires an unrestricted live AI-scoped key and Mira workspace access. Searches configured crawled public sources, not the entire web.
        Results carry crawl dates and excerpts; fetch a page to check current content. Customer queries and fetched pages do not enter the public index.
        Requires the Mira Search service to be enabled. Returns MIRA_BROWSING_UNAVAILABLE when it is not connected.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MiraWebSearchInput' }
      responses:
        '200':
          description: Ranked indexed sources and explicit corpus coverage
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraWebSearchResult' }
        '400': { description: Invalid query or filters }
        '403': { description: Live unrestricted AI key and workspace access required }
        '429': { description: Web request allowance or service capacity reached }
        '503': { description: Search service is not enabled or unavailable }
  /mira/fetch:
    post:
      tags: [Mira API]
      operationId: fetchWeb
      summary: Read a public HTTPS web page
      description: |
        Anonymous HTML, plain-text or Markdown reading with DNS-pinned public connections, robots checks and bounded redirects.
        Returns Markdown, line numbers, source dates, a content hash and truncation. Does not execute website JavaScript, bypass authentication or parse PDFs.
        Requires a live unrestricted AI-scoped key, workspace access and an enabled Mira Search service. The short fetch cache is private to the caller scope.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MiraWebFetchInput' }
      responses:
        '200':
          description: Observed public page text
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraWebPage' }
        '400': { description: Invalid or blocked URL, robots refusal, unsupported or unreadable page }
        '403': { description: Live unrestricted AI key and workspace access required }
        '429': { description: Web request allowance or service capacity reached }
        '503': { description: Page reader is not enabled or unavailable }
  /mira/chat/completions:
    post:
      tags: [Mira API]
      operationId: createMiraChatCompletion
      summary: Mira as your business's own agent, through the standard Chat Completions format
      description: |
        Point any Chat Completions client library at base URL https://api.mirroapi.cloud/v1/mira with a live ai-scoped key and model `mira` (or `mira-deep`).
        Access requires an active self-service business workspace with a verified owner, or explicitly approved legacy pilot access. Sign up at https://mirroapi.cloud/mira/start. Mira answers from your profile (PUT /mira/profile), your knowledge
        base (POST /mira/knowledge) and, for a firm on MIRRO, your published programs; she knows how prop firms work and runs her own
        read-only tools inside the gateway (knowledge search, exact prop-rule arithmetic identical to MIRRO's challenge evaluator, position
        size, payout split, calculator, time) for up to `mira.max_steps` steps. Your own function tools come back as standard `tool_calls`
        for your server to authorise and run; send the results as `tool` messages. `stream: true` answers text/event-stream with
        chat.completion.chunk frames and `data: [DONE]`; Mira's internal steps are SSE comments only, a failure after the first frame is an
        `error` data frame. stop, parallel_tool_calls: false, strict tools and tool_choice none are honoured; max_completion_tokens bounds the
        whole completion. Sampling fields (temperature, top_p, seed, penalties, metadata, store) are accepted and ignored; an explicit null is
        a field left out; n > 1, logprobs, audio, predicted outputs, web_search_options and the legacy functions fields are refused. Configured
        MIRRO web search and reading tools are controlled by mira.web (false opts out) and require mira.tools. Use a
        chat-completions client: /mira/responses is Mira's recoverable response API, not the Responses format. Images are base64 data
        URLs only (two per conversation, vision models). Each model call is reserved and settled under the workspace's daily and MIRRO's
        monthly allowance; `mira.cost_usd` is recorded model cost; self-service workspaces deduct it from their separate prepaid Mira credit. Unknown outcomes retain credit reservations. No message content is stored. Errors add the standard
        error.type and error.param; once a model call may have been paid a failure carries `x-should-retry: false`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MiraChatRequest' }
      responses:
        '200':
          description: A chat.completion, or (stream true) a text/event-stream of chat.completion.chunk frames ending in data [DONE]
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraChatCompletion' }
            text/event-stream:
              schema: { type: string, description: 'data: {chat.completion.chunk} frames, SSE comments, then data: [DONE]' }
        '400': { description: Invalid body; error.details.param names the field (context_length_exceeded in error.details.reason) }
        '401': { description: Unknown, revoked or expired key }
        '403': { description: Not an ai-scoped live key, or the workspace is not permitted }
        '404': { description: Unknown model }
        '429': { description: MIRA_ALLOWANCE (details.reason daily or monthly); see Retry-After }
        '503': { description: MIRA_BUSY, MIRA_UNAVAILABLE (details.usage none, recorded or unknown) or MIRA_NOT_ENABLED }
  /mira/models/{model}:
    get:
      tags: [Mira API]
      operationId: miraModel
      summary: Read one Mira model in the standard model shape
      parameters:
        - { name: model, in: path, required: true, schema: { type: string, enum: [mira, mira-deep] } }
      responses:
        '200':
          description: The model object
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraModel' }
        '403': { description: AI scope or workspace is not allowed }
        '404': { description: Unknown model, or mira-deep is not configured }
        '503': { description: MIRA_NOT_ENABLED }
  /mira/profile:
    get:
      tags: [Mira API]
      operationId: miraProfile
      summary: Read what Mira knows about your business
      responses:
        '200':
          description: The profile (null until set) and the industries Mira knows
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraProfileAnswer' }
        '403': { description: AI scope or workspace is not allowed }
        '503': { description: MIRA_NOT_ENABLED }
    put:
      tags: [Mira API]
      operationId: saveMiraProfile
      summary: Tell Mira who she works for
      description: Replaces the whole profile. A live key is required. Unknown fields are refused, never dropped.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MiraProfile' }
      responses:
        '200':
          description: The saved profile
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraProfileAnswer' }
        '400': { description: A field is invalid; the message names it }
        '403': { description: Not a live ai-scoped key, or the workspace is not permitted }
        '503': { description: MIRA_NOT_ENABLED }
    delete:
      tags: [Mira API]
      operationId: deleteMiraProfile
      summary: Remove the business profile
      responses:
        '200':
          description: Whether a profile was removed
          content:
            application/json:
              schema: { type: object, properties: { object: { type: string }, deleted: { type: boolean } } }
        '403': { description: Not a live ai-scoped key, or the workspace is not permitted }
        '503': { description: MIRA_NOT_ENABLED }
  /mira/knowledge:
    get:
      tags: [Mira API]
      operationId: miraKnowledge
      summary: List your knowledge documents
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 50 } }
        - { name: cursor, in: query, schema: { type: string }, description: nextCursor from the previous page }
      responses:
        '200':
          description: Newest first, with workspace totals
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  documents: { type: array, items: { $ref: '#/components/schemas/MiraKnowledgeDocument' } }
                  hasMore: { type: boolean }
                  nextCursor: { type: [string, 'null'] }
                  totals: { type: object, properties: { documents: { type: integer }, characters: { type: integer } } }
        '400': { description: Invalid limit or cursor }
        '403': { description: AI scope or workspace is not allowed }
        '503': { description: MIRA_NOT_ENABLED }
    post:
      tags: [Mira API]
      operationId: addMiraKnowledge
      summary: Teach Mira a document (rules, FAQ, policy, product page)
      description: |
        Plain text or Markdown up to 500,000 characters, split into sections by heading and indexed for retrieval. Re-posting the same
        externalId replaces that document (version + 1); identical content is a no-op (200, unchanged true). Text that looks like a
        credential is refused, because knowledge is quoted to customers. Up to 1,000 documents and 25 million characters per workspace.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MiraKnowledgeInput' }
      responses:
        '201':
          description: Added or replaced
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraKnowledgeDocument' }
        '200':
          description: Identical to the stored document; nothing changed
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MiraKnowledgeDocument' }
        '400': { description: Invalid document, too many sections, or a credential in the text }
        '403': { description: Not a live ai-scoped key, or the workspace is not permitted }
        '409': { description: The workspace knowledge limit is reached (details.reason) }
        '503': { description: MIRA_NOT_ENABLED }
  /mira/knowledge/website:
    post:
      tags: [Mira]
      operationId: learnMiraBusinessWebsite
      summary: Learn one public page from the business website
      description: |
        Requires a live AI-only key and an active Mira business workspace or approved legacy access. The URL must use the public host saved in the business
        profile (its bare domain and www alias are accepted), with no query parameters, credentials or action/login paths. Uses the isolated anonymous browser.
        Returns the saved document, observation date, truncation and discovered same-site links. Repeat to refresh;
        manual edits are protected from automatic replacement. This does not verify claims or schedule future crawls.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              additionalProperties: false
              properties:
                url: { type: string, format: uri, maxLength: 500 }
      responses:
        '200':
          description: Observed page saved or refreshed, plus discovered same-origin links
        '400':
          description: URL not allowed, page unavailable or no readable information
        '403':
          description: Live AI-only key required
        '409':
          description: A manual document is protected or the profile changed during the visit
        '503':
          description: Website learning is not connected
  /mira/business/learning:
    get:
      tags: [Mira API]
      operationId: getMiraBusinessLearning
      summary: Read saved website learning progress
      description: |
        Returns the saved page states, coverage and reviewed draft for this business. Progress is private to the workspace.
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
    post:
      tags: [Mira API]
      operationId: startMiraBusinessLearning
      summary: Start reading the company website
      description: |
        Requires a live AI key and a saved business website. Up to 24 observed public pages and three link levels. Dispatch one step at a time; closing the console pauses dispatch and retains progress. Does not inspect private accounts or verify business claims.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                url: { type: string, format: uri, maxLength: 500 }
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/business/learning/{id}/step:
    post:
      tags: [Mira API]
      operationId: stepMiraBusinessLearning
      summary: Step business learning
      description: |
        Read one leased page and save its text and observed same-site links. Expired leases can be resumed after interruption.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties: {}
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/business/learning/{id}/control:
    post:
      tags: [Mira API]
      operationId: controlMiraBusinessLearning
      summary: Control business learning
      description: |
        Pause, resume or retry unread pages. Pausing fences an unfinished read so it cannot write afterward.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [action]
              properties:
                action: { type: string, enum: [pause, resume, retry] }
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/business/learning/{id}/brief:
    post:
      tags: [Mira API]
      operationId: briefMiraBusinessLearning
      summary: Brief business learning
      description: |
        Metered model call producing an owner-review draft with source quotes. Unknown facts are omitted. No profile is changed. Uses normal prepaid credit and provider budgets.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties: {}
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/business/learning/{id}/apply:
    post:
      tags: [Mira API]
      operationId: applyMiraBusinessLearning
      summary: Apply business learning
      description: |
        Apply reviewed draft fields only if the profile and source versions are unchanged. Preserves the business name, rules and existing policy facts.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [briefId, description, products, audience, industry, email]
              properties:
                briefId: { type: string, format: uuid }
                description: { type: string, maxLength: 2000 }
                products: { type: string, maxLength: 4000 }
                audience: { type: string, maxLength: 500 }
                industry: { type: string, enum: [prop_firm, brokerage, trading_education, fintech, ecommerce, saas, services, other] }
                email: { type: string, maxLength: 254 }
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/business/connection:
    get:
      tags:
        - Mira API
      operationId: getMiraBusinessConnection
      summary: Read business capability settings
      description: Permissions and application-reported lookup timestamps. Switches do
        not establish a database connection.
      responses: &a1
        "200":
          description: Saved result returned
        "400":
          description: Invalid fields
        "401":
          description: Authentication required
        "403":
          description: Business access or capability refused
        "404":
          description: No matching record for this business and customer
        "409":
          description: State changed; read the saved result before retrying
    put:
      tags:
        - Mira API
      operationId: saveMiraBusinessConnection
      summary: Choose permitted business capabilities
      description: Use the current version. Cancellation requires orders. Disabling a
        capability blocks new execution claims, not an already-started action.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                version:
                  type: integer
                  minimum: 0
                capabilities:
                  type: object
                  additionalProperties: false
                  properties:
                    accounts:
                      type: boolean
                    orders:
                      type: boolean
                    cancellation:
                      type: boolean
                    support:
                      type: boolean
                  required:
                    - accounts
                    - orders
                    - cancellation
                    - support
              required:
                - version
                - capabilities
      responses: *a1
  /mira/business/checks:
    post:
      tags:
        - Mira API
      operationId: recordMiraBusinessLookup
      summary: Record a successful lookup
      description: Server-only integration telemetry, not a certification. Do not send
        customer records.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                capability:
                  type: string
                  enum:
                    - accounts
                    - orders
                    - support
              required:
                - capability
      responses: *a1
  /mira/business/actions:
    get:
      tags:
        - Mira API
      operationId: listMiraBusinessActions
      summary: Read recent action outcomes
      description: Returns up to 30 saved actions. Company-wide without customerId;
        customer-facing applications MUST bind customerId from their authenticated
        server session.
      parameters:
        - name: customerId
          in: query
          required: false
          schema: &a2
            type: string
            minLength: 1
            maxLength: 200
      responses: *a1
    post:
      tags:
        - Mira API
      operationId: prepareMiraBusinessAction
      summary: Prepare a customer review card
      description: Does not execute a business change. externalId is stable per
        operation. Customer identity comes from the authenticated application
        server, never the model. Confirmation expires after 24 hours.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                externalId:
                  type: string
                  minLength: 1
                  maxLength: 200
                customerId:
                  type: string
                  minLength: 1
                  maxLength: 200
                kind:
                  type: string
                  enum:
                    - cancel_order
                    - contact_support
                details:
                  oneOf:
                    - type: object
                      additionalProperties: false
                      properties:
                        orderId:
                          type: string
                          minLength: 1
                          maxLength: 200
                        reference:
                          type: string
                          minLength: 1
                          maxLength: 120
                        reason:
                          type: string
                          minLength: 1
                          maxLength: 500
                      required:
                        - orderId
                        - reference
                        - reason
                    - type: object
                      additionalProperties: false
                      properties:
                        subject:
                          type: string
                          minLength: 1
                          maxLength: 180
                        summary:
                          type: string
                          minLength: 1
                          maxLength: 6000
                      required:
                        - subject
                        - summary
              required:
                - externalId
                - customerId
                - kind
                - details
      responses: *a1
  /mira/business/actions/{id}:
    get:
      tags:
        - Mira API
      operationId: getMiraBusinessAction
      summary: Read a customer-bound action
      description: Returns the saved review and outcome, without execution
        credentials. A stale execution is uncertain and is never reclaimed
        automatically.
      parameters:
        - &a3
          name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - &a4
          name: customerId
          in: query
          required: true
          schema: *a2
      responses: *a1
  /mira/business/actions/{id}/claim:
    post:
      tags:
        - Mira API
      operationId: claimMiraBusinessAction
      summary: Claim one confirmed execution
      description: Server-only; call only after the authenticated customer confirms
        these exact details. NEVER expose as a model tool. claimed=true returns
        claimToken once. All later calls return claimed=false. No business
        mutation is performed by this endpoint.
      parameters:
        - *a3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                customerId:
                  type: string
                  minLength: 1
                  maxLength: 200
              required:
                - customerId
      responses: *a1
  /mira/business/actions/{id}/finish:
    post:
      tags:
        - Mira API
      operationId: finishMiraBusinessAction
      summary: Record the business outcome
      description: Use the one-time execution claim. Completed requires a business
        receipt reference. Lost/unknown outcomes must be uncertain, never failed.
        This endpoint does not execute the business operation.
      parameters:
        - *a3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                customerId:
                  type: string
                  minLength: 1
                  maxLength: 200
                claimToken:
                  type: string
                  format: uuid
                status:
                  type: string
                  enum:
                    - completed
                    - failed
                    - pending
                    - uncertain
                reference:
                  type: string
                  minLength: 1
                  maxLength: 200
                message:
                  type: string
                  minLength: 1
                  maxLength: 1000
              required:
                - customerId
                - claimToken
                - status
                - message
      responses: *a1
  /mira/business/actions/{id}/decline:
    post:
      tags:
        - Mira API
      operationId: declineMiraBusinessAction
      summary: Decline an unstarted action
      description: Bind the authenticated customer. An action that already started
        cannot be declined.
      parameters:
        - *a3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                customerId:
                  type: string
                  minLength: 1
                  maxLength: 200
              required:
                - customerId
      responses: *a1
  /mira/business/actions/{id}/resolve:
    post:
      tags:
        - Mira API
      operationId: resolveMiraBusinessAction
      summary: Reconcile a saved business receipt
      description: "Server-only read-only reconciliation: verify the result in your
        own system first. Only pending or uncertain executions can be resolved.
        This never replays the mutation."
      parameters:
        - *a3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                customerId:
                  type: string
                  minLength: 1
                  maxLength: 200
                status:
                  type: string
                  enum:
                    - completed
                    - failed
                reference:
                  type: string
                  minLength: 1
                  maxLength: 200
                message:
                  type: string
                  minLength: 1
                  maxLength: 1000
              required:
                - customerId
                - status
                - reference
                - message
      responses: *a1
  /mira/support/customer:
    get:
      tags:
        - Mira API
      operationId: listMiraCustomerSupport
      summary: Read a customer’s support replies
      description: Server-only business key. Bind customerId from your authenticated
        session. Returns up to 20 requests and latest replies; excludes staff
        notes. No email/SMS delivery.
      parameters:
        - *a4
      responses: *a1
  /mira/support/cases:
    post:
      tags: [Mira API]
      operationId: createMiraSupportCase
      summary: File a customer support request
      description: |
        Server-only business key. Your server must authenticate the customer and authorize consent to a handoff. externalId deduplicates writes; conflicting reuse fails. No email, SMS or external message is sent. Requests are private operational data, not shared knowledge.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [externalId, customerId, subject, summary]
              properties:
                externalId: { type: string, minLength: 1, maxLength: 200 }
                customerId: { type: string, minLength: 1, maxLength: 200 }
                subject: { type: string, minLength: 1, maxLength: 180 }
                summary: { type: string, minLength: 1, maxLength: 6000 }
                transcript: { type: array, maxItems: 20, items: { type: object, additionalProperties: false, required: [role, content], properties: { role: { type: string, enum: [user, assistant] }, content: { type: string, maxLength: 2000 } } } }
      responses:
        '200': { description: Existing request returned }
        '201': { description: Support request created }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/support/inbox:
    get:
      tags: [Mira API]
      operationId: listMiraSupportInbox
      summary: Read the private staff support inbox
      description: |
        Server-only staff operation; do not expose to customer browser code. Returns up to 30 requests and next cursor.
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [open, in_progress, resolved] } }
        - { name: before, in: query, schema: { type: string, format: uuid } }
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/support/cases/{id}:
    get:
      tags: [Mira API]
      operationId: getMiraSupportCase
      summary: Read a support request
      description: |
        For a customer-facing read your server MUST pass customerId from its authenticated session. The response excludes staff notes. Without customerId this is a staff-only detail read including notes. The API key has company-wide authority; keep it private.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: customerId, in: query, schema: { type: string, maxLength: 200 } }
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
    patch:
      tags: [Mira API]
      operationId: updateMiraSupportCase
      summary: Save a staff reply and status
      description: |
        Staff-only operation. Current version prevents overwriting another team member. Reply is saved for your application to display; no external delivery occurs.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [version, status, reply, note]
              properties:
                version: { type: integer, minimum: 1 }
                status: { type: string, enum: [open, in_progress, resolved] }
                reply: { type: string, maxLength: 6000 }
                note: { type: string, maxLength: 6000 }
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
    delete:
      tags: [Mira API]
      operationId: deleteMiraSupportCase
      summary: Delete a support request
      description: |
        Staff-only permanent removal of the support request, transcript, reply and internal note. Use to enforce your retention policy.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200': { description: Request completed }
        '400': { description: Invalid input }
        '403': { description: Business access is not permitted }
        '409': { description: State changed or request conflicts; refresh before retrying }
  /mira/knowledge/search:
    post:
      tags: [Mira API]
      operationId: searchMiraKnowledge
      summary: See exactly what Mira would retrieve for a question
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [query], additionalProperties: false, properties: { query: { type: string, maxLength: 500 }, limit: { type: integer, minimum: 1, maximum: 8, default: 5 } } }
      responses:
        '200':
          description: Your sections first (source business), then Mira's industry guides (source mira)
          content:
            application/json:
              schema:
                type: object
                properties:
                  object: { type: string, enum: [list] }
                  industry: { type: string }
                  data: { type: array, items: { $ref: '#/components/schemas/MiraKnowledgeHit' } }
        '400': { description: Invalid query or limit }
        '403': { description: AI scope or workspace is not allowed }
        '503': { description: MIRA_NOT_ENABLED }
  /mira/knowledge/{id}:
    get:
      tags: [Mira API]
      operationId: miraKnowledgeDocument
      summary: Read one knowledge document and its sections
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The document and its sections as Mira retrieves them
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/MiraKnowledgeDocument' }
                  - type: object
                    properties:
                      sectionsText: { type: array, items: { type: object, properties: { index: { type: integer }, heading: { type: [string, 'null'] }, text: { type: string } } } }
        '403': { description: AI scope or workspace is not allowed }
        '404': { description: No such document in this workspace }
        '503': { description: MIRA_NOT_ENABLED }
    delete:
      tags: [Mira API]
      operationId: deleteMiraKnowledge
      summary: Remove a knowledge document and its sections
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Removed
          content:
            application/json:
              schema: { type: object, properties: { id: { type: string }, object: { type: string }, deleted: { type: boolean } } }
        '403': { description: Not a live ai-scoped key, or the workspace is not permitted }
        '404': { description: No such document in this workspace }
        '503': { description: MIRA_NOT_ENABLED }
  /agent:
    get:
      tags: [Mira]
      operationId: agentWorkspace
      summary: Read agent availability, capabilities, saved tasks and spending limits
      description: |
        Capabilities report saved-code editing/search, static checks, optional isolated Node tests, website dependency installation/builds/browser previews and permitted dedicated-server diagnostics. Each model mode reports screenshot support separately.
        Website builds support static HTML and Vite front ends. Actual desktop/mobile screenshots and checks are saved with the exact source fingerprint. They do not certify backend services, external payments or a complete business journey.
        Requires a signed-in MIRRO API developer or active firm staff member. HQ and API keys are refused.
        Agent work preserves normal role and product restrictions. No arbitrary shell, SQL, payment or trade execution.
        AI limits are separate from trading credits. Unconfirmed provider usage keeps its reservation.
        Changes require the originating user and session, expire after 15 minutes and check the latest state before execution.
      responses:
        '200':
          description: Current task, review result or workspace information
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Role or session does not allow this operation }
        '409': { description: Budget reached, another task is running, or reviewed state changed }
        '501': { description: AI provider is not enabled on this server }
  /agent/company:
    get:
      tags: [Mira]
      operationId: agentCompanyCheck
      summary: Check the signed-in firm's saved company setup without an AI task
      description: |
        Active firm staff session only. Uses the same role and product restrictions as the workspace.
        Returns dated configuration checks and settings links, not customer records or credentials.
        A configured service is not proof of a live purchase, broker execution or company incorporation.
        This read starts no AI task and reserves no AI allowance. Unavailable sections remain explicit blockers.
      responses:
        '200':
          description: Company setup snapshot
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Requires an active firm staff session }
        '404': { description: Not available in MIRRO HQ }
  /agent/budget:
    put:
      tags: [Mira]
      operationId: agentBudget
      summary: Set the daily AI allowance up to USD 100 with no per-task cap
      description: |
        Requires a signed-in MIRRO API developer or active firm staff member. HQ and API keys are refused.
        Agent work preserves normal role and product restrictions. No arbitrary shell, SQL, payment or trade execution.
        AI limits are separate from trading credits. Unconfirmed provider usage keeps its reservation.
        Changes require the originating user and session, expire after 15 minutes and check the latest state before execution.
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "properties": {"dailyMicro": {"type": "integer", "minimum": 0, "maximum": 100000000}}, "required": ["dailyMicro"]}
      responses:
        '200':
          description: Current task, review result or workspace information
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Role or session does not allow this operation }
        '409': { description: Budget reached, another task is running, or reviewed state changed }
        '501': { description: AI provider is not enabled on this server }
  /agent/runs:
    post:
      tags: [Mira]
      operationId: agentRun
      summary: Start an AI task in the background and save its work
      description: |
        Tasks have no fixed step count or overall time cutoff. AI usage is limited only by the daily allowance (default USD 100).
        Each provider request is reserved separately on its UTC start date; uncertain requests retain only their own reservation.
        Multi-step builds can save a brief (outcome, requirements, constraints and questions) and a dated company_check.
        A continuation inherits its nearest saved brief; current user corrections take priority. Old setup checks are not inherited as current evidence.
        New websites may use Studio's complete starter packs; existing sites require focused edits.
        Linked code projects preserve latest file revisions (up to 24 files / 240 KB), with hash-bound replacements and exact text edits.
        code_check records syntax and static-import results for the saved files. When a separate execution worker is configured, code_check.execution records the offline Node test command result, bound to the exact file fingerprint.
        Editing or Undo invalidates prior results. Runtime receipts do not prove test coverage, dependency installation, browser behavior, deployment, live database changes or successful business setup.
        verification summarizes saved task evidence independently of the model answer. Dedicated diagnostics use permission-checked health reports; queued actions never imply completed operations.
        Known code errors trigger further repair when the saved files change; unchanged unresolved errors leave the task needing attention.
        Requires a signed-in MIRRO API developer or active firm staff member. HQ and API keys are refused.
        Agent work preserves normal role and product restrictions. No arbitrary shell, SQL, payment or trade execution.
        AI limits are separate from trading credits. Unconfirmed provider usage keeps its reservation.
        Changes require the originating user and session, expire after 15 minutes and check the latest state before execution.
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "properties": {"prompt": {"type": "string", "minLength": 1, "maxLength": 8000}, "requestId": {"type": "string", "format": "uuid"}, "parentId": {"type": "string", "format": "uuid"}, "mode": {"type": "string", "enum": ["balanced", "deep"]}, "execution": {"type": "string", "enum": ["review", "build"], "default": "review", "description": "Build allows website draft saves and DNS checks during this task only. Other changes still require review."}, "images": {"type": "array", "maxItems": 2, "items": {"type": "object", "required": ["name", "mime", "base64"], "properties": {"name": {"type": "string", "maxLength": 120}, "mime": {"type": "string", "enum": ["image/png", "image/jpeg", "image/webp"]}, "base64": {"type": "string", "maxLength": 1398104}}, "description": "Reference screenshot up to 1 MB and 2048px per side. Bytes are forwarded to the AI but not saved in task history or published."}}}, "required": ["prompt", "requestId"]}
      responses:
        '202':
          description: Task accepted and running in the background; poll GET /agent/runs/{id} for saved edits, progress and completion
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '200':
          description: Current task, review result or workspace information
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Role or session does not allow this operation }
        '409': { description: Budget reached, another task is running, or reviewed state changed }
        '501': { description: AI provider is not enabled on this server }
  /agent/runs/{id}:
    get:
      tags: [Mira]
      operationId: agentRunRead
      summary: Read a task owned by the signed-in user
      description: |
        Requires a signed-in MIRRO API developer or active firm staff member. HQ and API keys are refused.
        Agent work preserves normal role and product restrictions. No arbitrary shell, SQL, payment or trade execution.
        AI limits are separate from trading credits. Unconfirmed provider usage keeps its reservation.
        Changes require the originating user and session, expire after 15 minutes and check the latest state before execution.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Current task, review result or workspace information
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Role or session does not allow this operation }
        '409': { description: Budget reached, another task is running, or reviewed state changed }
        '501': { description: AI provider is not enabled on this server }
  /agent/runs/{id}/references/{referenceId}:
    get:
      tags: [Mira]
      operationId: agentReferenceRead
      summary: Read a saved public website observation
      description: "Signed-in task owner only. Returns saved source snapshots and provenance, not a new browser visit. Page content is untrusted reference material."
      parameters:
        - {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
        - {"name": "referenceId", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/runs/{id}/reference-assets:
    get:
      tags: [Mira]
      operationId: agentReferenceAssets
      summary: List imported reference assets for a task
      description: "Signed-in task owner only. Returns the assets referenced by this task with their observed provenance. Does not crawl or import new assets."
      parameters:
        - {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/runs/{id}/app-settings:
    get:
      tags: [Mira]
      operationId: agentAppSettings
      summary: Read managed application settings
      description: "Signed-in task owner only. Returns configuration and availability without credential values. Configuration does not certify a live provider connection."
      parameters:
        - {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/runs/{id}/app-settings/integrations/{name}:
    put:
      tags: [Mira]
      operationId: agentAppIntegration
      summary: Configure an application service connection
      description: "Task owner with current write permission only. Requires confirm=true. Provide remove=true to disconnect, or an HTTPS url, header (none, bearer or x-api-key), optional secret, method (GET, POST, PUT, PATCH or DELETE), allowed fields and optional userField. Credentials are encrypted and never returned to generated code. Writes still use managed authorization and idempotency."
      parameters:
        - {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
        - {"name": "name", "in": "path", "required": true, "schema": {"type": "string"}}
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "required": ["confirm"], "properties": {"confirm": {"type": "boolean", "const": true}, "remove": {"type": "boolean"}, "url": {"type": "string", "format": "uri"}, "header": {"type": "string", "enum": ["none", "bearer", "x-api-key"]}, "secret": {"type": "string", "writeOnly": true}, "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, "fields": {"type": "object", "additionalProperties": {"type": "string", "enum": ["string", "number", "boolean", "string?", "number?", "boolean?"]}}, "userField": {"type": "string"}}}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/runs/{id}/app-settings/oauth/{provider}:
    put:
      tags: [Mira]
      operationId: agentAppOAuth
      summary: Configure application social sign-in
      description: "Task owner with current write permission only. Requires confirm=true and provider clientId/secret, or remove=true. Provider apps and their callback URLs must be configured by the business. A saved configuration is not proof of completed live consent."
      parameters:
        - {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
        - {"name": "provider", "in": "path", "required": true, "schema": {"type": "string", "enum": ["google", "github"]}}
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "required": ["confirm"], "properties": {"confirm": {"type": "boolean", "const": true}, "remove": {"type": "boolean"}, "clientId": {"type": "string"}, "secret": {"type": "string", "writeOnly": true}}}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/runs/{id}/app-settings/restore:
    post:
      tags: [Mira]
      operationId: agentAppRecordsRestore
      summary: Restore managed application records
      description: "Task owner with current write permission only. Requires exact current expectedRevision and a retained revision. Restores records only; never restores sessions, SQL stores or provider settings."
      parameters:
        - {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "required": ["confirm", "revision", "expectedRevision"], "properties": {"confirm": {"type": "boolean", "const": true}, "revision": {"type": "string", "pattern": "^[0-9]{1,16}$"}, "expectedRevision": {"type": "string", "pattern": "^[0-9]{1,16}$"}}}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/runs/{id}/answer:
    post:
      tags: [Mira]
      operationId: agentRunAnswer
      summary: Answer the question a task is waiting for
      description: "Signed-in task owner only. Answers bind the current questionId and are accepted once. Provide either answers in displayed question order, a free-text reply, or dismissed=true. Each answer contains selected option labels and optional other text. Resumes the same saved task without changing its permissions or budget. Requires the matching question interface rollout."
      parameters:
        - {"name": "id", "in": "path", "required": true, "schema": {"type": "string", "format": "uuid"}}
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "required": ["questionId"], "properties": {"questionId": {"type": "string", "format": "uuid"}, "answers": {"type": "array", "items": {"type": "object", "properties": {"selected": {"type": "array", "items": {"type": "string"}}, "other": {"type": "string"}}}}, "reply": {"type": "string"}, "dismissed": {"type": "boolean", "const": true}}}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/site-api/{token}:
    post:
      tags: [Mira]
      operationId: agentPublishedApplication
      summary: Deliver a request to a published application
      description: "Internal hosting transport for a published app. The publication token identifies the app; private data still requires that app\u2019s own authenticated session. Accepts the bounded application request envelope and returns the application status/body envelope. Not a general outbound proxy. With host, the request is answered only while that hostname is this app's active custom domain."
      security: []
      parameters:
        - {"name": "token", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9_-]{32}$"}}
        - {"name": "host", "in": "query", "schema": {"type": "string", "maxLength": 253}, "description": "The custom hostname the websites Worker was asked for; refused with 404 unless it is this app's active domain"}
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "additionalProperties": true}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/oauth/launch:
    post:
      tags: [Mira]
      operationId: agentHostedOAuthLaunch
      summary: Handle hosted application OAuth launch
      description: "Internal hosted sign-in handoff. Uses expiring one-use OAuth state and the browser-bound ticket established by the trusted hosting flow. Do not call as a business AI tool or supply company API keys. Invalid or stale handoffs are refused."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "additionalProperties": true}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/oauth/callback:
    post:
      tags: [Mira]
      operationId: agentHostedOAuthCallback
      summary: Handle hosted application OAuth callback
      description: "Internal hosted sign-in handoff. Uses expiring one-use OAuth state and the browser-bound ticket established by the trusted hosting flow. Do not call as a business AI tool or supply company API keys. Invalid or stale handoffs are refused."
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "additionalProperties": true}
      responses:
        '200':
          description: Result for the requested operation
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Invalid request }
        '403': { description: Current caller is not authorized }
        '404': { description: Resource is not available to this caller }
        '409': { description: Saved state changed or this operation cannot currently proceed }
  /agent/runs/{id}/website/publish:
    post:
      tags: [Mira]
      operationId: agentWebsitePublish
      summary: Publish the reviewed successful website build
      description: |
        Signed-in task owner with current write permission only. Requires a completed task, the exact current source fingerprint and successful build/browser receipts. An older continued task cannot publish.
        Serves static front-end assets at an isolated MIRRO-hosted address. Does not provision a backend or replace the firm's Studio site or domain. Returns verified=false if the public response could not be checked; do not claim a verified live site in that case.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [buildId, fingerprint, confirm]
              properties:
                buildId: { type: string, format: uuid }
                fingerprint: { type: string, pattern: '^[a-f0-9]{64}$' }
                confirm: { type: boolean, const: true }
                indexable: { type: boolean, description: "Whether search engines may index the published site. Omitted keeps the site's current choice; a site that was never published, or was taken offline, is not indexable. Only the live site of an indexable publication drops the noindex header and gets a generated robots.txt and sitemap.xml; previews never do." }
      responses:
        '200':
          description: Published version, public URL and probe result
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Current task owner and write permission required }
        '409': { description: Source changed, build checks failed, task is running or a continuation exists }
        '501': { description: Website hosting is not configured }
  /agent/runs/{id}/website/unpublish:
    post:
      tags: [Mira]
      operationId: agentWebsiteUnpublish
      summary: Take the current published website offline
      description: Task owner and current write permission required. Checks the expected published build to avoid taking a newer publication offline. Saved files and previews remain available. Taking a site offline resets its search indexing to off; a connected custom domain stays connected and serves nothing until the site is published again.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [publishedBuildId, confirm]
              properties:
                publishedBuildId: { type: string, format: uuid }
                confirm: { type: boolean, const: true }
      responses:
        '200':
          description: The public website is offline
          content:
            application/json:
              schema: { type: object, properties: { unpublished: { type: boolean } } }
        '403': { description: Current task owner and write permission required }
        '409': { description: The published version changed; refresh before retrying }
  /agent/runs/{id}/website/reach:
    get:
      tags: [Mira]
      operationId: agentWebsiteReach
      summary: Read the published website's search indexing and custom domain
      description: Signed-in task owner only. Returns whether the site is published and indexable, whether the site uses Google or GitHub sign-in (which works only at its Mira address), whether MIRRO's Cloudflare custom-hostname service is configured and its CNAME target, the site's custom domain with the DNS records to add and its honest state (pending until ownership, DNS and HTTPS are all proven; active; failed; removed), and canManage (false for a firm member who is not an owner).
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: Indexing, provider status and the site's domain
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: An unrestricted signed-in session is required }
        '404': { description: Unknown task or website project }
  /agent/runs/{id}/website/indexing:
    put:
      tags: [Mira]
      operationId: agentWebsiteIndexing
      summary: Allow or stop search indexing of the published website
      description: Task owner with the publish permission. Applies to the live site at once. Previews and drafts are never indexable. Requires a published site; the choice is reset when the site is taken offline.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [indexable, confirm]
              properties:
                indexable: { type: boolean }
                confirm: { type: boolean, const: true }
      responses:
        '200':
          description: The site's reach after the change
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Missing choice or confirmation }
        '403': { description: The publish permission is required }
        '409': { description: The website is not published }
  /agent/runs/{id}/website/domain:
    post:
      tags: [Mira]
      operationId: agentWebsiteDomainAdd
      summary: Connect a custom hostname to the published website
      description: |
        A firm owner (or, without a firm, the signed-in workspace owner) only. The site must be published and must not use Google or GitHub sign-in. The hostname is normalized (lower case, punycode); IP addresses, local and reserved names, MIRRO, workers.dev and other hosting-platform hostnames are refused. One domain per site and up to 10 per owner. The domain starts pending with a TXT record that proves ownership and a CNAME to MIRRO's target; nothing is created at Cloudflare until ownership is proven, and the first owner to prove a hostname holds it. A hostname another account has proven may still be requested: the request holds nothing while that account's ownership value is still published, and takes the hostname over once only its own value is (whoever controls the DNS decides). The first check runs at once.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname, confirm]
              properties:
                hostname: { type: string, maxLength: 253, example: www.yourcompany.com }
                confirm: { type: boolean, const: true }
      responses:
        '200':
          description: The site's reach with the new pending domain and its DNS records
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '400': { description: Not a hostname that can be connected, or no confirmation }
        '403': { description: Only a firm owner can connect a domain }
        '409': { description: The site is offline, uses social sign-in, already has a domain, the owner has 10, or a firm website on MIRRO uses the hostname }
    delete:
      tags: [Mira]
      operationId: agentWebsiteDomainRemove
      summary: Remove the website's custom hostname
      description: A firm owner (or the workspace owner) only. Deletes the Cloudflare custom hostname first; if Cloudflare refuses, nothing changes. The website stays at its Mira address. The record is kept as removed.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The site's reach without a domain; removed names the hostname
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Only a firm owner can remove a domain }
        '404': { description: No connected domain }
        '409': { description: Cloudflare could not remove the hostname yet }
  /agent/runs/{id}/website/domain/check:
    post:
      tags: [Mira]
      operationId: agentWebsiteDomainCheck
      summary: Check the website's custom hostname now
      description: A firm owner (or the workspace owner) only. Looks up the ownership TXT and the CNAME and asks Cloudflare for the hostname and certificate status, at most every 30 seconds while pending and every 5 minutes while active (otherwise the stored state is returned). Active only when Cloudflare reports both the hostname and its certificate active. A pending request with no ownership record after 14 days fails. A proven request that is not serving fails an hour after DNS first answered without its ownership value, and a proven request (active or not) fails at once when another account's value is published without its own.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: The site's reach with the checked domain
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Only a firm owner can check a domain }
  /agent/site-assets/{token}:
    get:
      tags: [Mira]
      operationId: agentWebsiteAsset
      security: []
      summary: Read one built asset through a scoped website capability
      description: Hosting transport only. Unlisted preview links expire after 48 hours; live capabilities resolve only the explicitly published build. Returns encoded data, never executable HTML on the API origin. Do not put credentials in generated website files. Each file says whether the live site is indexable and names its active custom hostname (x-mira-indexable, x-mira-canonical-host).
      parameters:
        - { name: token, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{32}$' } }
        - { name: path, in: query, schema: { type: string, maxLength: 180, default: index.html } }
        - { name: host, in: query, schema: { type: string, maxLength: 253 }, description: "The custom hostname the websites Worker was asked for; refused with 404 unless it is this site's active domain" }
      responses:
        '200':
          description: One base64 asset, its content type and the build identifier
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '404': { description: Unknown, expired or unpublished capability, or missing page }
  /agent/site-hosts/{hostname}:
    get:
      tags: [Mira]
      operationId: agentWebsiteHost
      security: []
      summary: Which published website an active custom hostname serves
      description: Hosting transport for the websites Worker. Answers only for an active custom domain of a published site; pending, failed and removed hostnames, offline sites and unknown hosts are 404.
      parameters:
        - { name: hostname, in: path, required: true, schema: { type: string, maxLength: 253 } }
      responses:
        '200':
          description: The site's live capability and whether it is indexable
          content:
            application/json:
              schema:
                type: object
                properties:
                  hostname: { type: string }
                  token: { type: string, pattern: '^[A-Za-z0-9_-]{32}$' }
                  indexable: { type: boolean }
        '404': { description: Not an active custom hostname of a published site }
  /agent/site-index/{token}:
    get:
      tags: [Mira]
      operationId: agentWebsiteIndex
      security: []
      summary: The pages of a published, indexable website
      description: Hosting transport for the websites Worker's generated robots.txt and sitemap.xml. 404 for a preview, an offline site, a site its owner keeps out of search, or a host that is not the site's active domain.
      parameters:
        - { name: token, in: path, required: true, schema: { type: string, pattern: '^[A-Za-z0-9_-]{32}$' } }
        - { name: host, in: query, schema: { type: string, maxLength: 253 } }
      responses:
        '200':
          description: Publication time, active custom hostname and page paths under the site's root
          content:
            application/json:
              schema:
                type: object
                properties:
                  publishedAt: { type: string, format: date-time }
                  canonicalHost: { type: [string, 'null'] }
                  pages: { type: array, items: { type: string } }
        '404': { description: Not a published, indexable site }
  /agent/runs/{id}/cancel:
    post:
      tags: [Mira]
      operationId: agentRunCancel
      summary: Stop an active task without applying its proposals
      description: |
        Requires a signed-in MIRRO API developer or active firm staff member. HQ and API keys are refused.
        Agent work preserves normal role and product restrictions. No arbitrary shell, SQL, payment or trade execution.
        AI limits are separate from trading credits. Unconfirmed provider usage keeps its reservation.
        Changes require the originating user and session, expire after 15 minutes and check the latest state before execution.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "properties": {}, "required": []}
      responses:
        '200':
          description: Current task, review result or workspace information
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Role or session does not allow this operation }
        '409': { description: Budget reached, another task is running, or reviewed state changed }
        '501': { description: AI provider is not enabled on this server }
  /agent/undo/{id}:
    post:
      tags: [Mira]
      operationId: agentUndo
      summary: Undo a saved code edit or website draft without spending AI allowance
      description: |
        Requires the same actor and tenant plus current write permission. Undo records are returned by the task read.
        Stops the originating task if still running. Restores the exact prior file or draft, never the published website.
        Refuses to overwrite newer edits. Repeated requests return the existing result without undoing twice.
        Generated files in a continued task must be edited in the latest task; older task snapshots are preserved.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: false }
      responses:
        '200':
          description: Saved edit undone or already undone
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Current write permission required }
        '404': { description: No saved edit owned by this user in this tenant }
        '409': { description: A newer edit or continued task prevents undo }
  /agent/changes/{id}/review:
    post:
      tags: [Mira]
      operationId: agentChangeReview
      summary: Approve or dismiss the exact saved change
      description: |
        Requires a signed-in MIRRO API developer or active firm staff member. HQ and API keys are refused.
        Agent work preserves normal role and product restrictions. No arbitrary shell, SQL, payment or trade execution.
        AI limits are separate from trading credits. Unconfirmed provider usage keeps its reservation.
        Changes require the originating user and session, expire after 15 minutes and check the latest state before execution.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema: {"type": "object", "properties": {"approve": {"type": "boolean"}}, "required": ["approve"]}
      responses:
        '200':
          description: Current task, review result or workspace information
          content:
            application/json:
              schema: { type: object, additionalProperties: true }
        '403': { description: Role or session does not allow this operation }
        '409': { description: Budget reached, another task is running, or reviewed state changed }
        '501': { description: AI provider is not enabled on this server }
  /firm/tools/summary:
    get:
      tags: [Firm]
      summary: Review your firm's operations queue
      description: Unrestricted active firm staff with System tools enabled. Returns whole-firm counts for order delivery, phase handovers, broker reviews, quiet accounts, ownership issues and unusable pool stock, plus the 30 oldest delivery or reporting items. This is a diagnostic snapshot, not proof of broker or payment availability. No credentials are returned.
      responses: {"200": {"description": "Counts and bounded attention queue"}, "403": {"description": "Staff access or feature is unavailable"}}
  /firm/tools/lookup:
    get:
      tags: [Firm]
      summary: Find a customer, order or challenge account
      description: Searches only the authenticated firm's customers, order references, challenge accounts, stock logins and broker openings. Returns total matches and up to 50 records with kind, id, title, detail, status, timestamp and optional challengeId. No passwords, payment keys or raw provider payloads are returned.
      parameters: [{"in": "query", "name": "q", "required": true, "schema": {"type": "string", "minLength": 2, "maxLength": 120}}]
      responses: {"200": {"description": "Scoped search results"}, "400": {"description": "Search must contain 2 to 120 characters"}, "403": {"description": "Staff access or feature is unavailable"}}
  /firm/provisioning:
    get:
      tags: ["Firm"]
      summary: "Automatic account setup"
      description: "Lists current program settings, compatible broker catalogue and recent engine requests. enabled is the firm approval; runtimeEnabled reports whether this gateway permits creation. Existing request reads and authorized current-account credential recovery remain available when creation is paused. New openings exclude known OTP brokers. Brokers need verified account currency matching the program; imported unknown-currency types require operator verification."
      responses: {"200": {"description": "Success"}, "403": {"description": "Firm feature or role is not allowed"}, "409": {"description": "Setup, broker availability or hosting needs attention"}, "501": {"description": "Demo account creation is not enabled on this gateway"}}
  /firm/programs/{programId}/provisioning:
    put:
      tags: ["Firm"]
      summary: "Choose a program’s demo broker"
      description: "Owner or operations. Only offered broker leverages and countries are accepted. MIRRO controls the feature. A revision creates a new broker snapshot for future purchases; accepted purchases and existing challenges keep theirs. Funded-stage and continuation approvals are unchanged. Automatic openings use the assigned trader’s profile name; company contact details remain those saved for the broker setup."
      responses: {"200": {"description": "Success"}, "403": {"description": "Firm feature or role is not allowed"}, "409": {"description": "Setup, broker availability or hosting needs attention"}, "501": {"description": "Demo account creation is not enabled on this gateway"}}
      x-scope: "trade"
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["brokerId", "enabled", "revision", "leverage"], "properties": {"brokerId": {"type": "string", "format": "uuid"}, "enabled": {"type": "boolean"}, "revision": {"type": "integer", "minimum": 0}, "leverage": {"type": "integer", "minimum": 1, "maximum": 10000}, "country": {"type": "string", "maxLength": 64}}}}}}
      parameters: [{"in": "path", "name": "programId", "required": true, "schema": {"type": "string", "format": "uuid"}}]
  /firm/account-requests:
    post:
      tags: ["Firm"]
      summary: "Create and assign a challenge account"
      description: "MIRRO must enable demo_provisioning for the firm. Select the program broker in Automatic accounts first. Owner or operations role, session or unrestricted trade-scope API key. Your backend must verify payment before requesting an account. userId must be an active trader member in this firm: invite the trader, wait for acceptance, then read their ID from /firm/members. One stable reference per purchase prevents duplicate account creation. Broker opening and connection checks run asynchronously; ready means an empty, connected demo account with the correct balance and currency was assigned and its challenge started. Unknown broker outcomes require review. API-key calls use the standard API usage meter and rate limits; hosted accounts need active coverage. Internal worker steps do not add API request charges."
      responses: {"200": {"description": "Current request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmAccountRequest"}}}}, "403": {"description": "Firm feature or role is not allowed"}, "409": {"description": "Setup, broker availability or hosting needs attention"}, "501": {"description": "Demo account creation is not enabled on this gateway"}, "202": {"description": "Queued for broker creation and connection checks", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmAccountRequest"}}}}}
      x-scope: "trade"
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "additionalProperties": false, "required": ["programId", "userId", "reference"], "properties": {"programId": {"type": "string", "format": "uuid"}, "userId": {"type": "string", "format": "uuid"}, "reference": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,79}$"}}}}}}
  /firm/account-requests/by-reference:
    get:
      tags: [Firm]
      summary: Recover an account request by your purchase reference
      description: Active owner, operations or risk staff with an unrestricted key. Returns only a request belonging to this firm, without passwords. A read never creates, retries or cancels an account. Existing requests remain readable when new creation or hosting admission is paused. A 404 means no committed request was found at that moment; retry creation only with the same saved userId, programId and reference. Normal API read metering applies.
      parameters: [{in: query, name: reference, required: true, schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9_.:/-]{0,79}$'}}]
      responses:
        '200': {description: Current delivery state, content: {application/json: {schema: {$ref: '#/components/schemas/FirmAccountRequest'}}}}
        '400': {description: Invalid or missing purchase reference}
        '403': {description: Firm staff or unrestricted key required}
        '404': {description: No matching request in this firm}
  /firm/account-requests/{id}:
    get:
      tags: ["Firm"]
      summary: "Check automatic account delivery"
      description: "Returns id, reference, programId, userId, state, broker, platform, server, login, accountId, challengeId, message and createdAt. State is queued, opening, connecting, ready, needs_review or cancelled. Account and challenge IDs are returned when ready. Never contains passwords. Poll with backoff; keep the original request reference."
      responses: {"200": {"description": "Current request", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmAccountRequest"}}}}, "403": {"description": "Firm feature or role is not allowed"}, "409": {"description": "Setup, broker availability or hosting needs attention"}, "501": {"description": "Demo account creation is not enabled on this gateway"}}
      parameters: [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}]
  /firm/account-requests/{id}/retry:
    post:
      tags: ["Firm"]
      summary: "Recheck an existing account’s delivery"
      description: "Owner or operations. Only retries connection checks for a created account that has not yet been delivered. Does not create another broker account. Resolve an uncertain broker opening in Account creation & recovery instead."
      responses: {"200": {"description": "Success"}, "403": {"description": "Firm feature or role is not allowed"}, "409": {"description": "Setup, broker availability or hosting needs attention"}, "501": {"description": "Demo account creation is not enabled on this gateway"}}
      x-scope: "trade"
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object"}}}}
      parameters: [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}]
  /firm/account-requests/{id}/credentials:
    post:
      tags: ["Firm"]
      summary: "Read the assigned account’s credentials"
      description: "Owner or operations with an unrestricted trade-scope key or session. Audited; only a ready challenge’s current active account can be read. Returns accountId, platform, server, login, password and optional investorPassword. Call from your backend, then show only to the authenticated assigned trader. Do not log the response or cache it publicly."
      responses: {"200": {"description": "Success"}, "403": {"description": "Firm feature or role is not allowed"}, "409": {"description": "Setup, broker availability or hosting needs attention"}, "501": {"description": "Demo account creation is not enabled on this gateway"}}
      parameters: [{"in": "path", "name": "id", "required": true, "schema": {"type": "string", "format": "uuid"}}]
      x-scope: "trade"
  /firm/domains:
    get:
      tags: [Firm]
      summary: Read website domain and verification progress
      description: Unrestricted firm staff session with website and domain features
      responses:
        '200':
          description: Domain request, required DNS records and provider readiness
    put:
      tags: [Firm]
      summary: Request a custom website domain
      description: Owner session required; saves a request only, never proves DNS or publication
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hostname]
              properties:
                hostname:
                  type: string
                  example: www.yourfirm.com
      responses:
        '200':
          description: Saved hostname and ownership TXT record
    delete:
      tags: [Firm]
      summary: Disconnect a website domain
      description: Removes the provider hostname and routing after an owner request; keeps website content and trader accounts
      responses:
        '200':
          description: Domain disconnected
  /firm/domains/check:
    post:
      tags: [Firm]
      summary: Check domain ownership, DNS and HTTPS
      description: Owner session required; checks are rate limited per saved domain and also run in the background
      responses:
        '200':
          description: Latest observed progress, never a propagation guarantee
  /firm/domains/resolve/{hostname}:
    get:
      tags: [Firm]
      security: []
      summary: Resolve a verified custom hostname
      parameters:
        - in: path
          name: hostname
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Public firm slug and home (store or workspace) for a recently verified active custom hostname
        '404':
          description: Hostname is not connected to an active firm with domain and website or trader dashboard enabled

  /firm/payments:
    get:
      tags: [Firm]
      summary: Read your company payment setup
      description: Active firm owner session only — returns safe configuration, setup URLs and readiness checks, never a merchant key
      responses:
        '200': {description: Firm-owned Paystack setup and launch requirements}
        '403': {description: Not an active firm owner session}
    put:
      tags: [Firm]
      summary: Connect your company Paystack account
      description: Checks the key through a read-only Paystack request and encrypts an immutable credential version — pauses new checkout links until explicitly opened — existing orders retain their original merchant key — no payment or transfer is created
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, mode, currency, secretKey, ownedByFirm]
              properties:
                revision: {type: integer, minimum: 0}
                mode: {type: string, enum: [test, live]}
                currency: {type: string, enum: [NGN, USD, GHS, ZAR]}
                secretKey: {type: string, writeOnly: true, maxLength: 200}
                ownedByFirm: {type: boolean, enum: [true]}
      responses:
        '200': {description: Safe configuration — key is never returned}
        '400': {description: Invalid key, mode, currency or ownership confirmation}
        '403': {description: Not an active firm owner session}
        '409': {description: Stale revision or key verification refused}
  /firm/payments/checkout:
    post:
      tags: [Firm]
      summary: Open or pause new firm checkouts
      description: Owner session only — opening requires a verified key, company hosting capacity and for live mode a successful test account delivery or deployment attestation — pausing preserves verification and delivery of payments already started
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, enabled]
              properties:
                revision: {type: integer, minimum: 0}
                enabled: {type: boolean}
      responses:
        '200': {description: Updated checkout settings}
        '403': {description: Not an active firm owner session}
        '409': {description: Readiness requirements missing or revision changed}
  /firm/payments/{tenantId}/{provider}:
    post:
      tags: [Firm]
      summary: Receive a firm-owned gateway payment notification
      description: Provider-specific authentication with the order-pinned credential — Paystack and Squad SHA512 signatures, KoraPay SHA256 over data, or a private per-credential TransactPay URL capability — only queues known orders — direct provider verification is required before payment or delivery
      security: []
      parameters:
        - {in: path, name: tenantId, required: true, schema: {type: string, format: uuid}}
        - {in: path, name: provider, required: true, schema: {type: string, enum: [paystack, squad, transactpay, korapay]}}
        - {in: header, name: x-paystack-signature, schema: {type: string}}
        - {in: header, name: x-squad-encrypted-body, schema: {type: string}}
        - {in: header, name: x-korapay-signature, schema: {type: string}}
        - {in: query, name: key, description: Private URL capability required for TransactPay only, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema: {type: object}
      responses:
        '200': {description: Signed event acknowledged — not a payment or settlement proof}
        '400': {description: Invalid event body}
        '403': {description: Invalid payment signature}
  /firm/payments/gateways:
    get:
      tags: [Firm]
      summary: List built-in payment gateways and safe company settings
      description: Active owner session only — Paystack, Squad, TransactPay and KoraPay are preconfigured — includes required field names, copyable URLs and readiness without returning any keys
      responses:
        '200':
          description: Gateway catalog and current settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  collectionOwner: {const: firm}
                  gateways: {type: array, items: {$ref: '#/components/schemas/FirmPaymentGateway'}}
        '403': {description: Not an active firm owner session}
  /firm/payments/gateways/{provider}:
    put:
      tags: [Firm]
      summary: Connect a firm-owned payment gateway
      description: Owner session only — encrypted immutable credentials, no payment or transfer — independent revision per provider — replacement hides that gateway until re-enabled — TransactPay keys are saved with keyChecked false until a real checkout rehearsal, not advertised as verified
      parameters:
        - {in: path, name: provider, required: true, schema: {type: string, enum: [paystack, squad, transactpay, korapay]}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, mode, currency, keys, ownedByFirm]
              properties:
                revision: {type: integer, minimum: 0}
                mode: {type: string, enum: [test, live]}
                currency: {type: string, enum: [NGN, USD, GHS, ZAR, KES], description: Must be supported by the selected provider and approved on the merchant account}
                ownedByFirm: {const: true}
                keys:
                  type: object
                  additionalProperties: false
                  required: [secretKey]
                  properties:
                    secretKey: {type: string, writeOnly: true, maxLength: 250}
                    publicKey: {type: string, writeOnly: true, maxLength: 250, description: Required for TransactPay only}
                    encryptionKey: {type: string, writeOnly: true, maxLength: 8000, description: TransactPay base64 XML 4096-bit public encryption key}
      responses:
        '200': {description: Safe settings, content: {application/json: {schema: {$ref: '#/components/schemas/FirmPaymentGateway'}}}}
        '400': {description: Unsupported provider, mode, currency or credential fields}
        '403': {description: Not an active firm owner session}
        '409': {description: Changed revision or provider key check failed}
  /firm/payments/gateways/{provider}/checkout:
    post:
      tags: [Firm]
      summary: Show or hide a payment gateway at checkout
      description: Owner session only — enabling checks product availability, matching company hosting and available places — live requires a delivered test purchase using this provider — does not interrupt existing order reconciliation — checkout shows currency-compatible enabled options and hides test gateways when a live gateway is available
      parameters:
        - {in: path, name: provider, required: true, schema: {type: string, enum: [paystack, squad, transactpay, korapay]}}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, enabled]
              properties:
                revision: {type: integer, minimum: 0}
                enabled: {type: boolean}
      responses:
        '200': {description: Safe settings, content: {application/json: {schema: {$ref: '#/components/schemas/FirmPaymentGateway'}}}}
        '403': {description: Not an active firm owner session}
        '409': {description: Readiness requirements missing or revision changed}
  /ops/firms/{tenantId}/deployment-brief:
    get:
      tags: [Ops]
      summary: Download a credential-free firm deployment brief
      description: Operator session only — contains saved product choices, required environment variable names and launch checks, never secret values or an assertion of live readiness — payment, payout and identity provider ownership is always firm
      parameters:
        - {in: path, name: tenantId, required: true, schema: {type: string, format: uuid}}
      responses:
        '200': {description: Deployment brief with readiness set to not_certified}
        '403': {description: Not a MIRRO operator session}
        '404': {description: Firm not found}
  /ops/firm-products:
    get:
      tags: [Ops]
      summary: Firm product catalogue and reusable templates
      description: MIRRO operator session only — implementation status is separate from a feature selection
      responses:
        '200': {description: Feature catalogue and template choices}
        '403': {description: Not a MIRRO operator session}
  /ops/firms/{tenantId}/product:
    parameters:
      - {in: path, name: tenantId, required: true, schema: {type: string, format: uuid}}
    get:
      tags: [Ops]
      summary: Read a firm's product selection and change history
      responses:
        '200': {description: Product revision, catalogue and latest 100 changes}
        '403': {description: Not a MIRRO operator session}
        '404': {description: Firm not found}
    put:
      tags: [Ops]
      summary: Save a firm's feature selection and experience templates
      description: >-
        MIRRO operator session only. Revision conflicts return 409. The configuration and history are committed atomically.
        Features not yet implemented may be selected for planning but are excluded from effective features.
        Removing a feature stops new setup admission; existing record access, trade exits and risk evaluation are retained.
        Dedicated deployment records a preference; it does not connect, create or migrate a database.
        Database credentials are not accepted. The usage package sets full or engine workspace mode.
        Engine API and Account Automation use engine mode; the other packages use full mode.
        The response scope records approval, revision, custom additions and removals, and implementationRequired.
        Approved scope is separate from active-trader seats, account hosting coverage, API billing and launch readiness.
      x-scope: trade
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, template, deployment, websiteTemplate, dashboardTemplate, selected, reason]
              properties:
                revision: {type: integer, minimum: 0}
                template: {type: string, enum: [engine, engine_delivery, workspace, turnkey, connected, turnkey_complete]}
                deployment: {type: string, enum: [managed, dedicated]}
                websiteTemplate: {type: string, enum: [editorial, studio]}
                dashboardTemplate: {type: string, enum: [classic, terminal]}
                selected: {type: object, additionalProperties: {type: boolean}, description: Known feature IDs from the catalogue with dependencies selected together}
                reason: {type: string, minLength: 5, maxLength: 500}
      responses:
        '200': {description: Saved configuration with a new revision}
        '400': {description: Unknown feature, dependency, reason or setting}
        '403': {description: Not a MIRRO operator session}
        '404': {description: Firm not found}
        '409': {description: Revision changed — reload before saving}
  /ops/firms/{tenantId}/hosting:
    parameters:
      - {in: path, name: tenantId, required: true, schema: {type: string, format: uuid}}
      - {in: query, name: mode, schema: {type: string, enum: [live, sandbox], default: live}}
    get:
      tags: [Ops]
      summary: Read company hosting coverage and history
      description: MIRRO operator session only — live and sandbox allowances remain separate
      responses:
        '200': {description: Coverage, reserved and available places and last 30 revisions}
        '403': {description: MIRRO operator session required}
    put:
      tags: [Ops]
      summary: Record reviewed company hosting coverage
      description: >-
        Records an operator attestation of a paid invoice or agreed contract, not a payment-provider verification or money transfer.
        One place is reserved atomically per purchase and follows phase and funded continuation accounts.
        A place cannot sponsor the trader's unrelated accounts. Capacity cannot be reduced below reserved places.
        Expired or suspended coverage refuses new exposure, retains protective exits and preserves hosting grace and flat-position checks.
        Firm-issued accounts never fall back to personal hourly charges. Live checkout still requires merchant and terminal rehearsals.
      x-scope: trade
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, capacity, paidThrough, status, reference, reason, reviewed]
              properties:
                revision: {type: integer, minimum: 0}
                capacity: {type: integer, minimum: 1, maximum: 1000000}
                paidThrough: {type: string, format: date-time, description: Explicit timezone, future for active coverage and within five years}
                status: {type: string, enum: [active, suspended]}
                reference: {type: string, minLength: 5, maxLength: 160}
                reason: {type: string, minLength: 10, maxLength: 500}
                reviewed: {type: boolean, enum: [true]}
      responses:
        '200': {description: Recorded coverage and new revision}
        '400': {description: Invalid allowance or missing evidence attestation}
        '403': {description: MIRRO operator session required}
        '404': {description: Firm not found}
        '409': {description: Revision changed or capacity below reserved places}
  /firm/hosting/release-closed:
    post:
      tags: [Firm]
      summary: Reclaim hosting places from safely retired challenges
      description: Signed-in firm staff only — checks at most 100 closed or failed challenges per request, locks the challenge and accounts, and requires retired accounts, completed runtime shutdown, recent final flat data and no unresolved work — no trade, terminal stop or fund movement is initiated
      x-scope: trade
      parameters:
        - {in: query, name: mode, schema: {type: string, enum: [live, sandbox], default: live}}
      responses:
        '200': {description: Number of places released — active or uncertain accounts are retained}
        '403': {description: Firm staff session required}
  /firm/hosting:
    get:
      tags: [Firm]
      summary: View company hosting allowance
      description: Active firm staff only — this endpoint cannot change or purchase coverage
      parameters:
        - {in: query, name: mode, schema: {type: string, enum: [live, sandbox], default: live}}
      responses:
        '200': {description: Company allowance, reserved places, coverage expiry and review history}
        '400': {description: Invalid environment}
        '403': {description: Unrestricted firm staff access required}
  /firm/infrastructure:
    get:
      tags: [Firm]
      summary: Read dedicated infrastructure actions and resource samples
      description: Owner or operations session only — no credentials, database contents or raw journal messages are returned. Availability is false on the managed workspace. One hour of samples, the latest 60 actions, owner update settings and 30 days of local health observations are returned. Missing observations are unknown, not healthy. The 99.8% target is not an uptime guarantee or independent internet monitoring.
      responses:
        '200': {description: Availability, manager readiness, action history and resource samples}
        '403': {description: Active unrestricted owner or operations session required}
  /firm/infrastructure/actions:
    post:
      tags: [Firm]
      summary: Queue a bounded dedicated server action
      description: Dedicated destination only. Active owner session required. A durable requestId prevents duplicate execution. The manager must be reporting, one action may be active at a time, and authorization is checked again on execution. Queued requests expire after five minutes. An interrupted running action is marked uncertain and never replayed automatically.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [requestId, action]
              properties:
                requestId: {type: string, format: uuid}
                action: {type: string, enum: [restart, check, backup, events, deploy]}
                service: {type: string, enum: [mirro-api, mirro-engine, mirro-file-agent, mirro-terminal-provisioner, mirro-firm-site, caddy], description: Required for restart and events only}
                confirmed: {type: boolean, description: Must be true for a restart or deployment}
                releaseDigest: {type: string, pattern: '^[a-f0-9]{64}$', description: Required only for deployment — must match the protected approved release reported by this destination}
      responses:
        '202': {description: Recorded action — queued or a previously recorded outcome, never implied completion}
        '400': {description: Invalid or unconfirmed action}
        '403': {description: Dedicated owner access required}
        '409': {description: Manager unavailable, another action active or request ID conflict}
        '429': {description: Wait at least ten seconds between new actions}
  /firm/infrastructure/updates:
    put:
      tags: [Firm]
      summary: Set the dedicated firm's automatic update window
      description: Active owner session on the dedicated destination only. Uses optimistic revision checking. Turning updates off cancels future automatic activation, not an activation or recovery already in progress. Only reviewed application releases with unchanged schema and server tooling are eligible.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, enabled, windowStart]
              properties:
                revision: {type: integer, minimum: 0}
                enabled: {type: boolean}
                windowStart: {type: integer, minimum: 0, maximum: 23, description: UTC hour — new deployments may start in the next two hours}
      responses:
        '200': {description: Saved revision, enabled flag and daily UTC start hour}
        '400': {description: Invalid settings}
        '403': {description: Dedicated owner session required}
        '409': {description: Settings changed — refresh and review before saving}
  /firm/infrastructure/actions/{id}/cancel:
    post:
      tags: [Firm]
      summary: Cancel a dedicated action that has not started
      parameters:
        - {in: path, name: id, required: true, schema: {type: string, format: uuid}}
      responses:
        '200': {description: Action cancelled and recorded}
        '403': {description: Dedicated owner session required}
        '409': {description: Action already started or no longer cancellable}
  /firm/deployment:
    get:
      tags: [Firm]
      summary: Read dedicated server setup and installation steps
      description: Active unrestricted staff session only — saved details are not installation or launch evidence
      responses:
        '200': {description: Setup, revision history, steps, optional destination runtime evidence and unverified launch status}
        '403': {description: Staff sign-in required}
    put:
      tags: [Firm]
      summary: Save or request a fresh dedicated server installation
      description: Active owner session only with approved dedicated deployment — never accepts passwords, keys or shell commands and never starts SSH
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [revision, hostname, ownerEmail]
              properties:
                revision: {type: integer, minimum: 0}
                hostname: {type: string, maxLength: 253}
                serverAddress: {type: string, description: Public IP address — required when submit is true}
                ownerEmail: {type: string, format: email}
                sshPort: {type: integer, minimum: 1, maximum: 65535, default: 22}
                submit: {type: boolean, default: false}
      responses:
        '200': {description: Versioned draft or installation request}
        '400': {description: Invalid setup or credentials submitted}
        '403': {description: Owner or dedicated package approval required}
        '409': {description: Reload the current revision}
  /ops/firms/{tenantId}/deployment:
    get:
      tags: [Ops]
      summary: Review a firm's dedicated server request
      parameters:
        - {in: path, name: tenantId, required: true, schema: {type: string, format: uuid}}
      responses:
        '200': {description: Saved request and history — not live installation telemetry}
        '403': {description: Operator session required}
  /ops/firms/{tenantId}/deployment-plan:
    get:
      tags: [Ops]
      summary: Export an approved fresh-server installation plan
      description: Operator session only — no secrets or existing customer data are exported, and customer launch remains unverified
      parameters:
        - {in: path, name: tenantId, required: true, schema: {type: string, format: uuid}}
      responses:
        '200': {description: Installation plan with firm identity, approved product, hardware-sized account limit and bounded program/broker configuration snapshot}
        '403': {description: Operator session required}
        '409': {description: Installation request or dedicated approval missing}
  /firm/product:
    get:
      tags: [Firm]
      summary: Read the firm's selected features and operational availability
      description: Active firm staff only — no operator identities, secrets or infrastructure credentials
      responses:
        '200': {description: Product configuration and catalogue}
        '403': {description: Firm staff role required}
  /firm/activity:
    get:
      tags: [Firm]
      summary: Read firm business events and product changes
      description: Firm staff only with activity-record access — 50 business events per page and latest 100 product changes
      parameters:
        - {in: query, name: before, schema: {type: string}, description: nextCursor from the previous page}
      responses:
        '200': {description: Event items, nextCursor and productChanges}
        '400': {description: Invalid cursor}
        '403': {description: Firm staff role or feature required}
  /error-codes:
    get:
      tags: [Platforms]
      summary: Discover execution error codes and their meanings
      description: 'Versioned catalogue of published MT4/MT5 native trade codes and MIRRO preflight reasons. The catalogue is not a guarantee that a broker accepts a trade. Future or vendor-specific codes are preserved with known=false. HTTP errors retain error.code, error.message and the X-Request-Id support header.'
      x-scope: read
      responses:
        '200':
          description: Native and preflight error catalogue with original documentation sources
          content:
            application/json:
              schema:
                type: object
                required: [version, scope, sources, native, terminal, unknown]
                properties:
                  version: { type: string }
                  scope: { type: string }
                  sources: { type: array, items: { type: string, format: uri } }
                  native: { type: object, additionalProperties: true }
                  terminal: { type: array, items: { type: object, additionalProperties: true } }
                  unknown: { type: object, additionalProperties: true }
        '401': { description: API key or console session required }
  /capabilities:
    get:
      tags: [Platforms]
      summary: Current release capabilities and this key’s limits
      description: Runtime feature discovery. Supported features still require account permissions, terminal compatibility and broker availability. Process-local quotas and replay are not distributed failover guarantees.
      x-scope: read
      responses:
        '200':
          description: Capabilities
          content:
            application/json:
              schema:
                type: object
                required: [contractVersion, rest, trades, marketData, stream, limits, reliability]
                properties:
                  contractVersion: { type: string }
                  diagnostics: { type: object, additionalProperties: true, description: Catalogue URL and supported diagnostic version }
                  rest: { type: object, additionalProperties: { type: boolean } }
                  trades: { type: object, additionalProperties: true }
                  marketData: { type: object, additionalProperties: true }
                  stream: { type: object, additionalProperties: true }
                  limits: { type: object, additionalProperties: true }
                  reliability: { type: object, additionalProperties: { type: boolean } }
        '503': { description: API_BUSY; respect Retry-After }
  /keys:
    get:
      tags: [Keys]
      summary: List API keys
      x-scope: admin
      responses:
        '200': { $ref: '#/components/responses/KeyList' }
    post:
      tags: [Keys]
      summary: Create an API key
      x-scope: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, scopes]
              properties:
                name: { type: string }
                scopes: { type: array, items: { $ref: '#/components/schemas/Scope' } }
                accountIds:
                  type: array
                  items: { type: string }
                  description: Restrict the key to these accounts. Empty = all accounts of the tenant.
                environment:
                  type: string
                  enum: [live, test]
                  default: live
                  description: 'A test key (`mk_test_…`) trades and consents on demo accounts only (`403 TEST_KEY_LIVE_ACCOUNT` on a live account) and can create test keys only (`403 TEST_KEY_LIVE_KEY`). Everything else — reads, audit, usage — is the same.'
                expiresAt: { type: [string, 'null'], format: date-time, description: 'Optional. In the future and at most 730 days ahead; takes effect within the ~15 s resolved-key cache.' }
      responses:
        '403': { description: 'TEST_KEY_LIVE_KEY: a test key asked for a live key.' }
        '409': { description: 'CONFLICT: 20 active keys already; revoke one first.' }
        '201':
          description: Key created. The secret is shown once.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ApiKey'
                  - type: object
                    properties:
                      secret: { type: string, description: Shown once. Store it. }
  /keys/{keyId}:
    delete:
      tags: [Keys]
      summary: Revoke an API key
      x-scope: admin
      parameters: [{ $ref: '#/components/parameters/keyId' }]
      responses: { '204': { description: Revoked } }
  /usage:
    get:
      tags: [Keys]
      summary: Usage, reference estimates and recorded hourly deductions
      description: API product only — HQ uses account plans. Hour buckets whose start is in [from,to) are included. The period defaults to the current UTC month through now and must be positive and no longer than 366 days. Failure to read usage or coverage returns 503, never a zero estimate. API request counts do not themselves produce a charge.
      parameters:
        - { name: from, in: query, schema: { type: string }, description: 'ISO date or timestamp with timezone, inclusive' }
        - { name: to, in: query, schema: { type: string }, description: 'ISO date or timestamp with timezone, exclusive' }
      x-scope: read
      responses:
        '200':
          description: Usage
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Usage' }

  /billing/payments:
    get:
      tags: [Billing]
      summary: Read payment readiness, balance, account plans, invoices and ledger entries
      description: Session-only. Disabled by default. Sandbox and live balances are isolated. Sandbox balances have no monetary value. Includes at most 50 invoices and 50 ledger entries in the configured environment. Refresh explicitly for finalized-payment and billing status.
      security: [{ consoleSession: [] }]
      responses:
        '200':
          description: Payment status, including off mode when checkout is disabled
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PaymentSummary' }
        '401': { description: Missing or invalid console session }
        '403': { description: API keys cannot access user billing }
  /billing/enrollment:
    get:
      tags: [Billing]
      summary: Read the current billing offer and your recorded acceptance
      description: Session-only. Prices and statements come from an immutable operator-approved offer. Live offers require matching approved launch terms. A scheduled enrollment is distinct from active charging. Adding service credit does not accept an offer.
      security: [{ consoleSession: [] }]
      responses:
        '200':
          description: Current offer and enrollment, or null when unavailable
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingEnrollment' }
        '401': { description: Missing or invalid console session }
        '403': { description: API keys cannot access user billing }
    post:
      tags: [Billing]
      summary: Explicitly accept the displayed billing offer
      description: |
        Session-only. Requires a currently valid session created by the MirroAPI gateway.
        The server records the offer, exact rates, statement, authenticated session, requestId and acceptance time atomically.
        Billing begins at the next UTC hour boundary; earlier hours are not charged by this activation.
        Reuse requestId after an uncertain response. Repeating the same accepted offer returns the original acceptance, even if checkout or the offer has since been disabled.
        A different offer cannot replace an existing enrollment. A stale, replaced or disabled offer cannot enroll a new customer.
        Sandbox acceptance never authorizes live charges. High reliability is unavailable.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [offerId, requestId, accepted]
              properties:
                offerId: { type: string, format: uuid }
                requestId: { type: string, format: uuid }
                accepted: { type: boolean, const: true }
      responses:
        '200':
          description: Durable acceptance and enrollment state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BillingEnrollment' }
        '400': { description: Missing affirmative consent, invalid identifiers or unexpected fields }
        '401': { description: Missing or invalid console session }
        '403': { description: API key, revoked session, expired session or session not created by MirroAPI }
        '409': { description: Enrollment already exists under another acceptance }
        '503': { description: Payments disabled or offer no longer available for new enrollment }
  /billing/payment-invoices:
    post:
      tags: [Billing]
      summary: Create a payment invoice in the configured environment
      description: |
        Session-only. Sandbox supports Base Sepolia USDC and a configured six-decimal Tron Nile test token.
        Live supports native USDC on Base and Tether USDt on Tron. Live checkout requires separate operator launch approval and address-ownership evidence; selecting live mode alone does not activate payments.
        A rail from another environment is rejected. Test funds never grant live service credit.
        The requestId in the body, not Idempotency-Key, controls durable idempotency. Reuse it after a timeout.
        Reusing it with another rail or amount returns 409. The server assigns a never-reused deposit address and a one-hour expiry.
        Partial payments do not credit until the quote is covered by eligible finalized transfers. Late and excess funds require review.
        Only the quoted amount is credited. Reviewed excess, partial and late funds have separate refund reservation and completion bookkeeping; it never refunds service credit. Reservations require the finalized scan watermark to pass invoice expiry. Destinations require authenticated manual review, especially for exchange withdrawals. There is no public refund execution or general withdrawal API. Usage charges require separate, explicit enrollment with captured prices.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [rail, amount, requestId]
              properties:
                rail: { type: string, enum: [base-sepolia-usdc, tron-nile-usdt, base-usdc, tron-usdt] }
                amount:
                  type: string
                  pattern: '^(0|[1-9][0-9]{0,4})(\.[0-9]{1,6})?$'
                  description: New live invoices require at least 5 tokens (5000000 atomic units), and at most 20000 tokens. Sandbox allows positive amounts below 5. An unchanged retry of an existing immutable quote retains its original amount, including older quotes below the current minimum. The fixed service quote grants one nominal USD of service credit per token in the same environment; this is not a promise of cash redemption.
                requestId: { type: string, format: uuid }
      responses:
        '201':
          description: The created invoice, or its current state when retrying the same request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CryptoInvoice' }
        '400': { description: Invalid amount, rail, requestId, unexpected fields, or PAYMENT_MINIMUM_TOPUP for a new live quote below 5 tokens }
        '401': { description: Missing or invalid console session }
        '403': { description: API keys cannot create payment invoices }
        '409': { description: requestId already used for another quote }
        '503': { description: Payments disabled, launch not approved, address ownership unverified, rail halted, scanner stale, address pool empty or pending-invoice limit reached }

  /platforms:
    get:
      tags: [Platforms]
      summary: Supported platforms and live reachability
      description: |
        Returns every platform, how a trader authorises it, and its current reachability.
        A firm or broker that has disabled our application appears as `reachable: false` and is
        never billed while in that state.

        `events` says how a platform's terminal or adapter receives commands and reports state:
        `stream` (MetaTrader 5: the terminal holds a socket to the engine; cTrader and TradeLocker:
        the platform pushes to the adapter) or `poll` (MetaTrader 4: the terminal fetches over HTTP
        long-poll or the file carrier; DXtrade). It changes nothing about how you call this API.

        `status` is `available` when a connection method is configured, `alpha` when disabled, or `per-firm`
        (`dxtrade`) on a gateway that offers MetaTrader 4. A gateway started with `MIRRO_API_MT4=0`
        lists `mt4` as `unsupported` with `selfOnboard.ok: false` and `reason: PLATFORM_UNSUPPORTED`,
        and refuses its accounts and configuration links with that code; `status` and `selfOnboard`
        say which gateway you are on, so read them rather than assuming.
      x-scope: read
      security: []
      responses:
        '200':
          description: Platforms
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Platform' }
  /platforms/{platform}/servers:
    get:
      tags: [Platforms]
      summary: Broker servers known for a platform
      description: |
        Searches the broker directory for MetaTrader 4 and MetaTrader 5 servers. A server runs one
        MetaTrader version, so the same query can answer differently for `mt4` and `mt5`. Other
        platforms have no server list and answer `{ "items": [] }`. `kind` is read from the
        server's own name.
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/platform'
        - { in: query, name: q, required: true, description: 'At least 2 characters; shorter is 400 BAD_REQUEST.', schema: { type: string, minLength: 2 } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 60, default: 30 } }
      responses:
        '200':
          description: Servers
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, example: ICMarketsSC-Live03 }
                        company: { type: [string, 'null'] }
                        platform: { $ref: '#/components/schemas/PlatformId' }
                        kind: { type: string, enum: [live, demo, contest, unknown] }
                        verified: { type: boolean }
  /platforms/ctrader/authorize:
    x-not-served: true
    get:
      tags: [Platforms]
      summary: Unused legacy OAuth path
      description: |
        This legacy path is not served and answers `404 NOT_FOUND`. Use the session-only
        `POST /ctrader/authorize` flow, then `POST /ctrader/callback` and `POST /ctrader/connect`.
      x-phase: C
      x-scope: provision
      parameters:
        - in: query
          name: redirectUri
          required: true
          schema: { type: string, format: uri }
      responses:
        '302': { description: Redirect to Spotware }

  /billing/deployment-check:
    get:
      tags: [Billing]
      summary: Check billing prerequisites before a deployment
      description: In live payment mode every customer must have accepted billing and sufficient service credit, with healthy billing and an approved launch. This read-only check reserves no credit; deployment checks again. Use it before a redeploy that first stops a running terminal.
      x-scope: provision
      responses:
        '200':
          description: Current billing prerequisites are satisfied
          content:
            application/json:
              schema:
                type: object
                required: [allowed, billingRequired]
                properties:
                  allowed: { type: boolean, const: true }
                  billingRequired: { type: boolean }
        '402': { description: BILLING_ENROLLMENT_REQUIRED or INSUFFICIENT_BALANCE; accept billing in a signed-in session and fund the service balance }
        '503': { description: BILLING_UNAVAILABLE; no deployment is authorized by this check }
  /accounts:
    get:
      tags: [Accounts]
      summary: List accounts
      x-scope: read
      responses:
        '200':
          description: Accounts
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Account' }
    post:
      tags: [Accounts]
      summary: Connect an account
      description: |
        MetaTrader 4 and 5 require `login`, `password`, `server`; a hosted terminal of that
        version is provisioned and the account is billed hourly from `DEPLOYED`. Credentials are
        written to the vault and are never returned. The same login and server already on your
        desk answers `200` with that account instead of a second one.

        cTrader is refused here with `422 USE_OAUTH`. TradeLocker answers `422 USE_PLATFORM_CONNECT`
        when its connection flow is configured, or `501 NOT_IMPLEMENTED` when disabled.
        Use the session-only connection flow reported in `GET /platforms` as `selfOnboard.via`.

        A MetaTrader login that already runs on the other MetaTrader version is refused with
        `TERMINAL_LOGIN_ON_OTHER_PLATFORM`; a server the broker directory knows only for the
        other version is refused with `TERMINAL_SERVER_IS_OTHER_PLATFORM` (`details.platform`
        names the version that matches). Other provisioning refusals carry their reason
        upper-cased as the code (for example `TERMINAL_USER_CAPACITY_REACHED`).

        `reliability: high` is refused on every platform with `422 RELIABILITY_UNAVAILABLE`
        (`details.field: reliability`): no standby terminal is in service. Send `standard` or
        leave it out.

        In live payment mode every customer must first accept billing and hold service credit:
        missing acceptance is `402 BILLING_ENROLLMENT_REQUIRED` (`details.billingPath`), too
        little credit `402 INSUFFICIENT_BALANCE`, and billing that cannot be read
        `503 BILLING_UNAVAILABLE`. The same check runs on `POST /accounts/{id}/deploy`, which an
        account with an active plan passes without it.

        `start: false` skips that check: the hosted account is created and not started (`201`,
        `state: UNDEPLOYED`, `deployment.error: plan_required`), so a person with no credit can add
        an account and then pay its plan (`POST /billing/plans/checkout`: 6.00 USD per account per
        30 days, trading APIs included). The account starts when the plan is paid, or on deploy.
      x-scope: provision
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AccountCreate' }
      responses:
        '200':
          description: This login on this server is already on your desk; that account is returned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '201':
          description: Account created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Account' }
        '400':
          description: A missing or malformed field (`BAD_REQUEST`), e.g. no `server`, a `role` or `environment` outside its list.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '402':
          description: Live payment mode; billing not accepted (`BILLING_ENROLLMENT_REQUIRED`, `details.billingPath`) or not enough service credit (`INSUFFICIENT_BALANCE`). Nothing was provisioned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '422':
          description: Refused (`RELIABILITY_UNAVAILABLE`, `TERMINAL_LOGIN_ON_OTHER_PLATFORM`, `TERMINAL_SERVER_IS_OTHER_PLATFORM`, `USE_OAUTH`, `USE_PLATFORM_CONNECT`, or another provisioning reason upper-cased)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '501':
          description: TradeLocker connection is disabled on this gateway (`NOT_IMPLEMENTED`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '503':
          description: Live payment mode; the billing balance could not be read (`BILLING_UNAVAILABLE`). Nothing was provisioned.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}:
    parameters: [{ $ref: '#/components/parameters/accountId' }]
    get:
      tags: [Accounts]
      summary: Get an account
      x-scope: read
      responses:
        '200': { $ref: '#/components/responses/Account' }
    patch:
      tags: [Accounts]
      summary: Update name, roles, password or server
      description: |
        Send any of `name`, `roles`, `password`, `server`. They are applied in the order roles,
        password and server, name; a refusal stops there and `details.applied` lists what had
        already changed. A new password (and optionally server) replaces the hosted terminal's
        sealed credential and restarts its provisioning; the login never changes. `server` needs
        `password` with it, and only hosted MetaTrader accounts have either (`409`,
        `details.reason: not_a_hosted_terminal`). Tags are not stored and are not accepted.

        `reliability` is checked like on create: `standard` is accepted and changes nothing (sent
        alone it is `400`, nothing to update), `high` is `422 RELIABILITY_UNAVAILABLE` (`details.field: reliability`) on every platform.
      x-scope: provision
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: [string, 'null'], maxLength: 80, description: 'null clears it.' }
                roles:
                  type: object
                  description: Either or both. `follower` is the execute role a trade needs.
                  properties:
                    master: { type: boolean }
                    follower: { type: boolean }
                password: { type: string, writeOnly: true, minLength: 4, maxLength: 128 }
                server: { type: string, maxLength: 128, description: Requires `password`; the credential is sealed together with its server. }
                reliability: { $ref: '#/components/schemas/Reliability' }
      responses:
        '200': { $ref: '#/components/responses/Account' }
        '400':
          description: Nothing to update, or a malformed field (`BAD_REQUEST`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '409':
          description: Roles or credentials could not be changed (`CONFLICT`, `details.reason`, `details.applied`)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '422':
          description: '`RELIABILITY_UNAVAILABLE`'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
    delete:
      tags: [Accounts]
      summary: Remove an account and destroy its credentials
      x-scope: provision
      responses: { '204': { description: Removed } }
  /accounts/{accountId}/deploy:
    post:
      tags: [Accounts]
      summary: Request account deployment
      description: In live payment mode all customers require billing acceptance and sufficient credit, unless the account has an active plan (`Account.plan`), which covers its hours and passes this check. Checks are repeated for retries; acceptance is never inferred from a deposit. Chargeable hours follow the accepted enrollment start time and captured usage evidence, less the hours a plan covers.
      x-scope: provision
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '202': { description: Deploying }
        '402': { description: Billing acceptance or sufficient service credit is required }
        '503': { description: Billing is unavailable }
  /accounts/{accountId}/undeploy:
    post:
      tags: [Accounts]
      summary: Stop the account (billing ends at the hour)
      x-scope: provision
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses: { '202': { description: Undeploying } }
  /accounts/{accountId}/consent:
    parameters: [{ $ref: '#/components/parameters/accountId' }]
    get:
      tags: [Accounts]
      summary: Trader consent record
      description: Trade scope on a live account is refused until a consent record exists.
      x-scope: read
      responses:
        '200':
          description: Consent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Consent' }
    post:
      tags: [Accounts]
      summary: Record trader consent
      x-scope: provision
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [traderName, traderEmail, statement]
              properties:
                traderName: { type: string }
                traderEmail: { type: string, format: email }
                statement: { type: string, description: The exact text the trader agreed to. }
                ip: { type: string }
      responses:
        '201':
          description: Recorded
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Consent' }

  /accounts/{accountId}/information:
    get:
      tags: [State]
      summary: Account information
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '200':
          description: Information
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AccountInformation' }
  /accounts/{accountId}/positions:
    get:
      tags: [State]
      summary: Open positions
      description: |
        Every open position as the account's terminal or adapter last reported it, **one item per
        position**. An account that is both a strategy (master) and an execute (follower) account
        is reported from both sides internally; the API still lists each position once, under the
        ticket a close must name.

        A book that could not be read is never reported as an empty book: if the gateway cannot
        read this account's positions it answers `503 BOOK_UNAVAILABLE` (`details.read: positions`)
        and nothing about the positions is known from that answer. Retry — do not treat it as flat.
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '200':
          description: Positions
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Position' }
        '503':
          description: '`BOOK_UNAVAILABLE`: the positions could not be read (`details.read: positions`). Not an empty book; retry.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}/positions/{ticket}:
    get:
      tags: [State]
      summary: Position by ticket or positionId
      description: |
        Matches `Position.ticket` or `Position.positionId`. On MetaTrader 4 the two differ after a
        partial close (see `Position`): naming either returns the live remainder. A ticket a
        partial close ended also lands on the remainder, when the remainder's succession is proven.
        `404 NOT_FOUND` means no open position answers to that ticket; `503 BOOK_UNAVAILABLE`
        means the book could not be read, which is not the same answer.
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }, { $ref: '#/components/parameters/ticket' }]
      responses:
        '200':
          description: Position
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Position' }
        '503':
          description: '`BOOK_UNAVAILABLE`: the positions could not be read (`details.read: positions`). Not "no such position"; retry.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}/orders:
    get:
      tags: [State]
      summary: Pending orders
      description: |
        The account's own working orders as its terminal or adapter last reported them, one item
        per order ticket — the newest report of each ticket wins, whatever role the account has.
        An order leaves the list when it leaves the terminal's report (filled, cancelled or
        expired). Positions opened by a fill appear under `/positions`.

        As with `/positions`, an unreadable book is answered `503 BOOK_UNAVAILABLE`
        (`details.read: orders`), never as an empty list.
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '200':
          description: Orders
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Order' }
        '503':
          description: '`BOOK_UNAVAILABLE`: the working orders could not be read (`details.read: orders`). Not an empty book; retry.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}/orders/{ticket}:
    get:
      tags: [State]
      summary: Pending order by ticket
      description: |
        One working order from the same stored book as `GET /orders`, matched on its exact order
        ticket as a string (an order has no `positionId` to match until it fills). `404 NOT_FOUND`
        means no working order carries that ticket; `503 BOOK_UNAVAILABLE` (`details.read: orders`)
        means the book could not be read, which is not the same answer.
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }, { $ref: '#/components/parameters/ticket' }]
      responses:
        '200':
          description: Order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '404':
          description: No working order carries that ticket (`NOT_FOUND`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '503':
          description: '`BOOK_UNAVAILABLE`: the working orders could not be read (`details.read: orders`). Not "no such order"; retry.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}/server-time:
    get:
      tags: [State]
      summary: Gateway clock, or the broker clock read from the terminal
      description: |
        Without `source` (or with `source=gateway`) this is the gateway's own UTC clock, answered
        at once, with `brokerTime: null` and `source: gateway`.

        `source=terminal` asks the account's hosted MetaTrader 4 or 5 terminal for its broker
        clock and answers `time` (when the gateway received the reading), `brokerTime` (the
        broker's clock as the terminal read it, written without a UTC suffix because it is not
        UTC: it is the broker's last quote clock, which can stop while markets are closed),
        `timeBasis: broker`, `source: terminal`, `observedAt` and `receivedAt`. It is a native
        terminal read under the rules of `/symbols/{symbol}/price`: the terminal must be
        broker-connected and seen within 15 seconds (`503 TERMINAL_UNAVAILABLE`), on EA 1.8.0 or
        later (`422 EA_UPGRADE_REQUIRED`), and answer within a 20-second deadline
        (`503 TERMINAL_READ_TIMEOUT`). API platforms have no terminal (`501 NOT_IMPLEMENTED`).
        Any other `source` is `400 BAD_REQUEST`.
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - { in: query, name: source, schema: { type: string, enum: [gateway, terminal], default: gateway } }
      responses:
        '200':
          description: Time
          content:
            application/json:
              schema:
                type: object
                required: [time, brokerTime, source]
                properties:
                  time: { type: string, format: date-time, description: 'The gateway''s UTC clock: now, or when the terminal reading was received.' }
                  brokerTime: { type: [string, 'null'], description: 'null from the gateway. With `source=terminal`, the broker clock as read, without a UTC suffix.' }
                  source: { type: string, enum: [gateway, terminal] }
                  timeBasis: { type: string, enum: [broker], description: 'With `source=terminal` only: `brokerTime` is on the broker''s clock.' }
                  observedAt: { type: string, format: date-time, description: 'With `source=terminal` only: the terminal''s own UTC clock when it read the broker time.' }
                  receivedAt: { type: string, format: date-time, description: 'With `source=terminal` only: when the gateway received the reading.' }
        '400': { description: '`BAD_REQUEST`: `source` is not gateway or terminal.' }
        '422': { description: '`EA_UPGRADE_REQUIRED`: the terminal runs an EA older than 1.8.0.' }
        '429': { description: '`RATE_LIMITED` (`details.scope: terminal-read`): a read is already pending on this account, or the gateway''s concurrent-read cap is reached.' }
        '501': { description: '`NOT_IMPLEMENTED`: not a hosted MetaTrader account.' }
        '503': { description: '`TERMINAL_UNAVAILABLE`, `TERMINAL_READ_TIMEOUT`, `TERMINAL_READ_INVALID`, `TERMINAL_READ_UNAVAILABLE` (`details.reason` is the terminal''s word), `TERMINAL_CLOCK_UNCERTAIN` or `TERMINAL_READ_CANCELLED`. No reading is invented.' }

  /accounts/{accountId}/history/deals:
    get:
      tags: [History]
      summary: Deals by time range, ticket or position
      description: |
        Paged. `cursor` is opaque: a page ends on a deal and the next page starts after it by
        time AND ticket, so deals sharing a timestamp are never skipped. `ticket` and
        `positionId` are exact stored identifiers, one value each of at most 256 characters,
        never trimmed, case-folded or read as numbers; anything else is `400 BAD_REQUEST` with
        `details.field`. Money a deal could not be priced with is `null`, never `"null"` or `0`.
        History that could not be read is `503 HISTORY_UNAVAILABLE`, never an empty page.
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - { in: query, name: from, schema: { type: string, format: date-time } }
        - { in: query, name: to, schema: { type: string, format: date-time } }
        - { in: query, name: ticket, schema: { type: string } }
        - { in: query, name: positionId, schema: { type: string } }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Deals
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/Deal' } }
                  nextCursor: { type: [string, 'null'] }
        '400':
          description: '`BAD_REQUEST` (`details.field: ticket | positionId`): the identifier was blank, repeated or over 256 characters.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '503':
          description: '`HISTORY_UNAVAILABLE`: the history could not be read. Not an empty history; retry.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}/history/orders:
    x-not-served: true
    get:
      tags: [History]
      summary: Historical orders by time range, ticket or position (not served yet)
      description: |
        Not served by the current gateway: answers `404 NOT_FOUND`. Closed and cancelled orders
        are not stored yet; every fill is in `/history/deals`.
      x-phase: C
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - { in: query, name: from, schema: { type: string, format: date-time } }
        - { in: query, name: to, schema: { type: string, format: date-time } }
        - { in: query, name: ticket, schema: { type: string } }
        - { in: query, name: positionId, schema: { type: string } }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/Order' } }
                  nextCursor: { type: [string, 'null'] }

  /accounts/{accountId}/symbols:
    get:
      tags: [Market data]
      summary: Symbols available on the account
      description: The broker symbols in the account's stored catalogue, exact case, as an array of strings. A catalogue that could not be read is `503 SYMBOL_CATALOG_UNAVAILABLE`, never `[]`.
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '200':
          description: Symbols
          content:
            application/json:
              schema: { type: array, items: { type: string } }
        '503':
          description: '`SYMBOL_CATALOG_UNAVAILABLE`: the catalogue could not be read. Not an empty catalogue; retry.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}/symbols/{symbol}/specification:
    get:
      tags: [Market data]
      summary: Contract specification
      description: |
        The stored specification of one symbol, matched in exact case. The account's own
        catalogue answers first (`source: account-catalogue`, so two accounts at the same broker
        can differ); before the account's catalogue has been ingested the shared broker catalogue
        answers instead (`source: broker-catalogue`). Once any catalogue run exists for the
        account, a symbol that is not in it is `404 NOT_FOUND`. Unknown fields stay `null`
        (`tickSize` and `tickValue` are not stored yet and are always `null`); nothing is inferred
        from `digits`. A read that failed is `503 SYMBOL_CATALOG_UNAVAILABLE`, never the shared
        fallback.
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }, { $ref: '#/components/parameters/symbol' }]
      responses:
        '200':
          description: Specification
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SymbolSpecification' }
        '404':
          description: The symbol is not in the account's catalogue (`NOT_FOUND`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '503':
          description: '`SYMBOL_CATALOG_UNAVAILABLE`: the catalogue could not be read; retry.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
  /accounts/{accountId}/symbols/{symbol}/price:
    get:
      tags: [Market data]
      summary: Current bid/ask, read from the terminal
      description: |
        A native terminal read: the account's hosted MetaTrader 4 or 5 terminal is asked for the
        broker's current bid and ask for `symbol` (exact broker case) and the answer is returned
        as it came — decimal strings, `source: terminal`, the quote's own `time` and `timeBasis`,
        when the terminal read it (`observedAt`), when the gateway received it (`receivedAt`),
        its `ageSeconds` and whether it is `stale`. Request and response, not tick streaming.

        By default the quote must be verifiably UTC (`timeBasis: utc`, which needs the
        terminal's broker clock offset on record) and at most `maxAgeSeconds` old (1–300,
        default 15); otherwise `503 QUOTE_STALE` with `details.ageSeconds`, `details.timeBasis`
        and `details.maxAgeSeconds`. `allowStale=true` accepts an older quote, or one whose age
        is unknown (`stale: null`, `timeBasis: broker`). Each of `allowStale` and
        `maxAgeSeconds` may be sent once; `allowStale` must be `true` or `false`
        (`400 BAD_REQUEST`).

        The terminal must be broker-connected and seen within 15 seconds
        (`503 TERMINAL_UNAVAILABLE`), on EA 1.8.0 or later (`422 EA_UPGRADE_REQUIRED`), and
        answer within a 20-second deadline (`503 TERMINAL_READ_TIMEOUT`). A reading the gateway
        cannot verify — wrong id, kind or symbol, or a bid above the ask — is
        `503 TERMINAL_READ_INVALID`; a terminal clock more than 10 seconds from the gateway's is
        `503 TERMINAL_CLOCK_UNCERTAIN`; a broker that could not supply the quote is
        `503 TERMINAL_READ_UNAVAILABLE` (`details.reason` is the terminal's word); a client that
        went away is `503 TERMINAL_READ_CANCELLED`. One read at a time per account and a
        gateway-wide cap: beyond either, `429 RATE_LIMITED` (`details.scope: terminal-read`).
        API platforms have no terminal: `501 NOT_IMPLEMENTED`. No price is ever invented.
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - $ref: '#/components/parameters/symbol'
        - { in: query, name: allowStale, schema: { type: boolean, default: false } }
        - { in: query, name: maxAgeSeconds, schema: { type: integer, minimum: 1, maximum: 300, default: 15 } }
      responses:
        '200':
          description: Price
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Price' }
        '400': { description: '`BAD_REQUEST`: a malformed symbol, `allowStale` not true or false, or a query parameter sent twice.' }
        '422': { description: '`EA_UPGRADE_REQUIRED`: the terminal runs an EA older than 1.8.0.' }
        '429': { description: '`RATE_LIMITED` (`details.scope: terminal-read`): a read is already pending on this account, or the gateway''s concurrent-read cap is reached.' }
        '501': { description: '`NOT_IMPLEMENTED`: not a hosted MetaTrader account.' }
        '503': { description: '`TERMINAL_UNAVAILABLE` (no fresh broker-connected terminal), `QUOTE_STALE` (`details.ageSeconds`, `details.timeBasis`, `details.maxAgeSeconds`), `TERMINAL_READ_TIMEOUT`, `TERMINAL_READ_INVALID`, `TERMINAL_READ_UNAVAILABLE` (`details.reason` is the terminal''s word), `TERMINAL_CLOCK_UNCERTAIN` or `TERMINAL_READ_CANCELLED`. No price is invented.' }
  /accounts/{accountId}/symbols/{symbol}/tick:
    get:
      tags: [Market data]
      summary: Current bid/ask (the same terminal read as /price; not a historical tick feed)
      description: Answers exactly what `/price` answers — the terminal's current bid/ask snapshot, with the same parameters and refusals. MetaTrader reports no `last` price or tick volume, so there is none here; historical ticks are not served.
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - $ref: '#/components/parameters/symbol'
        - { in: query, name: allowStale, schema: { type: boolean, default: false } }
        - { in: query, name: maxAgeSeconds, schema: { type: integer, minimum: 1, maximum: 300, default: 15 } }
      responses:
        '200':
          description: Tick
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Tick' }
        '400': { description: '`BAD_REQUEST`: a malformed symbol, `allowStale` not true or false, or a query parameter sent twice.' }
        '422': { description: '`EA_UPGRADE_REQUIRED`: the terminal runs an EA older than 1.8.0.' }
        '429': { description: '`RATE_LIMITED` (`details.scope: terminal-read`): a read is already pending on this account, or the gateway''s concurrent-read cap is reached.' }
        '501': { description: '`NOT_IMPLEMENTED`: not a hosted MetaTrader account.' }
        '503': { description: '`TERMINAL_UNAVAILABLE` (no fresh broker-connected terminal), `QUOTE_STALE` (`details.ageSeconds`, `details.timeBasis`, `details.maxAgeSeconds`), `TERMINAL_READ_TIMEOUT`, `TERMINAL_READ_INVALID`, `TERMINAL_READ_UNAVAILABLE` (`details.reason` is the terminal''s word), `TERMINAL_CLOCK_UNCERTAIN` or `TERMINAL_READ_CANCELLED`. No price is invented.' }
  /accounts/{accountId}/symbols/{symbol}/book:
    x-not-served: true
    get:
      tags: [Market data]
      summary: Order book (not served yet)
      description: Not served by the current gateway; answers `404 NOT_FOUND`.
      x-phase: C
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }, { $ref: '#/components/parameters/symbol' }]
      responses:
        '200':
          description: Book
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Book' }
  /accounts/{accountId}/symbols/{symbol}/candles:
    x-not-served: true
    get:
      tags: [Market data]
      summary: OHLCV candles (not served yet)
      description: Not served by the current gateway; answers `404 NOT_FOUND`.
      x-phase: C
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - $ref: '#/components/parameters/symbol'
        - { in: query, name: timeframe, required: true, schema: { type: string, enum: [1m, 5m, 15m, 30m, 1h, 4h, 1d, 1w, 1M] } }
        - { in: query, name: from, schema: { type: string, format: date-time } }
        - { in: query, name: to, schema: { type: string, format: date-time } }
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Candles
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Candle' } }
  /accounts/{accountId}/symbols/{symbol}/ticks:
    x-not-served: true
    get:
      tags: [Market data]
      summary: Historical ticks (not served yet)
      description: Not served by the current gateway; answers `404 NOT_FOUND`.
      x-phase: C
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - $ref: '#/components/parameters/symbol'
        - { in: query, name: from, required: true, schema: { type: string, format: date-time } }
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Ticks
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Tick' } }

  /accounts/{accountId}/trades:
    post:
      tags: [Trade]
      summary: Place, modify, close or cancel
      description: |
        One endpoint, discriminated by `action`. The request is validated, checked against the
        account's Risk settings, its execution role and its connectivity, and — if it passes —
        written to a durable command ledger in a single commit and answered with **202** and a
        `Command`. The command is handed to the account's terminal or adapter in the same instant
        (typically it is on the terminal's socket before this response reaches you); poll
        `GET /accounts/{id}/trades/{commandId}` or subscribe to the `command.resolved` webhook
        for the terminal state. `result.latencyMs` on the resolved command is the measured time
        from the ledger row to the terminal's acknowledgement arriving at the engine. This
        excludes client network time and the final result commit. Optional
        `result.terminalExecutionMs` measures the final attempt inside the EA, including
        validation, duplicate detection and broker calls. It excludes transport,
        previous attempts and retry backoff; it is not pure broker network RTT.
        MT4 EA 1.9.5 and MT5 EA 1.9.2 introduce this field; older terminals omit it.
        Every HTTP response also includes `Server-Timing: gateway;dur=<milliseconds>`.
        This monotonic duration covers gateway handling, including body reception,
        authentication, validation, the durable acceptance commit and response serialization.
        It excludes TLS setup, response transmission and subsequent broker completion.
        A fast **202 Accepted** does not mean the broker has filled the order.
        These measurements do not imply a broker execution latency guarantee.

        A refusal returns `422` with the arithmetic and is written to the Audit log
        (`GET …/risk/breaches`) and onto the command ledger as a command in state `refused` with the
        same `result.code` — a structural refusal (role, consent, connectivity, funding) is born there,
        a risk or ticket refusal closes out the row it had — so `GET …/trades` lists it and the
        `order.refused` webhook carries it; the 422 body itself does not name the ledger row. Live
        accounts require a consent record; execution requires the account's follower (execute) role.
        A command nothing claims within two minutes is marked `expired` (`EXECUTION_NOT_CLAIMED`)
        rather than silently dropped.

        A **transient** broker outcome (the terminal's answer starts `transient_`: no price before
        the first tick, broker reconnecting, requote, a fill whose position could not yet be named)
        is not final: the command stays `claimed` and the same command id is handed to the
        terminal again until `expiresAt`. While it waits, `result.lastBroker` is the last
        transient answer, `result.transientAttempts` how many there were and
        `result.lastTransientAt` when the last came. At `expiresAt`, if the LAST hand-out was
        itself answered `transient_*`, it is resolved `rejected` with
        `result.code: EXECUTION_TRANSIENT_EXHAUSTED` and `result.broker` naming the last answer.

        If instead the last hand-out was never answered at all and the expiry is long past, the
        command ends `expired` with `result.code: EXECUTION_UNKNOWN`. That is **not** a rejection:
        a terminal held the command and may have executed it (an expert-advisor restart loses
        unsent acknowledgements). Read `GET /positions` and the deal history before sending it
        again. A late answer is the truth and replaces either ending, naming what it corrected in
        `result.correctedFrom`.

        Four outcomes resolve ok with an explanation in `result.note` (a sentence) and
        `result.broker` (the terminal's own word). None is reported as a rejection:

        - `already_closed` — the ticket was already closed, so nothing was sent.
        - `recovered_after_unknown` — an earlier attempt's outcome was unknown and the terminal
          found the order it had placed; `result.ticket` names it.
        - `already_reduced` — a re-driven `close-partial` whose split had already landed: the
          position was reduced ONCE, and `result.ticket` is the remainder.
        - `recovered_closed` — a re-driven command found its own fill already closed in the
          account history, so nothing was placed a second time; read the deal history for it.

        Three terminal refusals are permanent and traded nothing, all of them
        `result.code: BROKER_REJECTED` with the word in `result.broker`:

        - `ticket_superseded` — the ticket named is closed and a later ticket provably holds the
          remainder. `result.ticket` is that live ticket; send the command again naming it.
        - `remainder_unproven` — the ticket named is closed and an open order looks like its
          remainder, but no broker record proves it, so nothing was sent rather than act on a
          different position. Re-read `GET /positions` and name the live ticket you mean.
        - `remainder_ambiguous` — more than one open order could be the remainder. Same answer:
          re-read `GET /positions` and name the ticket.

        `symbol_not_available` is permanent in the same way, and only when the terminal can prove it:
        it is answered when the terminal is logged in, connected and its symbol list is not empty.
        A terminal that is not connected answers `transient_not_connected` instead, so a command is
        re-driven rather than refused for a symbol nobody could look up.

        A refusal MIRRO itself makes, before any terminal sees the command, is never dressed up as
        a broker rejection: it resolves `rejected` with `result.code: EXECUTION_REFUSED`,
        `result.reason` the stable word (`position_book_unknown`, `entry_price_required`,
        `stop_limit_unsupported`, `invalid_request`, `unsupported_action`, `opens_suppressed`) and
        no `result.broker` at all — the broker was never asked.

        Stops on a **market** order travel as a follow-up protection command after the fill
        (`{commandId}:p`), anchored to the fill price. `modify-position` therefore needs
        `entryPrice` (the position's open price) so its stops can be expressed the same way.
        A `modify-position` naming only one of `stopLoss` / `takeProfit` keeps the other leg:
        the missing leg is filled from the account's last reported book when the command is
        built, and the offsets are anchored on the book's own open price. If the position has no
        row in the book the command is accepted (`202`) and then resolved `rejected` with
        `result.code: EXECUTION_REFUSED` and `result.reason: position_book_unknown` (the message
        starts with the same word), rather than sent with a stop wiped: send both legs, or wait
        for the terminal's next book.

        **MetaTrader 4 tickets.** A partial close on MetaTrader 4 gives the remainder a new
        broker ticket. `Position.ticket` is always the live ticket a close must name;
        `Position.positionId` is the stable identity (the origin ticket). A `close` or
        `modify-position` naming the origin ticket or a ticket that a
        partial close superseded is re-addressed to the live remainder, at the gateway (the
        command's `request.ticket` becomes the live ticket and `request.ticketResolvedFrom`
        keeps the one you sent) and in the terminal; `already_closed` is answered only when no
        remainder exists. A `close-partial` is never re-addressed: repeating a full close or a
        stop change costs nothing, but cutting a position a second time is a different trade, and
        a retry under a new `Idempotency-Key` is a new command here. A `close-partial` naming a
        superseded ticket is therefore refused with `422 TICKET_SUPERSEDED` (`details.ticket`,
        `details.liveTicket`), nothing is sent, and you decide whether the remainder should be cut
        again. The terminal refuses the same case with `ticket_superseded`.
        If the ledger row cannot be re-addressed the command is refused with
        `422 TICKET_READDRESS_FAILED` (`details.ticket`, `details.liveTicket`) and nothing is
        sent — unless that refusal itself could not be recorded, which is `503 EXECUTION_UNKNOWN`
        (see the responses below). Where the terminal cannot prove which open order carries the
        remainder it refuses permanently (`ticket_superseded`, `remainder_unproven`,
        `remainder_ambiguous`) and trades nothing: re-read `GET /positions` and name the live
        ticket. After a `close-partial` on MetaTrader 4, `Command.result.ticket` is the
        **remainder** ticket.

        Not offered, refused before anything is queued with `422 INVALID_REQUEST` and
        `details.field` naming the field: `type: stop-limit`
        (no hosted terminal takes it; MetaTrader 4 has no such order), `comment` on any action
        (reserved for MIRRO's own dedup tag on the broker order), `expiration` on any action
        (not carried to the terminals yet), and `volume` on `modify-order` (a pending order's
        volume cannot change in place on MetaTrader: `cancel` and place again). Nothing is
        dropped silently. Every other validation failure (a missing `symbol`, `ticket`,
        `volume`, `price` or `entryPrice`, a malformed decimal) is the same `422 INVALID_REQUEST`
        with `details.field`; a modify that changes nothing has `details.field: null`.

        A `modify-order` that leaves out any of `price`, `stopLoss`, `takeProfit` is a patch:
        the terminal keeps the legs not named. That needs EA 1.7.3 or later on the account's
        hosted terminal; on an older EA it is refused before anything is queued with
        `422 EA_UPGRADE_REQUIRED` (`details.requiredVersion`, `details.actualVersion`) — send
        all three (null removes a stop) or update the terminal. In live payment mode a command
        that opens or widens risk (`market`, `pending`, a `modify-order`, a `modify-position`
        that is not protective) is refused when the prepaid balance is not healthy, with the
        billing word as the code (`BILLING_ENROLLMENT_REQUIRED`, `INSUFFICIENT_BALANCE`,
        `BILLING_UNAVAILABLE`); `close`, `close-partial`, `cancel` and a protective stop change
        are never refused for billing.
      x-scope: trade
      parameters: [{ $ref: '#/components/parameters/accountId' }, { $ref: '#/components/parameters/idempotencyKey' }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/TradeRequest' }
      responses:
        '202':
          description: Accepted into the ledger
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Command' }
        '422':
          description: 'Refused by validation (`INVALID_REQUEST` with `details.field`), the terminal''s EA version for a partial `modify-order` (`EA_UPGRADE_REQUIRED`), execution role (`EXECUTION_ROLE_REQUIRED`), consent (`NO_CONSENT`), connectivity (`ACCOUNT_DISCONNECTED`, `BROKER_DOWN`, `FIRM_UNREACHABLE`), the prepaid balance in live payment mode (`BILLING_ENROLLMENT_REQUIRED`, `INSUFFICIENT_BALANCE`, `BILLING_UNAVAILABLE`), risk settings (`RISK_*` with `details.arithmetic`) or MetaTrader 4 tickets (`TICKET_SUPERSEDED` when a `close-partial` names a ticket a partial close ended, `TICKET_READDRESS_FAILED` when the ledger row could not be re-addressed). Every refusal after validation also carries `details.request`. Nothing was sent. A test key on a live account is `403 FORBIDDEN`. Retries: a structural refusal (role, consent, connectivity: `EXECUTION_ROLE_REQUIRED`, `NO_CONSENT`, `ACCOUNT_DISCONNECTED`, `BROKER_DOWN`, `FIRM_UNREACHABLE`) reserves no `Idempotency-Key`, so a retry under the same key is judged again and, if refused again, leaves a new ledger row and a new `order.refused` event per retry; a risk or ticket refusal (`RISK_*`, `TICKET_SUPERSEDED`, `TICKET_READDRESS_FAILED`) is sealed under the key, so a retry replays the recorded 422 (`Idempotent-Replayed: true`) with one row and one event. A prepaid-balance refusal (`BILLING_ENROLLMENT_REQUIRED`, `INSUFFICIENT_BALANCE`, `BILLING_UNAVAILABLE`) is sealed like a risk refusal.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
        '503':
          description: '`EXECUTION_UNKNOWN`: the gateway meant to refuse this command (`details.intendedRefusal` names the refusal it intended) but could not record that refusal on the ledger, so the command may still be handed to the terminal. Its outcome is not known. Read `GET /accounts/{accountId}/trades/{details.command}` before sending anything again; a retry with the same `Idempotency-Key` replays that command''s real state.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Refusal' }
    get:
      tags: [Trade]
      summary: Recent commands on the account
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }, { $ref: '#/components/parameters/limit' }]
      responses:
        '200':
          description: Commands
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Command' } }
  /accounts/{accountId}/trades/{commandId}:
    get:
      tags: [Trade]
      summary: One command and its current state
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }, { in: path, name: commandId, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Command
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Command' }
  /accounts/{accountId}/margin:
    post:
      tags: [Trade]
      summary: Margin for a hypothetical order, computed by the terminal
      description: |
        A native terminal read under the rules of `/symbols/{symbol}/price` (a fresh
        broker-connected terminal, a 20-second deadline, a verified clock and a fresh quote; the
        same `422`, `429`, `501` and `503` answers), on EA 1.9.1 or later. Nothing is placed and
        nothing is rounded: the terminal checks the broker's volume limits and lot step as sent.
        Read scope is enough; it is not idempotency-keyed.

        Send `symbol` (exact broker case), `side`, `volume` (a positive decimal string) and,
        on MetaTrader 5, an optional `price` (MetaTrader 4 takes none). Any other field, or a malformed one, is
        `400 BAD_REQUEST` with `details.field`; a `price` on MetaTrader 4 is
        `422 MARGIN_PRICE_UNSUPPORTED` (`details.field: price`), because MT4 can only check free
        margin at the current broker price.

        MetaTrader 5 answers `method: order_calc_margin` with `margin` (OrderCalcMargin) and
        `freeMarginBefore`, `freeMarginAfter`, `sufficientFunds` null: existing positions are not
        counted and affordability is not certified. A zero result is refused as unverified
        (`503 TERMINAL_READ_INVALID`), so a product whose margin is genuinely zero is not certified
        here. MetaTrader 4 answers `method: free_margin_check` with `freeMarginBefore`,
        `freeMarginAfter` and `sufficientFunds` (AccountFreeMarginCheck on the current account)
        and `margin` null: remaining free margin is not standalone required margin. Both carry
        the symbol, side, volume, the `price` the calculation used, the quote's `time`,
        `timeBasis` and `ageSeconds`, and the observation times. A calculation is not a
        guarantee that a later order executes.

        On MetaTrader 4 netting/hedging effects make the two free-margin values distinct from a
        standalone order-margin quote. This read creates no trade. Unknown or stale readings are refused.
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [symbol, side, volume]
              additionalProperties: false
              properties:
                symbol: { type: string, maxLength: 128, description: Exact broker symbol. }
                side: { type: string, enum: [buy, sell] }
                volume: { type: string, description: 'Positive decimal string, at most 16 decimals; not rounded to the lot step.' }
                price: { type: string, description: 'The hypothetical price, on MetaTrader 5. On MetaTrader 4 it is refused with 422 MARGIN_PRICE_UNSUPPORTED.' }
      responses:
        '200':
          description: Margin
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MarginCheck' }
        '400': { description: '`BAD_REQUEST` (`details.field`): an unknown field, or a malformed symbol, side, volume or price.' }
        '422': { description: '`MARGIN_PRICE_UNSUPPORTED` (`details.field: price`) on MetaTrader 4; `EA_UPGRADE_REQUIRED` below EA 1.9.1.' }
        '429': { description: '`RATE_LIMITED` (`details.scope: terminal-read`): a read is already pending on this account, or the gateway''s concurrent-read cap is reached.' }
        '501': { description: '`NOT_IMPLEMENTED`: not a hosted MetaTrader account.' }
        '503': { description: '`TERMINAL_UNAVAILABLE`, `QUOTE_STALE`, `TERMINAL_READ_TIMEOUT`, `TERMINAL_READ_INVALID` (a calculation that does not match the request, or a zero MetaTrader 5 margin), `TERMINAL_READ_UNAVAILABLE` (`details.reason`), `TERMINAL_CLOCK_UNCERTAIN` or `TERMINAL_READ_CANCELLED`. No estimate is derived from guessed leverage.' }

  /copies:
    get:
      tags: [Copy]
      summary: List copy links
      x-scope: read
      responses:
        '200':
          description: Copies
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Copy' } }
    post:
      tags: [Copy]
      summary: Create a copy link
      description: |
        Copies the master's full position book to the follower under a sizing mode. Cross-broker
        symbol translation and currency conversion are automatic. Copies into prop accounts are
        refused unless the firm's policy in the Platform Registry permits them.
      x-scope: copy
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CopyCreate' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Copy' }
  /copies/{copyId}:
    parameters: [{ $ref: '#/components/parameters/copyId' }]
    get:
      tags: [Copy]
      summary: Get a copy link
      x-scope: read
      responses:
        '200':
          description: Copy
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Copy' }
    patch:
      tags: [Copy]
      summary: Change sizing, filters or active state
      x-scope: copy
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CopyUpdate' }
      responses:
        '200':
          description: Copy
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Copy' }
    delete:
      tags: [Copy]
      summary: Remove a copy link (open follower positions are left in place)
      x-scope: copy
      responses: { '204': { description: Removed } }
  /copies/{copyId}/preview:
    get:
      tags: [Copy]
      summary: What the follower would do right now for each master position
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/copyId' }]
      responses:
        '200':
          description: Preview
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/CopyIntent' } }
  /copies/{copyId}/events:
    get:
      tags: [Copy]
      summary: Every copy decision, including refusals, with the arithmetic
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/copyId' }, { $ref: '#/components/parameters/limit' }, { $ref: '#/components/parameters/cursor' }]
      responses:
        '200':
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/AuditEvent' } }
                  nextCursor: { type: [string, 'null'] }

  /accounts/{accountId}/stats:
    x-not-served: true
    get:
      tags: [Stats]
      summary: Performance statistics (not served yet)
      description: |
        Not served by the current gateway: answers `404 NOT_FOUND`. When it opens, `verified: true`
        will mean every number was computed from fills MirroAPI observed on the account itself,
        not from a statement the account reported.
      x-phase: C
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '200':
          description: Stats
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Stats' }
  /accounts/{accountId}/stats/equity:
    x-not-served: true
    get:
      tags: [Stats]
      summary: Equity and balance curve (not served yet)
      description: Not served by the current gateway; answers `404 NOT_FOUND`.
      x-phase: C
      x-scope: read
      parameters:
        - $ref: '#/components/parameters/accountId'
        - { in: query, name: resolution, schema: { type: string, enum: [1h, 1d] } }
      responses:
        '200':
          description: Curve
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    time: { type: string, format: date-time }
                    balance: { type: string }
                    equity: { type: string }

  /accounts/{accountId}/risk:
    parameters: [{ $ref: '#/components/parameters/accountId' }]
    get:
      tags: [Risk]
      summary: Risk settings
      x-scope: read
      responses:
        '200':
          description: Risk
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RiskSettings' }
    put:
      tags: [Risk]
      summary: Set risk settings (refuse-to-send is enforced before every command)
      description: |
        Every rule accepted here is enforced by the gate that runs before an opening command leaves
        (`RISK_MAX_LOTS`, `RISK_MAX_OPEN_LOTS`, `RISK_OPEN_LOTS_UNKNOWN`, `RISK_MAX_POSITIONS`,
        `RISK_DAILY_LOSS`, `RISK_DAILY_LOSS_UNKNOWN`, `RISK_TRADING_HOURS`; exits are never refused;
        the two `_UNKNOWN` codes refuse a rule that could not be measured rather than pass it). A rule the gateway
        cannot enforce is not stored as if it were: `maxDrawdownPercent`, `flattenOnBreach: true` and
        `refuseToSend: false` are refused with `422 RISK_RULE_UNSUPPORTED`, `details.rule` naming the
        rule, and nothing is written. The whole settings object is replaced on every PUT.
      x-scope: trade
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RiskSettings' }
      responses:
        '200':
          description: Risk
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RiskSettings' }
        '400': { description: 'BAD_REQUEST: a percent at or above 100, a non-decimal string, a `tradingHours` rule that does not parse, or a setting name the gateway does not know (`details.unknown`).' }
        '422': { description: 'RISK_RULE_UNSUPPORTED (`details.rule`: maxDrawdownPercent, flattenOnBreach or refuseToSend): the rule has no enforcer, so it is not taken.' }
  /accounts/{accountId}/risk/breaches:
    get:
      tags: [Risk]
      summary: Breaches and refusals against the settings
      x-scope: read
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '200':
          description: Breaches
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/AuditEvent' } }

  /audit/events:
    get:
      tags: [Audit]
      summary: Everything that happened, with evidence
      description: Orders, refusals, disconnects, consent, key use, webhook deliveries. Retained 180 days.
      x-scope: read
      parameters:
        - { in: query, name: accountId, schema: { type: string } }
        - { in: query, name: type, schema: { $ref: '#/components/schemas/AuditType' } }
        - { in: query, name: from, schema: { type: string, format: date-time } }
        - { in: query, name: to, schema: { type: string, format: date-time } }
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/cursor'
      responses:
        '200':
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/AuditEvent' } }
                  nextCursor: { type: [string, 'null'] }
  /audit/events/{eventId}:
    get:
      tags: [Audit]
      summary: One event
      x-scope: read
      parameters: [{ in: path, name: eventId, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Event
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuditEvent' }

  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhooks
      x-scope: read
      responses:
        '200':
          description: Webhooks
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Webhook' } }
    post:
      tags: [Webhooks]
      summary: Create a webhook
      description: 'Deliveries are signed `X-Mirro-Signature: t=<unix>,v1=<hmac-sha256>` and retried with backoff (30 s growing to 12 h between attempts) for about 28 hours in all, then abandoned with a `webhook.failed` audit event. Retries reuse the event id: de-duplicate on it.'
      x-scope: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { $ref: '#/components/schemas/WebhookEvent' } }
                accountIds: { type: array, items: { type: string } }
      responses:
        '201':
          description: Created. The signing secret is shown once.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Webhook'
                  - type: object
                    properties:
                      secret: { type: string }
  /webhooks/{webhookId}:
    delete:
      tags: [Webhooks]
      summary: Delete a webhook
      x-scope: admin
      parameters: [{ in: path, name: webhookId, required: true, schema: { type: string } }]
      responses: { '204': { description: Deleted } }
  /webhooks/{webhookId}/deliveries:
    get:
      tags: [Webhooks]
      summary: Delivery log (30 days)
      x-scope: read
      parameters: [{ in: path, name: webhookId, required: true, schema: { type: string } }]
      responses:
        '200':
          description: Deliveries
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    eventId: { type: string }
                    eventType: { type: string }
                    attempts: { type: integer }
                    deliveredAt: { type: [string, 'null'], format: date-time }
                    failedAt: { type: [string, 'null'], format: date-time }
                    lastStatus: { type: [integer, 'null'] }
                    lastError: { type: [string, 'null'] }
  /webhooks/{webhookId}/test:
    post:
      tags: [Webhooks]
      summary: Send a signed test event
      x-scope: admin
      parameters: [{ in: path, name: webhookId, required: true, schema: { type: string } }]
      responses: { '202': { description: Queued } }

  /hooks:
    get:
      tags: [Signals]
      summary: List signal hooks
      x-scope: read
      parameters: [{ in: query, name: accountId, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Hooks
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Hook' } }
    post:
      tags: [Signals]
      summary: Create a signal hook
      description: |
        A hook is a URL a charting platform can POST one line of text to. The line becomes ordinary
        commands through the same gate, the same ledger and the same dispatch as `POST /trades` —
        there is no second execution path.

        **The response is the only time the URL is shown.** It carries the key, and a key in a URL
        leaks: into a screenshot of the alert dialog, into a support ticket, into a shared strategy.
        A second secret must therefore appear inside the message as its first field, so seeing the
        URL is not enough to trade the account. That one can be read back later; the URL cannot.
      x-scope: trade
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountIds, name]
              properties:
                accountIds:
                  type: array
                  items: { type: string, format: uuid }
                  maxItems: 20
                  description: 'The accounts this hook trades. One alert reaches all of them, and not one is a copy of another: each is sized from ITS OWN balance, addressed by ITS OWN broker''s spelling of the instrument, and judged against its own book. A failure on one does not hold up the others. `accountId` is accepted for a single account.'
                accountId: { type: string, format: uuid, description: 'Shorthand for a one-account hook.' }
                name: { type: string, maxLength: 120 }
                defaults: { $ref: '#/components/schemas/HookDefaults' }
                manage: { $ref: '#/components/schemas/HookManage' }
                policy: { $ref: '#/components/schemas/HookPolicy' }
                guards: { $ref: '#/components/schemas/HookGuards' }
                symbolMap:
                  type: object
                  additionalProperties: { type: string }
                  description: 'Chart spelling to this broker''s spelling, e.g. {"NAS100":"USTECm"}. Usually unnecessary: the chart''s name is matched against the instrument each of the broker''s listings IS, so EURUSD finds EURUSDm on its own. Set one where two listings are the same instrument at different contract sizes, and the gateway refuses rather than choosing.'
                source:
                  description: 'Where the alerts come from. Left out, a webhook: the URL below is the source. `{"kind":"telegram","channelId":"…"}` instead has the gateway read that channel through the Telegram this session has connected — the hook still has its key and secret, and the same admission, worker and alert log; the URL is simply not how its alerts arrive.'
                  oneOf:
                    - { type: object, required: [kind], properties: { kind: { type: string, enum: [webhook] } } }
                    - { type: object, required: [kind, channelId], properties: { kind: { type: string, enum: [telegram] }, channelId: { type: string }, channelTitle: { type: string }, channelUsername: { type: string } } }
                style: { $ref: '#/components/schemas/HookStyle' }
                reading: { $ref: '#/components/schemas/HookReading' }
      responses:
        '201':
          description: Created. `key` and `secret` are shown here and `key` is never shown again.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HookCreated' }
  /hooks/{hookId}:
    parameters: [{ in: path, name: hookId, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Signals]
      summary: Get a hook
      x-scope: read
      parameters: [{ in: query, name: reveal, schema: { type: boolean }, description: 'true also returns the message secret and writes hook.secret.revealed to the audit log. Requires trade scope and access to every target account.' }]
      responses:
        '200':
          description: Hook
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Hook' }
    patch:
      tags: [Signals]
      summary: Rename, pause, or change behaviour
      description: 'Naming `policy` or `guards` REPLACES that object, so a field left out returns to its default rather than lingering invisibly.'
      x-scope: trade
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 120 }
                paused: { type: boolean, description: 'Paused logs every alert and sends nothing.' }
                policy: { $ref: '#/components/schemas/HookPolicy' }
                guards: { $ref: '#/components/schemas/HookGuards' }
                symbolMap: { type: object, additionalProperties: { type: string } }
                defaults: { $ref: '#/components/schemas/HookDefaults' }
                manage: { $ref: '#/components/schemas/HookManage' }
                accountIds: { type: array, items: { type: string, format: uuid }, description: 'Replaces the whole set.' }
                style: { $ref: '#/components/schemas/HookStyle' }
                reading: { $ref: '#/components/schemas/HookReading' }
                source:
                  description: 'A webhook can become a Telegram source, and a Telegram source can be pointed at another channel. `{"kind":"telegram","channelId":"…"}`; the Telegram is this session''s, never named here.'
                  oneOf:
                    - { type: object, required: [kind], properties: { kind: { type: string, enum: [webhook] } } }
                    - { type: object, required: [kind, channelId], properties: { kind: { type: string, enum: [telegram] }, channelId: { type: string }, channelTitle: { type: string }, channelUsername: { type: string } } }
      responses:
        '200':
          description: Hook
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Hook' }
    delete:
      tags: [Signals]
      summary: Revoke a hook
      description: 'Its URL stops answering at once. Open positions are untouched and the alert log is kept — it is the record of what traded the account.'
      x-scope: trade
      responses:
        '204': { description: Revoked }
  /hooks/{hookId}/alerts:
    get:
      tags: [Signals]
      summary: Every alert this hook received, acted on or not
      x-scope: read
      parameters:
        - { in: path, name: hookId, required: true, schema: { type: string, format: uuid } }
        - { $ref: '#/components/parameters/limit' }
        - { in: query, name: before, schema: { type: string, format: date-time } }
      responses:
        '200':
          description: Alerts, newest first
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/HookAlert' } }
  /hooks/{hookId}/messages:
    get:
      tags: [Telegram]
      summary: What the listener made of this channel's messages
      description: |
        Every message this connection received, newest first: the text, the reading, and what the reading
        came to — traded, held, refused or read as noise, each with the reason by name. It is the record for
        a Telegram source the way `GET /hooks/{hookId}/alerts` is the record for a webhook, and a message
        that traded appears in BOTH, because the lines it compiled to went down the one pipeline.

        A hook whose `source.kind` is `webhook` has no messages and answers an empty list.
      x-scope: read
      parameters:
        - { in: path, name: hookId, required: true, schema: { type: string, format: uuid } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        '200':
          description: Messages, newest first
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/SourceMessage' } }
        '404': { description: No such hook }
        '503': { description: This gateway does not keep channel messages (TELEGRAM_SOURCES_UNAVAILABLE) }
  /hooks/{hookId}/templates:
    get:
      tags: [Signals]
      summary: A ready-to-paste message for every command
      description: Contains the saved webhook URL and message credential. Requires trade scope and access to every target account. Legacy connections without an encrypted URL return null until their owner replaces the credentials.
      x-scope: trade
      parameters: [{ in: path, name: hookId, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: Templates
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: [string, 'null'] }
                  note: { type: string }
                  tradingViewSourceIps: { type: array, items: { type: string } }
                  linkContext: { type: string, description: 'Current configuration fingerprint for preparing an action-restricted alert URL.' }
                  commands:
                    type: array
                    items:
                      type: object
                      properties: { command: { type: string }, message: { type: string } }
  /hooks/{hookId}/preview:
    post:
      tags: [Signals]
      summary: Preview an alert without sending orders
      description: Parses the message, resolves each account’s instrument and plans against current positions. No alert admission, broker command or delivery evidence is created. This does not prove future execution or account risk-gate acceptance. Quote-dependent plans require a fresh native terminal read. Replace TradingView placeholders with example values before previewing.
      x-scope: trade
      parameters: [{ in: path, name: hookId, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message]
              properties: { message: { type: string, maxLength: 2048 } }
      responses:
        '200':
          description: Preview or validation explanation; no orders sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  message: { type: string }
                  reason: { type: string }
                  paused: { type: boolean }
                  accounts: { type: array, items: { type: object, additionalProperties: true } }
  /hooks/{hookId}/test:
    post:
      tags: [Signals]
      summary: Check the connection without placing an order
      description: |
        Sends a ping through the signal parser and guards. Only ping is accepted; a trade message
        returns BAD_REQUEST. This internal check does not verify the public URL, TradingView
        delivery, account connectivity, or broker execution. Verify those separately.
      x-scope: trade
      parameters: [{ in: path, name: hookId, required: true, schema: { type: string, format: uuid } }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties: { message: { type: string, maxLength: 2048 } }
      responses:
        '200':
          description: The verdict, as the URL would have answered it
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HookRun' }
  /hooks/{hookId}/alert-links:
    parameters: [{ in: path, name: hookId, required: true, schema: { type: string, format: uuid } }]
    get:
      tags: [Signals]
      summary: List active prepared alert URLs without revealing credentials
      x-scope: trade
      responses:
        '200':
          description: Prepared alerts matching the current connection configuration
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/AlertLink' }
    post:
      tags: [Signals]
      summary: Prepare a message and an action-restricted private URL
      x-scope: trade
      description: |
        Opt-in bearer URL authentication. The readable, compact and simple formats allow only their prepared
        instructions. They cannot authorize changed actions, size, protection or a different fixed
        instrument. A chart-instrument template permits any instrument allowed by the connection.
        The command is validated using current defaults. Only {{ticker}} may vary in the
        instrument position; price fields (price, slprice, tpprice) may carry a TradingView
        chart value — {{close}}, {{open}}, {{high}}, {{low}} or {{plot("Title")}} — which
        TradingView expands to a number when the alert fires. A NaN value is refused.
        Set format to reusable and acceptReusableUrlAuthorization to true for one private webhook
        accepting different supported instructions on this connection. The supplied message is validated;
        responses contain that message plus a plain-language summary of the request, not an execution result.
        Preparing a different instruction recovers the same reusable URL while configuration is unchanged.
        It uses a separate scope: existing action-restricted URLs never gain this authorization.
        Messages may be edited directly in TradingView, retaining the Sent at: {{timenow}} line.
        Scripts may instead supply a unique nonce. Missing delivery identity is refused for trades.
        Replays use canonical intent plus event identity and remain blocked after gateway restarts,
        while the alert record is retained. Two identical instructions in the same TradingView timestamp
        represent one event; scripts needing separate events must supply distinct identifiers.
        The default readable message includes an Event placeholder for TradingView time.
        Set format to simple for a command line followed by Sent at: {{timenow}} on a second line.
        Its /tv/ URL contains a 192-bit private token without an embedded database ID.
        The delivery stamp is required and preserves durable duplicate detection.
        The older compact format remains one comma-separated line ending in nonce={{timenow}},
        with no connection ID to type. Its shorter /tv/ URL is an alias for the same receiver;
        it preserves authentication, revocation, rate limits and duplicate detection.
        Copy that exact message and its paired URL. Repeated preparation of the same command, format and
        configuration recovers the same pair. At most 100 active pairs per connection configuration.
        Account, policy, guard, default, management or credential changes revoke pairs.
        Updating an instrument mapping does not revoke pairs.
        Pausing remains reversible. Revocation cannot undo broker commands already submitted.
        Existing two-part credentials and script messages remain supported separately.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [message, acceptUrlAuthorization, expectedConfig]
              properties:
                message: { type: string, maxLength: 1800, example: 'buy,EURUSD,lots=0.01,sl=20' }
                format: { type: string, enum: [readable, compact, simple, reusable], default: readable }
                acceptUrlAuthorization: { type: boolean, const: true }
                acceptReusableUrlAuthorization: { type: boolean, const: true, description: 'Required when format is reusable. Acknowledges that the private URL authorizes different supported instructions within connection settings.' }
                expectedConfig: { type: string, description: 'linkContext from the authorized templates response. Stale snapshots are refused.', pattern: '^[0-9a-f]{64}$' }
      responses:
        '201':
          description: Matched message and credential-bearing URL; store privately
          content:
            application/json:
              schema:
                allOf:
                  - { $ref: '#/components/schemas/AlertLink' }
                  - type: object
                    required: [message, url]
                    properties:
                      message: { type: string }
                      url: { type: string }
                      reusable: { type: boolean, description: 'True for an explicitly authorized reusable scope.' }
                      summary: { type: array, items: { type: string }, description: 'Readable interpretation of the requested instruction and defaults. Does not confirm broker execution.' }
        '400': { description: Invalid command or missing URL authorization acknowledgement }
        '409': { description: Configuration changed or active URL limit reached }
  /hooks/{hookId}/alert-links/{linkId}:
    delete:
      tags: [Signals]
      summary: Revoke one prepared alert URL
      x-scope: trade
      parameters:
        - { in: path, name: hookId, required: true, schema: { type: string, format: uuid } }
        - { in: path, name: linkId, required: true, schema: { type: string, format: uuid } }
      responses:
        '204': { description: New deliveries are refused; other URLs and open positions are unchanged }
  /firm/public:
    get:
      tags: [Firm]
      summary: A firm's public branding, by slug, before sign-in
      parameters: [{ in: query, name: slug, required: true, schema: { type: string } }]
      responses:
        '200': { description: Name, branding and status, content: { application/json: { schema: { type: object, properties: { appearance: { $ref: '#/components/schemas/FirmAppearance' }, slug: { type: string }, name: { type: string }, branding: { $ref: '#/components/schemas/FirmBranding' }, status: { type: string, enum: [active, suspended] } } } } } }
        '404': { description: No such firm }
  /firm:
    get:
      tags: [Firm]
      summary: The firm workspace this session belongs to
      description: 'A firm is a tenant of kind firm, selected by the x-mirro-tenant header. Returns the package, branding, roles and what the caller may do.'
      responses:
        '200': { description: The firm, content: { application/json: { schema: { $ref: '#/components/schemas/Firm' } } } }
        '404': { description: This tenant is not a firm }
    patch:
      tags: [Firm]
      summary: Change the firm's name or branding (owner)
      x-scope: trade
      requestBody: { content: { application/json: { schema: { type: object, properties: { name: { type: string, maxLength: 80 }, branding: { $ref: '#/components/schemas/FirmBranding' } } } } } }
      responses:
        '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/Firm' } } } }
        '403': { description: Not an owner }
  /firm/overview:
    get:
      tags: [Firm]
      summary: The control room's numbers over every trader (staff)
      responses:
        '200': { description: Counts for seats, connections, accounts reporting, the last 24 hours and what needs a person }
        '403': { description: Not firm staff }
  /firm/alerts:
    get:
      tags: [Firm]
      summary: Every trader's alerts, newest first, with who sent them (staff)
      parameters:
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }
        - { in: query, name: verdict, schema: { type: string, enum: [acted, refused, ignored, processing] } }
      responses:
        '200': { description: Alerts with hook and trader, secrets redacted }
  /firm/members:
    get:
      tags: [Firm]
      summary: Members, seats and open invitations (staff)
      responses:
        '200': { description: Members with role, status, label, connections and last alert; seats used against the limit }
  /firm/members/invite:
    post:
      tags: [Firm]
      summary: Invite a trader or staff member by e-mail
      description: A one-use link valid 7 days, e-mailed when the gateway can send mail and always returned so it can be handed over directly. Inviting a trader needs a free seat.
      x-scope: trade
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string }, role: { type: string, enum: [owner, risk, ops, trader], default: trader }, label: { type: string, maxLength: 80 } } } } } }
      responses:
        '201': { description: The invitation, content: { application/json: { schema: { type: object, properties: { email: { type: string }, role: { type: string }, label: { type: [string, 'null'] }, expiresAt: { type: string, format: date-time }, mailed: { type: boolean }, url: { type: string } } } } } }
        '409': { description: Every seat is taken }
  /firm/members/{userId}:
    patch:
      tags: [Firm]
      summary: Suspend or reactivate a member, change role or label
      description: 'A suspended trader''s alerts are refused with firm_member_suspended; open positions are left as they are.'
      x-scope: trade
      parameters: [{ in: path, name: userId, required: true, schema: { type: string, format: uuid } }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { status: { type: string, enum: [active, suspended] }, role: { type: string, enum: [owner, risk, ops, trader] }, label: { type: [string, 'null'] } } } } } }
      responses:
        '200': { description: The member as it now stands }
  /firm/invites/{token}:
    get:
      tags: [Firm]
      summary: What an invitation is, before sign-in
      parameters: [{ in: path, name: token, required: true, schema: { type: string } }]
      responses:
        '200': { description: Firm, invited address, role and expiry }
        '404': { description: Unknown, used or expired }
  /firm/invites/accept:
    post:
      tags: [Firm]
      summary: Join the firm with an invitation (signed in as the invited address)
      x-scope: trade
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [token], properties: { token: { type: string } } } } } }
      responses:
        '200': { description: Joined; the role and the firm }
        '403': { description: Signed in as a different address, or a different workspace }
  /firm/rules:
    get:
      tags: [Firm]
      summary: The firm's ceilings over every trader's connections (staff)
      responses:
        '200': { description: Rules, content: { application/json: { schema: { $ref: '#/components/schemas/FirmRules' } } } }
    put:
      tags: [Firm]
      summary: Replace the firm's ceilings (owner or risk desk)
      description: 'Ceilings, not defaults. A trader''s own setting still applies; the stricter wins. Enforced in the signal path on the next alert; refusals name the firm (firm_requires_stop_loss, symbol_not_allowed, outside_the_hook_schedule with details.setBy firm).'
      x-scope: trade
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/FirmRules' } } } }
      responses:
        '200': { description: Saved, content: { application/json: { schema: { $ref: '#/components/schemas/FirmRules' } } } }
  /firm/programs:
    get:
      tags: [Firm]
      summary: The firm's challenge programs
      parameters: [{ in: query, name: all, schema: { type: string, enum: ['1'] }, description: 'Staff only — include draft and archived programs.' }]
      responses:
        '200': { description: Programs, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/FirmProgram' } } } } }
    post:
      tags: [Firm]
      summary: Create a program (owner or risk desk)
      description: 'What the firm sells: starting balance, drawdown rule (balance or equity; static or trailing), optional daily loss, stages with targets and minimum trading days, profit split. A running challenge keeps the rules it started with. Use status draft for an unpublished offer; omitted status remains active for API compatibility. Drafts cannot be purchased or enrolled.'
      x-scope: trade
      requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProgramInput' } } } }
      responses:
        '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/FirmProgram' } } } }
        '400': { description: 'A rule does not validate; details.field names it' }
  /firm/programs/templates:
    get:
      tags: [Firm]
      summary: Program templates, per currency, ready to save
      description: 'Naira firms (NairaProp''s ladder: one stage, 10% target, 20% drawdown, no daily limit, the 3-minute rule, 80% split) and international USD models (two-step, one-step trailing, end-of-day trailing with consistency, instant funding). Sizes and typical prices per template.'
      responses:
        '200': { description: Templates, content: { application/json: { schema: { type: array, items: { type: object, properties: { id: { type: string }, currency: { type: string }, region: { type: string }, label: { type: string }, sizes: { type: array, items: { type: number } }, prices: { type: object, additionalProperties: { type: number } }, rules: { $ref: '#/components/schemas/ProgramInput' }, notes: { type: string } } } } } } }
  /firm/programs/{programId}:
    patch:
      tags: [Firm]
      summary: Edit or archive a program (owner or risk desk)
      x-scope: trade
      parameters: [{ in: path, name: programId, required: true, schema: { type: string, format: uuid } }]
      requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/ProgramInput' } } } }
      responses:
        '200': { description: Updated, content: { application/json: { schema: { $ref: '#/components/schemas/FirmProgram' } } } }
  /firm/challenges:
    get:
      tags: [Firm]
      summary: Live challenges with each account's live figures, most at risk first (staff)
      parameters: [{ in: query, name: all, schema: { type: string, enum: ['1'] }, description: 'Include ended challenges.' }, { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 1000, default: 500 } }]
      responses:
        '200': { description: Challenges, content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Challenge' } } } } }
    post:
      tags: [Firm]
      summary: Start a challenge for a seated trader on the firm's broker account (owner or operations)
      description: 'Either an existing account of the trader (accountId) or a new MetaTrader login the platform hosts from now (platform, login, password, server). Rules are frozen from the program onto the challenge. Evaluation begins with the account''s first report.'
      x-scope: trade
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [userId, programId], properties: { userId: { type: string, format: uuid }, programId: { type: string, format: uuid }, reference: { type: string, maxLength: 80 }, accountId: { type: string, format: uuid }, platform: { type: string, enum: [mt4, mt5] }, login: { type: string }, password: { type: string }, server: { type: string }, environment: { type: string, enum: [demo, live], default: live }, accountLabel: { type: string }, timezone: { type: string, description: 'For the daily loss day boundary; default UTC.' } } } } } }
      responses:
        '201': { description: Started, content: { application/json: { schema: { $ref: '#/components/schemas/Challenge' } } } }
        '409': { description: The account already has a live challenge, or the trader is suspended }
        '422': { description: 'FIRM_TRADER_REQUIRED (not a seated trader) or PROGRAM_INVALID' }
  /firm/challenges/import:
    post:
      tags: [Firm]
      summary: Start challenges in bulk from a CSV (owner or operations)
      description: 'Body is text/csv or text/plain: header `email,program,platform,login,password,server[,reference][,label]`, up to 500 rows, 512 KB. A seated trader''s row starts the challenge and connects the account; a stranger''s row invites them as a trader (run the import again once they join); a bad row is refused. Every row reports started, invited or refused with the reason.'
      x-scope: trade
      requestBody: { required: true, content: { text/plain: { schema: { type: string } }, text/csv: { schema: { type: string } } } }
      responses:
        '200': { description: Per-row results, content: { application/json: { schema: { type: object, properties: { rows: { type: integer }, started: { type: integer }, invited: { type: integer }, refused: { type: integer }, results: { type: array, items: { type: object, properties: { line: { type: integer }, email: { type: string }, login: { type: string }, status: { type: string, enum: [started, invited, refused] }, message: { type: string }, url: { type: string }, challengeId: { type: string } } } } } } } } }
  /firm/offer:
    get:
      tags: [Firm]
      summary: The public prop-firm offer — modes, packages priced for a country, account currencies, templates
      security: []
      parameters: [{ in: query, name: country, schema: { type: string, minLength: 2, maxLength: 2 }, description: 'ISO-3166 alpha-2. NG is quoted in naira; everyone else in US dollars. Defaults to the caller''s country header.' }]
      responses:
        '200': { description: The offer, content: { application/json: { schema: { type: object, properties: { country: { type: [string, 'null'] }, billingCurrency: { type: string, enum: [USD, NGN] }, modes: { type: array, items: { type: object, properties: { id: { type: string, enum: [full, engine] }, label: { type: string }, blurb: { type: string } } } }, packages: { type: array, items: { type: object, properties: { id: { type: string }, label: { type: string }, seats: { type: integer }, monthly: { type: number }, currency: { type: string }, features: { type: array, items: { type: object, properties: { id: { type: string }, label: { type: string } } } } } } }, programCurrencies: { type: array, items: { type: string } }, templates: { type: array, items: { type: object } } } } } } }
  /firm/apply:
    post:
      tags: [Firm]
      summary: Apply to onboard a firm (public)
      description: 'From the B2B page. Stored for MirroAPI''s operators, who reply within a business day; the applicant receives a receipt. Rate limited per address.'
      security: []
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [firmName, contactName, email, country, mode, package, consent], properties: { firmName: { type: string, maxLength: 80 }, contactName: { type: string, maxLength: 80 }, email: { type: string, format: email }, phone: { type: string }, country: { type: string, minLength: 2, maxLength: 2 }, website: { type: string }, mode: { type: string, enum: [full, engine] }, package: { type: string, enum: [starter, growth, enterprise] }, expectedTraders: { type: integer }, currencies: { type: array, items: { type: string } }, platforms: { type: array, items: { type: string, enum: [mt4, mt5] } }, message: { type: string, maxLength: 2000 }, consent: { type: boolean } } } } } }
      responses:
        '201': { description: Received, content: { application/json: { schema: { type: object, properties: { ok: { type: boolean }, id: { type: [string, 'null'] }, message: { type: string } } } } } }
  /ops/overview:
    get:
      tags: [Ops]
      summary: 'The operators'' desk — counts (MirroAPI staff only)'
      responses:
        '200': { description: Counts }
        '403': { description: Not an operator }
  /ops/applications:
    get:
      tags: [Ops]
      summary: Firm applications (MirroAPI staff only)
      parameters: [{ in: query, name: status, schema: { type: string, enum: [new, contacted, approved, rejected] } }]
      responses:
        '200': { description: Applications }
  /ops/applications/{id}:
    patch:
      tags: [Ops]
      summary: Note or re-stage an application (MirroAPI staff only)
      x-scope: trade
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody: { content: { application/json: { schema: { type: object, properties: { status: { type: string, enum: [new, contacted, rejected] }, notes: { type: string } } } } } }
      responses:
        '200': { description: The application }
  /ops/applications/{id}/approve:
    post:
      tags: [Ops]
      summary: 'Approve — create the firm workspace and owner invitation atomically'
      description: 'Operator session required. Application approval, tenant, profile, registry, invitation and selected product commit together. Concurrent approval and stale review updates cannot create a second workspace for the same application. Approval does not activate customer checkout or attach a dedicated database.'
      x-scope: trade
      parameters: [{ in: path, name: id, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [slug], properties: { slug: { type: string, pattern: '^[a-z0-9][a-z0-9-]{1,40}$' }, name: { type: string }, package: { type: string, enum: [starter, growth, enterprise] }, seats: { type: integer }, mode: { type: string, enum: [full, engine] }, ownerEmail: { type: string, format: email }, product: { type: object, description: 'Optional product settings matching PUT /ops/firms/{tenantId}/product with revision zero and a reason — template must agree with mode; saved atomically with the workspace' }, notes: { type: string } } } } } }
      responses:
        '201': { description: 'The workspace is created; the owner invitation URL is returned once — service launch verification remains separate', content: { application/json: { schema: { type: object, properties: { slug: { type: string }, tenantId: { type: string }, ownerEmail: { type: string }, inviteUrl: { type: string }, mailed: { type: boolean }, provisioning: { type: object, properties: { state: { const: workspace_created }, customerLaunch: { const: setup_required }, deployment: { enum: [managed, dedicated] } } } } } } } }
        '409': { description: Slug taken or already approved }
  /ops/firms:
    get:
      tags: [Ops]
      summary: Every firm with its package, mode, status, sender and usage (MirroAPI staff only)
      responses:
        '200': { description: Firms }
  /ops/firms/{tenantId}:
    patch:
      tags: [Ops]
      summary: 'Change a firm''s package, seats, mode, status, sender verification or notes (MirroAPI staff only)'
      x-scope: trade
      parameters: [{ in: path, name: tenantId, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { package: { type: string }, seats: { type: integer }, status: { type: string, enum: [active, suspended] }, mode: { type: string, enum: [full, engine] }, senderVerified: { type: boolean }, opsNotes: { type: string }, billingCurrency: { type: string, enum: [USD, NGN] } } } } } }
      responses:
        '200': { description: The firm }
  /ops/firms/{tenantId}/invite:
    post:
      tags: [Ops]
      summary: Mint a fresh owner or staff invitation for a firm (MirroAPI staff only)
      x-scope: trade
      parameters: [{ in: path, name: tenantId, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [email], properties: { email: { type: string, format: email }, role: { type: string, enum: [owner, risk, ops, trader] } } } } } }
      responses:
        '201': { description: The invitation URL, once }
  /firm/mail-sender:
    get:
      tags: [Firm]
      summary: 'The firm''s own sender address for letters to its traders (owner)'
      responses:
        '200': { description: Sender and whether it is verified and in use, content: { application/json: { schema: { $ref: '#/components/schemas/MailSender' } } } }
    put:
      tags: [Firm]
      summary: Set the sender address (owner); MirroAPI verifies it at the carrier before use
      x-scope: trade
      requestBody: { required: true, content: { application/json: { schema: { type: object, properties: { sender: { type: [string, 'null'] } } } } } }
      responses:
        '200': { description: Saved; unverified until DNS records resolve, content: { application/json: { schema: { $ref: '#/components/schemas/MailSender' } } } }
  /firm/challenges/{challengeId}:
    get:
      tags: [Firm]
      summary: One challenge with its record (staff, or the trader it belongs to)
      parameters: [{ in: path, name: challengeId, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200': { description: The challenge and its events, content: { application/json: { schema: { $ref: '#/components/schemas/Challenge' } } } }
  /firm/challenges/{challengeId}/override:
    post:
      tags: [Firm]
      summary: A person's decision over the record (owner or risk desk)
      description: 'pass_phase, fail, close or reinstate, with a reason of 5–500 characters. Pool-managed challenges refuse pass_phase and reinstate; use verified stage delivery instead. Written as an override event under the caller''s name; the automatic evaluation continues on the same rules afterwards.'
      x-scope: trade
      parameters: [{ in: path, name: challengeId, required: true, schema: { type: string, format: uuid } }]
      requestBody: { required: true, content: { application/json: { schema: { type: object, required: [action, reason], properties: { action: { type: string, enum: [pass_phase, fail, close, reinstate] }, reason: { type: string, minLength: 5, maxLength: 500 } } } } } }
      responses:
        '200': { description: The challenge as it now stands, content: { application/json: { schema: { $ref: '#/components/schemas/Challenge' } } } }
        '409': { description: The action does not fit the challenge's state }
  /me/challenges:
    get:
      tags: [Firm]
      summary: The signed-in trader's own challenges, live, in the firm's words
      responses:
        '200': { description: 'Challenges (empty outside a firm); no staff-only fields', content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/Challenge' } } } } }
  /notifications/settings:
    get:
      tags: [Signals]
      summary: How the owner is notified
      description: E-mail (on by default, to the sign-in address) and Telegram (linked through the bot), and which events send a message. Repeats of one refusal are grouped for 15 minutes; more than 30 notifications in an hour become one summary.
      responses:
        '200': { description: Current settings, content: { application/json: { schema: { $ref: '#/components/schemas/NotificationSettings' } } } }
    patch:
      tags: [Signals]
      summary: Change notification settings
      x-scope: trade
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                emailEnabled: { type: boolean }
                email: { type: [string, 'null'], description: 'An address, or null to use the sign-in address.' }
                events: { type: object, additionalProperties: { type: boolean }, description: 'kind → on/off; kinds not named keep their value.' }
      responses:
        '200': { description: Updated settings, content: { application/json: { schema: { $ref: '#/components/schemas/NotificationSettings' } } } }
        '400': { description: Invalid address or unknown kind }
  /notifications/telegram/link:
    post:
      tags: [Signals]
      summary: Start linking a Telegram chat
      description: Returns a one-use code (15 minutes) and a t.me link. Sending the code to the bot — or pressing Start on that link — binds the chat. The database keeps only the code's hash.
      x-scope: trade
      responses:
        '200': { description: The code to send, content: { application/json: { schema: { type: object, properties: { code: { type: string }, botUsername: { type: string }, url: { type: string }, expiresInSec: { type: integer }, instructions: { type: string } } } } } }
        '501': { description: Telegram is not enabled on this gateway }
        '503': { description: The Telegram Bot API did not answer (TELEGRAM_UNAVAILABLE) }
  /notifications/telegram:
    delete:
      tags: [Signals]
      summary: Unlink the Telegram chat
      x-scope: trade
      responses:
        '204': { description: Unlinked; e-mail is unaffected }
  /notifications/test:
    post:
      tags: [Signals]
      summary: Send a real test message down each configured channel
      x-scope: trade
      responses:
        '200': { description: Per-channel result, content: { application/json: { schema: { type: object, properties: { email: { type: object, properties: { attempted: { type: boolean }, ok: { type: [boolean, 'null'] }, to: { type: [string, 'null'] }, error: { type: [string, 'null'] } } }, telegram: { type: object, properties: { attempted: { type: boolean }, ok: { type: [boolean, 'null'] }, error: { type: [string, 'null'] } } } } } } } }
  /notifications:
    get:
      tags: [Signals]
      summary: Recent notifications with each channel's delivery state
      description: What was sent and, the point, what could not be — each channel carries pending, sent, failed or off, with attempts and the carrier's last error.
      parameters:
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        '200':
          description: Newest first
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string }
                    kind: { type: string, enum: [trade_filled, trade_refused, order_rejected, unprotected_position, needs_review, trade_care_problem, test_arrived, capped] }
                    subject: { type: string }
                    body: { type: string }
                    hookId: { type: [string, 'null'] }
                    alertId: { type: [string, 'null'] }
                    createdAt: { type: string, format: date-time }
                    email: { type: object, properties: { state: { type: string, enum: [pending, sent, failed, off] }, to: { type: [string, 'null'] }, attempts: { type: integer }, error: { type: [string, 'null'] }, sentAt: { type: [string, 'null'], format: date-time } } }
                    telegram: { type: object, properties: { state: { type: string, enum: [pending, sent, failed, off] }, attempts: { type: integer }, error: { type: [string, 'null'] }, sentAt: { type: [string, 'null'], format: date-time } } }
  /hooks/{hookId}/alerts/{alertId}/resolve:
    post:
      tags: [Signals]
      summary: Mark a held alert reviewed so later alerts resume
      description: |
        An alert whose execution stopped in review (partly submitted, then the connection changed or the
        deadline passed) holds every later alert on the hook: nothing new is sent on top of an unknown
        outcome. The owner, having checked the broker results for each account, records that decision
        here. Nothing is re-sent for the held alert and the broker is not touched. The alert's row becomes
        `acted` (commands were submitted) or `refused` with reason `reviewed`; `detail.heldReason` keeps
        why it was held.
      x-scope: trade
      parameters:
        - { in: path, name: hookId, required: true, schema: { type: string, format: uuid } }
        - { in: path, name: alertId, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [brokerResultsChecked]
              properties:
                brokerResultsChecked: { type: boolean, description: Must be true. Confirms the broker results were inspected. }
                note: { type: string, maxLength: 280 }
      responses:
        '200':
          description: Later alerts on this hook are due again
          content: { application/json: { schema: { type: object, properties: { id: { type: string }, resolved: { type: boolean } } } } }
        '400': { description: brokerResultsChecked was not true }
        '404': { description: Hook or alert not found }
        '409': { description: The alert is not waiting for review }
  /hooks/{hookId}/rotate:
    post:
      tags: [Signals]
      summary: Replace the webhook URL and message secret
      description: Invalidates both credentials while retaining the hook id, settings, ownership and alert history. Update every sender. Use a persisted Idempotency-Key to recover a lost response.
      x-scope: trade
      parameters: [{ in: path, name: hookId, required: true, schema: { type: string, format: uuid } }]
      responses:
        '200':
          description: New credentials, also available through authorized templates and reveal reads
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HookCreated' }
  /ctrader/authorize:
    post:
      tags: [cTrader]
      summary: Start a cTrader sign-in
      description: |
        Session-only. Returns the cTrader URL to send the trader to, and a one-use `state`.

        **The console owns the redirect.** cTrader sends the browser back to the redirect registered
        on the application — a console page — which hands `state` and `code` to
        `POST /ctrader/callback` on the same signed-in session. There is no unauthenticated callback,
        which is what closes the OAuth account-linking attack: a stolen `state` cannot be spent by
        anybody else, because the flow names the person who started it.

        `approved` must be `true`. cTrader's own consent screen is cTrader's; this is MirroAPI's, and
        what was approved is written down with its timestamp.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role, approved]
              properties:
                role: { type: string, enum: [master, follower, both], description: 'What the account will do: be copied, receive copies, or both.' }
                approved: { type: boolean, enum: [true], description: 'An explicit approval, recorded on the flow. Anything but true is refused NO_CONSENT.' }
                label: { type: string, maxLength: 120 }
                product: { type: string, enum: [hq, api], description: First-party workspace returning from the registered callback. }
      responses:
        '200':
          description: Where to send the trader
          content:
            application/json:
              schema:
                type: object
                required: [authorizationUrl, state, expiresInSec]
                properties:
                  authorizationUrl: { type: string, format: uri, description: 'On id.ctrader.com. Send the browser here.' }
                  state: { type: string, description: 'Keep it; hand it back on the callback. Only its hash is stored.' }
                  expiresInSec: { type: integer, description: 'How long the sign-in is held (600).' }
                  redirectUri: { type: string, format: uri, description: 'The redirect registered on the application, which cTrader will use.' }
        '422': { description: 'NO_CONSENT — approved was not true' }
        '503': { description: This gateway has no cTrader application configured (CTRADER_UNAVAILABLE) }
  /ctrader/callback:
    post:
      tags: [cTrader]
      summary: Finish a cTrader sign-in
      description: |
        Session-only, and it must be the session that started the flow. Exchanges the code, checks the
        authorisation can actually trade, and seals the tokens under the tenant's key.

        A reading-only authorisation is refused HERE rather than at the first order, which is the
        difference between a clear answer now and a puzzle later.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state, code]
              properties:
                state: { type: string }
                code: { type: string, description: 'What cTrader put on the redirect.' }
      responses:
        '200':
          description: Authorised
          content:
            application/json:
              schema:
                type: object
                required: [grantId, accounts]
                properties:
                  grantId: { type: string, format: uuid }
                  role: { type: string, enum: [master, follower, both] }
                  accounts:
                    type: array
                    description: 'The cTrader accounts this authorisation covers. Connect one with POST /ctrader/connect.'
                    items: { type: object, properties: { id: { type: string }, environment: { type: string, enum: [demo, live] }, traderLogin: { type: [integer, 'null'] }, brokerName: { type: [string, 'null'] } } }
        '400': { description: 'CTRADER_AUTHORIZATION_FAILED — cTrader would not exchange the code' }
        '403': { description: 'CTRADER_TRADING_SCOPE_REQUIRED — reading only' }
        '404': { description: 'CTRADER_FLOW_EXPIRED — expired, already finished, or another session''s' }
        '503': { description: 'CTRADER_UNAVAILABLE' }
  /ctrader/grants:
    get:
      tags: [cTrader]
      summary: The cTrader authorisations this sign-in holds
      security: [{ consoleSession: [] }]
      responses:
        '200':
          description: Authorisations, newest first, each with the accounts it covers and what is connected
          content:
            application/json:
              schema: { type: object, required: [items], properties: { items: { type: array, items: { type: object, additionalProperties: true } } } }
        '503': { description: 'CTRADER_UNAVAILABLE' }
  /ctrader/connect:
    post:
      tags: [cTrader]
      summary: Turn one authorised cTrader account into a MirroAPI account
      description: |
        Session-only. Connection starts read-only. Demo accounts use the instrument certification
        flow. Live accounts use `POST /ctrader/accounts/{accountId}/activate-live` after the owner
        approves real-money copying. Activation checks access without placing a trade.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grantId, ctidTraderAccountId, environment, role]
              properties:
                grantId: { type: string, format: uuid }
                ctidTraderAccountId: { type: string, description: 'The account id, as the authorisation gives it.' }
                environment: { type: string, enum: [demo, live] }
                role: { type: string, enum: [master, follower, both] }
                label: { type: string, maxLength: 120 }
      responses:
        '201':
          description: Connected
          content:
            application/json:
              schema: { type: object, properties: { accountId: { type: string, format: uuid }, existing: { type: boolean } } }
        '200': { description: Already connected; the same account is returned }
        '404': { description: 'CTRADER_GRANT_NOT_FOUND' }
        '409': { description: 'TERMINAL_ACCOUNT_ALREADY_OWNED — that cTrader account belongs to another user' }
        '422': { description: 'CTRADER_ACCOUNT_NOT_GRANTED' }
        '503': { description: 'CTRADER_UNAVAILABLE' }
  /ctrader/accounts/{accountId}/plan:
    post:
      tags: [cTrader]
      summary: Review a demo execution check
      description: Signed-in sessions only. Ownership is checked. Live accounts cannot run demo certification; existing execution and consent gates apply. The plan step sends no trade. Certify sends the approved small demo trades and records evidence.
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                symbols: { type: array, minItems: 1, maxItems: 3, items: { type: string } }
      responses:
        '200':
          description: Completed step; plan returns plan and planHash, certify returns certification, start and resume return the account state.
          content:
            application/json:
              schema: { type: object }
        '422': { description: 'ACCOUNT_REFUSED or NO_CONSENT; details.reason identifies the failed check.' }
  /ctrader/accounts/{accountId}/certify:
    post:
      tags: [cTrader]
      summary: Run an explicitly approved demo check
      description: Signed-in sessions only. Ownership is checked. Live accounts cannot run demo certification; existing execution and consent gates apply. The plan step sends no trade. Certify sends the approved small demo trades and records evidence.
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                approved: { type: boolean, enum: [true] }
                planHash: { type: string }
      responses:
        '200':
          description: Completed step; plan returns plan and planHash, certify returns certification, start and resume return the account state.
          content:
            application/json:
              schema: { type: object }
        '422': { description: 'ACCOUNT_REFUSED or NO_CONSENT; details.reason identifies the failed check.' }
  /ctrader/accounts/{accountId}/start:
    post:
      tags: [cTrader]
      summary: Enable a checked demo account
      description: Signed-in sessions only. Ownership is checked. Live accounts cannot run demo certification; existing execution and consent gates apply. The plan step sends no trade. Certify sends the approved small demo trades and records evidence.
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: Completed step; plan returns plan and planHash, certify returns certification, start and resume return the account state.
          content:
            application/json:
              schema: { type: object }
        '422': { description: 'ACCOUNT_REFUSED or NO_CONSENT; details.reason identifies the failed check.' }
  /ctrader/accounts/{accountId}/resume:
    post:
      tags: [cTrader]
      summary: Resume an account with valid existing checks
      description: Signed-in sessions only. Ownership is checked. Live accounts cannot run demo certification; existing execution and consent gates apply. The plan step sends no trade. Certify sends the approved small demo trades and records evidence.
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string, format: uuid } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
      responses:
        '200':
          description: Completed step; plan returns plan and planHash, certify returns certification, start and resume return the account state.
          content:
            application/json:
              schema: { type: object }
        '422': { description: 'ACCOUNT_REFUSED or NO_CONSENT; details.reason identifies the failed check.' }
  /ctrader/accounts/{accountId}/activate-live:
    post:
      tags: [cTrader]
      summary: Enable live copying after owner approval and broker access checks
      description: |
        Session-only. Requires the account holder's explicit approval. Rechecks trading permission,
        hedging mode, account data, positions, orders and instrument contracts with cTrader.
        Enables supported market-position actions on that exact live account. Changed instrument
        contracts require a fresh check; pending-order execution remains unavailable.
        This endpoint sends no broker order. Owner authorization is recorded separately from
        demo lifecycle evidence and must not be presented as proof of successful trade execution.
      security: [{ consoleSession: [] }]
      parameters:
        - in: path
          name: accountId
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [approved, liveConfirmation]
              properties:
                approved: { type: boolean, const: true }
                liveConfirmation: { type: string, const: I UNDERSTAND LIVE TRADES USE REAL MONEY }
                role: { type: string, enum: [master, follower, both] }
      responses:
        '200': { description: Owner approval and broker checks recorded; account enabled for supported live copying. }
        '400': { description: Select a live account and a valid copying role. }
        '401': { description: Sign in first. }
        '404': { description: The account is not owned by this user. }
        '422': { description: Approval missing, account busy, unsupported account type or broker checks failed. }
        '503': { description: cTrader is unavailable. }
  /tradelocker/discover:
    post:
      tags: [Accounts]
      summary: Find the account holder’s TradeLocker accounts
      description: Signed-in sessions only. Credentials are sent only to the selected TradeLocker environment. Discovery returns account choices without tokens. Connection reads the selected account and its instruments, seals credentials and creates the account. It sends no trade. Both environments can copy after checks. Live accounts additionally require approved=true and the exact liveConfirmation phrase. The account holder approves real-money copying; connection itself sends no trade.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, server, environment]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password, writeOnly: true }
                server: { type: string, maxLength: 200 }
                environment: { type: string, enum: [demo, live] }
      responses:
        '200':
          description: Account choices (accounts) and environment; no token or credential is returned.
          content:
            application/json:
              schema: { type: object }
        '422': { description: 'ACCOUNT_REFUSED or NO_CONSENT. Check the credentials or consent before retrying.' }
        '503': { description: TradeLocker connection is disabled on this gateway. }
  /tradelocker/connect:
    post:
      tags: [Accounts]
      summary: Verify and connect a TradeLocker account
      description: Signed-in sessions only. Credentials are sent only to the selected TradeLocker environment. Discovery returns account choices without tokens. Connection reads the selected account and its instruments, seals credentials and creates the account. It sends no trade. Both environments can copy after checks. Live accounts additionally require approved=true and the exact liveConfirmation phrase. The account holder approves real-money copying; connection itself sends no trade.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password, server, environment, accountId, role, approved]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password, writeOnly: true }
                server: { type: string, maxLength: 200 }
                environment: { type: string, enum: [demo, live] }
                accountId: { type: string }
                role: { type: string, enum: [master, follower, both] }
                approved: { type: boolean, enum: [true] }
                label: { type: string, maxLength: 120 }
                liveConfirmation: { type: string, description: 'For live accounts: I UNDERSTAND LIVE TRADES USE REAL MONEY' }
      responses:
        '200':
          description: accountId, environment, state (connecting) and liveEnabled.
          content:
            application/json:
              schema: { type: object }
        '422': { description: 'ACCOUNT_REFUSED or NO_CONSENT. Check the credentials or consent before retrying.' }
        '503': { description: TradeLocker connection is disabled on this gateway. }
  /telegram:
    get:
      tags: [Telegram]
      summary: The Telegram connected to this sign-in, if any
      description: |
        Session-only. `available` is false when this gateway has no Telegram application id and hash: the
        console then offers the source but does not let a sign-in start that cannot finish. `account` is null
        until one is connected.

        This is the ONE route under this tag that answers on a gateway without those credentials — the rest
        refuse with `503 TELEGRAM_SOURCES_UNAVAILABLE`, so a client can ask once and hide the whole feature.
      security: [{ consoleSession: [] }]
      responses:
        '200':
          description: Whether this gateway can read channels, and the Telegram connected here
          content:
            application/json:
              schema:
                type: object
                required: [available, account]
                properties:
                  available: { type: boolean, description: 'This gateway has the Telegram application credentials.' }
                  account: { anyOf: [{ $ref: '#/components/schemas/TelegramAccount' }, { type: 'null' }] }
        '401': { description: Missing or invalid console session }
        '403': { description: An API key cannot act as somebody's Telegram }
    delete:
      tags: [Telegram]
      summary: Disconnect this Telegram
      description: |
        Ends the session AT TELEGRAM (the gateway logs out, so it disappears from the person's own devices
        screen too) and forgets the sealed session. The connections reading through it are PAUSED, not
        deleted: their alert log, their settings and the channel they name are kept, so connecting the same
        Telegram again resumes them rather than rebuilding them.
      security: [{ consoleSession: [] }]
      responses:
        '200':
          description: Disconnected
          content:
            application/json:
              schema:
                type: object
                required: [ok, hooksPaused]
                properties:
                  ok: { type: boolean }
                  hooksPaused: { type: integer, description: 'How many connections stopped reading.' }
        '503': { description: Reading Telegram channels is not switched on for this gateway (TELEGRAM_SOURCES_UNAVAILABLE) }
  /telegram/login:
    post:
      tags: [Telegram]
      summary: Send the code Telegram signs in with
      description: |
        Step one of three. Telegram sends a code to the number, in the Telegram app when the person is
        signed in on another device and by SMS otherwise — `via` says which, so the console can name the
        right place to look. The attempt is held for ten minutes and then forgotten; after that
        `POST /telegram/login/code` answers `404 TELEGRAM_LOGIN_EXPIRED` and the phone is entered again.

        Starting a second attempt for the same person drops the first. Not idempotent: each call asks
        Telegram for a new code.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, description: 'The number the Telegram is on, with its country code, e.g. +2348012345678. Spaces, brackets and dashes are ignored.' }
      responses:
        '200':
          description: A code is on its way
          content:
            application/json:
              schema:
                type: object
                required: [attemptId, via, expiresInSec, phone]
                properties:
                  attemptId: { type: string, format: uuid, description: 'Name it on the next two calls.' }
                  via: { type: string, enum: [app, sms], description: 'Where Telegram sent the code.' }
                  expiresInSec: { type: integer, description: 'How long this attempt is held (600).' }
                  phone: { type: string, description: 'The number, masked, as it will be shown from now on.' }
        '400': { description: 'TELEGRAM_PHONE_INVALID, or TELEGRAM_LOGIN_FAILED when Telegram knows no account on it' }
        '429': { description: Telegram asks for a pause before another attempt on this number (TELEGRAM_LOGIN_FAILED) }
        '503': { description: 'TELEGRAM_SOURCES_UNAVAILABLE, or Telegram was not reachable (TELEGRAM_LOGIN_FAILED)' }
  /telegram/login/code:
    post:
      tags: [Telegram]
      summary: Finish the sign-in with the code
      description: |
        Step two. Answers `{ account }` when that finished it, or `{ passwordNeeded: true, attemptId }` when
        the Telegram has two-step verification — then POST the same `attemptId` to
        `/telegram/login/password`. A wrong code does not end the attempt; the same `attemptId` can be tried
        again until it expires.

        On success the session is sealed under the tenant's key, like a terminal credential, and never leaves
        the gateway in the clear.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attemptId, code]
              properties:
                attemptId: { type: string, format: uuid }
                code: { type: string, description: 'The five or six digits Telegram sent.' }
      responses:
        '200':
          description: Connected, or two-step verification is still to come
          content:
            application/json:
              schema:
                anyOf:
                  - { type: object, required: [account], properties: { account: { $ref: '#/components/schemas/TelegramAccount' } } }
                  - { type: object, required: [passwordNeeded, attemptId], properties: { passwordNeeded: { type: boolean, enum: [true] }, attemptId: { type: string, format: uuid } } }
        '400': { description: 'TELEGRAM_CODE_INVALID or TELEGRAM_CODE_EXPIRED' }
        '404': { description: This attempt is not one the gateway is holding any more (TELEGRAM_LOGIN_EXPIRED) }
        '503': { description: Reading Telegram channels is not switched on for this gateway (TELEGRAM_SOURCES_UNAVAILABLE) }
  /telegram/login/password:
    post:
      tags: [Telegram]
      summary: Finish a two-step sign-in
      description: |
        Step three, only when `POST /telegram/login/code` answered `passwordNeeded`. The password is
        Telegram's own two-step password (Telegram → Settings → Privacy and Security → Two-Step
        Verification); the gateway checks it against Telegram and stores nothing of it.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attemptId, password]
              properties:
                attemptId: { type: string, format: uuid }
                password: { type: string }
      responses:
        '200':
          description: Connected
          content:
            application/json:
              schema: { type: object, required: [account], properties: { account: { $ref: '#/components/schemas/TelegramAccount' } } }
        '400': { description: Telegram refused the password (TELEGRAM_PASSWORD_INVALID) }
        '404': { description: This attempt is not one the gateway is holding any more (TELEGRAM_LOGIN_EXPIRED) }
        '503': { description: Reading Telegram channels is not switched on for this gateway (TELEGRAM_SOURCES_UNAVAILABLE) }
  /telegram/channels:
    get:
      tags: [Telegram]
      summary: The channels and groups this Telegram can read
      description: |
        What the connected Telegram is a member of, by title. Channels it has left or that are gone are not
        listed. Choosing one is `source` on `POST /hooks`; nothing is read until a connection names it.
      security: [{ consoleSession: [] }]
      responses:
        '200':
          description: Channels, by title
          content:
            application/json:
              schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: '#/components/schemas/TelegramChannel' } } } }
        '409': { description: No Telegram is connected to this sign-in (TELEGRAM_NOT_CONNECTED) }
        '503': { description: 'TELEGRAM_SOURCES_UNAVAILABLE, or the session was ended from Telegram (TELEGRAM_NOT_CONNECTED)' }
  /telegram/rehearse:
    post:
      tags: [Telegram]
      summary: What this channel's recent messages WOULD do
      description: |
        A rehearsal, and the reason to trust the feature at all: the channel's recent messages, what this
        connection makes of each, and the exact alert lines it would have sent — **without sending
        anything**. No hook is needed, so it answers before a connection exists and again whenever the
        settings change.

        `defaults` is the size the rehearsal is written against. Left out, a message that would otherwise
        trade is held as `no_size` rather than shown as an order nobody asked for.
      security: [{ consoleSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channelId]
              properties:
                channelId: { type: string, description: 'The channel, as GET /telegram/channels gives it.' }
                limit: { type: integer, minimum: 1, maximum: 50, default: 20 }
                style: { $ref: '#/components/schemas/HookStyle' }
                reading: { $ref: '#/components/schemas/HookReading' }
                defaults: { $ref: '#/components/schemas/HookDefaults' }
      responses:
        '200':
          description: The messages, newest first, each with its reading and what it would send
          content:
            application/json:
              schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: '#/components/schemas/RehearsedMessage' } } } }
        '400': { description: 'channelId, limit, style, reading or defaults is malformed; details.field names it' }
        '404': { description: This Telegram can no longer read that channel (TELEGRAM_CHANNEL_UNAVAILABLE) }
        '409': { description: No Telegram is connected to this sign-in (TELEGRAM_NOT_CONNECTED) }
        '503': { description: 'TELEGRAM_SOURCES_UNAVAILABLE, or Telegram did not answer (TELEGRAM_LOGIN_FAILED)' }
  /alerts/{hookKey}:
    post:
      tags: [Signals]
      summary: Receive an alert (the URL you paste into TradingView)
      security: []
      description: |
        **Public, and `text/plain`.** TradingView sends a URL and a body — it cannot send an
        Authorization header. A valid JSON message is also accepted. So this route
        takes the alert's Message box verbatim, and the credential is the key in the path plus the
        secret at the front of the message for legacy `sh_` URLs.

        Prepared `sa_` URLs instead authorize one stored instruction. Send the exact readable
        message returned by POST /hooks/{hookId}/alert-links, with TradingView placeholders
        expanded. No message secret is needed for these scoped URLs. A changed action, fixed
        instrument or parameter is rejected. Event identity is scoped to this prepared URL and
        instrument; repeated delivery is durably refused while the alert record is retained.
        The generated timestamp distinguishes events only to TradingView's time resolution.
        Keep the URL private; possession authorizes its action. Revoke it to stop new deliveries.

        Reusable opaque URLs use a separately acknowledged scope. They accept supported comma-separated
        instructions with a resolved Sent at line or script nonce. Actions and values may change; accounts,
        policy and guards still come from the connection. Missing instrument or delivery identity is refused.
        Old action-restricted links and generic message-secret authentication are unchanged.

        The legacy message is `[secret,]command[,SYMBOL][,key=value]…`, or the same fields as a JSON
        object. A field this bridge does not understand is REFUSED, not ignored: a field silently
        dropped is a trade sized differently from the one you asked for, and nothing would show it.

        Statuses are chosen so TradingView's own alert log is useful:

        | Status | Meaning |
        |---|---|
        | 202 | Alert durably queued; `verdict: processing`, `reason: queued`. Poll alert history for broker outcomes |
        | 200 | Ping received and authenticated; no broker command |
        | 400 | The message could not be read; `reason` names the field |
        | 401 | The secret is wrong, missing, or the source address is not allowed |
        | 404 | No hook answers at this URL |
        | 409 | Duplicate event, changed configuration, or unresolved earlier execution |
        | 422 | Alert refused before admission; account sizing and broker refusals are recorded asynchronously |
        | 423 | The hook is paused |
        | 429 | Too many alerts a minute |
        | 503 | Admission unavailable; check existing event history before retrying with the same nonce |

        Known-hook decisions are recorded when storage is available. Invalid URLs and infrastructure failures may have no alert row. Queue processing uses durable per-step identities, waits for confirmed reversal closes, and holds unknown outcomes for review.
      parameters: [{ in: path, name: hookKey, required: true, schema: { type: string }, description: 'sh_XXXXXXXX.<secret>' }]
      requestBody:
        required: true
        content:
          text/plain:
            schema: { type: string }
            examples:
              market: { value: 'S3CRET,buy,EURUSD,risk=1,sl=20,tp=40' }
              close: { value: 'S3CRET,closelong,EURUSD' }
              json: { value: '{"secret":"S3CRET","command":"buy","symbol":"EURUSD","risk":1,"sl":20}' }
      responses:
        '202':
          description: Alert durably queued; not a broker fill
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HookRun' }
        '200':
          description: Understood, nothing to do
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HookRun' }
        '422':
          description: Refused, with the reason
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HookRun' }
  /stream:
    get:
      tags: [Stream]
      summary: Bounded WebSocket account-state snapshots
      description: |
        Connect with WebSocket to `wss://api.mirroapi.cloud/v1/stream`, the same host as REST
        (a plain HTTP `GET` answers `426 UPGRADE_REQUIRED`). The API key travels in the
        `Authorization: Bearer <key>` header on the upgrade or, for a browser, as the first
        message `{"op":"auth","token":"<key>"}` within 5 seconds of the server's
        `{"op":"auth-required","timeoutMs":5000}`. Cookies, console session bearers, a query
        string (any `?…` on the URL is refused with 400) and subprotocol credentials are not
        accepted. The key needs `read` scope. The server answers
        `{"op":"ready","sessionId":"…","seq":0,"pollMs":1000,"replayMs":300000,"channels":["account","positions","orders","commands"],"maxAccounts":3}`.

        Client → server (wait for `ready`, then send)
        ```json
        {"op":"subscribe","accountId":"<account uuid>","channels":["account","positions","orders","commands"]}
        {"op":"unsubscribe","accountId":"<account uuid>","channels":["orders"]}
        {"op":"ping"}
        {"op":"resume","sessionId":"<previous sessionId>","after":<last seq received>}
        ```
        Four channels: `account` (an `AccountInformation`), `positions` and `orders` (the full
        book — the same arrays `GET /positions` and `GET /orders` answer, never diffs) and
        `commands` (the latest 200 `Command`s). At most 3 accounts per socket and 4 channels
        per message. `subscribe` and `unsubscribe` are answered
        `{"op":"subscribed"|"unsubscribed","accountId":"…","channels":[…]}` with the channels now
        selected; `ping` with `{"op":"pong","at":"…"}`.

        Server → client
        ```json
        {"op":"snapshot","sessionId":"…","seq":1041,"accountId":"…","ch":"positions","at":"2026-09-18T08:00:00.120Z","source":"stored-terminal-state","lastSeenAt":"…","brokerConnected":true,"terminalFresh":true,"data":[…]}
        {"op":"heartbeat","sessionId":"…","seq":1041,"at":"…"}
        {"op":"error","code":"SNAPSHOT_UNAVAILABLE","accountId":"…","ch":"orders"}
        {"op":"reset","sessionId":"…","seq":1041,"reason":"RESUME_UNAVAILABLE"}
        ```
        State is read every second (`pollMs`) from what the terminal last stored and a
        `snapshot` goes out only when it changed, so states between two reads may be coalesced;
        `terminalFresh` (broker-connected and seen within 15 seconds) says whether the terminal
        is live, not that every row is. This is not tick streaming and not a complete event
        journal. A read that failed sends `error SNAPSHOT_UNAVAILABLE` for that account and
        channel — never an invented empty book. A `heartbeat` goes out after 10 seconds of
        silence; a socket that answers no ping for 30 seconds is closed
        (`STREAM_HEARTBEAT_TIMEOUT`). A read the terminal state could not answer in 10 seconds
        closes the socket with `STREAM_READ_TIMEOUT` (1013); a socket the gateway could not write to
        is closed `STREAM_WRITE_FAILED` (1011), and a transport error `STREAM_SOCKET_ERROR` (1011).
        `SNAPSHOT_UNAVAILABLE` is the error frame for a channel whose read failed.

        Resume: after reconnecting and authenticating with the same key, send `resume`
        **before** subscribing. Up to 256 snapshots (1 MiB, 5 minutes) are replayed from a
        process-local buffer after `{"op":"resumed","sessionId":"…","seq":…}`. A session that is
        gone answers `reset` with `RESUME_UNAVAILABLE` (subscribe again); a gap in the buffer
        `reset` with `REPLAY_GAP`, followed by fresh full snapshots. Discard what you cached on
        any `reset`; nothing is replayed across a gateway restart or a host change, and
        unsubscribing clears the retained buffer. Never treat `reset` as successful replay.
        `resumed` is followed by retained frames newer than the `after` sequence; those are
        historical replay. Protocol version 2 advertises `resumeRefresh: true` in `ready` and
        `/capabilities`: fresh snapshots for every retained channel then follow the replay even
        when unchanged, with sequences greater than `resumed.seq`. Recovery after a read failure
        or a quota pause resends fresh snapshots even when unchanged.

        Refusals arrive as `{"op":"error","code":"…"}` and, for the fatal ones, as the close
        reason: `UNAUTHORIZED` (close 1008), `FORBIDDEN`, `ACCOUNT_UNAVAILABLE`,
        `STREAM_CAPACITY` (close 1013 on connect: 4 sockets per client IP, 2 per key; the 33rd
        socket on a gateway, or more than 30 upgrades a minute from one address, is refused at
        the upgrade with HTTP 429 and never opens — repeated reconnects also consume this
        handshake allowance; a URL with a query string or a subprotocol is
        refused with 400 the same way), `STREAM_UNAVAILABLE`, `AUTH_TIMEOUT` (1008), `RATE_LIMITED`
        (`retryAfterSec`; every channel read spends one unit of the key's read quota, shared
        with REST), `STREAM_BACKPRESSURE` (a slow consumer is closed; reconnect with backoff),
        `INVALID_JSON`, `INVALID_MESSAGE`, `INVALID_SUBSCRIPTION`, `INVALID_RESUME`,
        `RESUME_BEFORE_SUBSCRIBE`, `SESSION_IN_USE`, `SUBSCRIPTION_LIMIT`,
        `ALREADY_AUTHENTICATED`, `STREAM_BUSY` (one message at a time), `MESSAGE_RATE_LIMIT`
        (20 messages a second), `TEXT_REQUIRED` (text frames only, 4 KiB at most). The key and
        the account's ownership are re-checked before every frame: a revoked key is closed even
        while the REST key cache is still warm; lost ownership closes the connection the same
        way. On `STREAM_BUSY` retry the control message after a short delay. Reconnect with
        bounded exponential backoff and jitter. See `/capabilities` for the current release limits.
      x-scope: read
      responses:
        '101': { description: WebSocket upgrade (switching protocols); authenticate before account data is sent }
        '426': { description: '`UPGRADE_REQUIRED`: a plain HTTP request; connect with WebSocket and authenticate with an API key.' }

  /health:
    get:
      tags: [Health]
      summary: Is the gateway up, and what does it run
      security: []
      description: |
        Unauthenticated and small, answered before the router is consulted (GET, or HEAD for the same
        status with no body; 120 a minute per address). HTTP 200 with `ok: true` when the database and
        the engine checks pass; HTTP 503 with `ok: false` and `failing` naming `db` or `engine`
        otherwise, so a probe keyed on the status code sees a real database or engine failure as down.
        The newest terminal heartbeat (`checks.terminals`) is reported as an age and never fails the
        report: a heartbeat read that throws or takes over a second answers `ok: null` with a `reason`.
        A failed check carries a stable word in `error`, never the driver's message. `version` and
        `release` say what the gateway runs.
      responses:
        '200':
          description: Up
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }
        '503':
          description: A check failed; `checks` names it
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }

  /configuration-links:
    post:
      tags: [Accounts]
      summary: A one-time link on which the trader types their own login and password
      description: |
        For a hosted MetaTrader account whose credentials the developer never holds. The link opens a
        page on the console (`url`); the trader enters server, login and password there, the account is
        created and starts deploying, and it appears on `GET /accounts`. One use, seven days; the token
        is shown once and stored as a hash. A live account is connected through a link only with the
        trader's own confirmation phrase (`GET /configure/{token}` says which). `mt4` is refused
        `422 PLATFORM_UNSUPPORTED` on a gateway started with `MIRRO_API_MT4=0`.
      x-scope: provision
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform: { type: string, enum: [mt5, mt4] }
                name: { type: string, description: Optional label, at most 80 characters. }
                environment: { type: string, enum: [live, demo], default: live }
                role: { type: string, enum: [follow, master, both], default: follow }
      responses:
        '201':
          description: The link
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigurationLink' }
        '422': { description: PLATFORM_UNSUPPORTED }
  /accounts/{accountId}/configuration-link:
    post:
      tags: [Accounts]
      summary: A one-time link on which the trader re-enters the password (and server) of an existing hosted account
      description: 'Hosted MetaTrader accounts only: an API-platform account is re-authorised by the trader on the platform itself (`409 CONFLICT`, `details.reason: not_a_hosted_terminal`). The link carries the account''s platform, name and environment; the login cannot change.'
      x-scope: provision
      parameters: [{ $ref: '#/components/parameters/accountId' }]
      responses:
        '201':
          description: The link
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigurationLink' }
        '409': { description: 'CONFLICT: not a hosted terminal' }
  /configure/{token}:
    parameters: [{ in: path, name: token, required: true, schema: { type: string }, description: 'The link token. Unknown, expired, used, or its account gone: all 404.' }]
    get:
      tags: [Accounts]
      summary: What a configuration link is for
      security: []
      description: 'Public (the trader has no key), rate limited per address. `liveConfirmation` is the exact phrase a live `create` link needs on POST, or null.'
      responses:
        '200':
          description: The link's purpose
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ConfigureInfo' }
        '404': { description: NOT_FOUND }
    post:
      tags: [Accounts]
      summary: The trader submits login, password and server
      security: []
      description: |
        Public. The link is claimed before the work so two submissions cannot both act; a refused
        submission hands it back. A `create` link needs `login`, `password` and `server`, and for a
        live account `liveConfirmation` equal to the phrase `GET` returned (`422 LIVE_CONFIRMATION_REQUIRED`
        otherwise, the phrase in `details.liveConfirmation`). An `update` link needs `password`, takes an
        optional `server`, and refuses a different `login` (`400`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                login: { type: string }
                password: { type: string, minLength: 4, maxLength: 128 }
                server: { type: string, maxLength: 128 }
                liveConfirmation: { type: string }
      responses:
        '201':
          description: Accepted; the account is provisioning
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, const: true }
                  accountId: { type: string }
        '404': { description: NOT_FOUND }
        '422': { description: LIVE_CONFIRMATION_REQUIRED }

  /quotas:
    get:
      tags: [Keys]
      summary: The limits this gateway enforces and what you hold against each
      description: '`accounts.max` is null: only hosted terminals are capped. The rate limits are the calling key''s own buckets.'
      x-scope: read
      responses:
        '200':
          description: Quotas
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Quotas' }
  /quotas/requests:
    get:
      tags: [Keys]
      summary: Your quota requests
      description: A key limited to accounts cannot read or open quota requests (`403 FORBIDDEN`).
      x-scope: read
      responses:
        '200':
          description: Requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/QuotaRequest' } }
    post:
      tags: [Keys]
      summary: Ask for a higher limit
      description: Stored and audited; a person answers it.
      x-scope: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, requested, reason]
              properties:
                kind: { type: string, enum: [accounts, hostedTerminals, apiKeys, webhooks, rateLimits] }
                requested: { type: integer, minimum: 1, maximum: 1000000 }
                reason: { type: string, maxLength: 2000 }
      responses:
        '201':
          description: Opened
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuotaRequest' }
  /requests:
    get:
      tags: [Audit]
      summary: Your recent requests, as the gateway answered them
      description: 'In memory, per gateway process (`retention: process`; `since` is when it started). Route patterns, never ids or query strings. A key limited to accounts sees only its own requests.'
      x-scope: read
      parameters: [{ in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 500, default: 100 } }]
      responses:
        '200':
          description: Requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/RequestLogEntry' } }
                  retention: { type: string, const: process }
                  since: { type: string, format: date-time }

  /support/tickets:
    get:
      tags: [Support]
      summary: Your support tickets
      x-scope: read
      responses:
        '200':
          description: Tickets
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/SupportTicket' } }
    post:
      tags: [Support]
      summary: Open a support ticket
      description: 'Stored and listed at once, and mailed to the operator (at most 10 mails per developer per hour; past that the ticket is still stored). A key limited to accounts must name one of them in `accountId` (`403 FORBIDDEN`).'
      x-scope: read
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [category, subject, message]
              properties:
                category: { type: string, enum: [accounts, trading, copy, billing, api, other] }
                subject: { type: string, maxLength: 160 }
                message: { type: string, maxLength: 5000 }
                accountId: { type: string }
      responses:
        '201':
          description: Opened
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SupportTicket' }

  /strategies:
    get:
      tags: [Copy]
      summary: Your strategies (a named master account with default copy settings)
      description: A key limited to accounts sees only strategies on those accounts.
      x-scope: read
      responses:
        '200':
          description: Strategies
          content:
            application/json:
              schema: { type: array, items: { $ref: '#/components/schemas/Strategy' } }
    post:
      tags: [Copy]
      summary: Create a strategy on one of your accounts
      description: 'One strategy per account (`409 CONFLICT`, `details.reason: account_has_strategy`). The account is granted the master role if it does not hold it; if that is refused the strategy is taken back out (`409`). `id` is optional, 4–12 letters and digits; a taken id is `409`.'
      x-scope: copy
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, name]
              properties:
                id: { type: string, pattern: '^[A-Za-z0-9]{4,12}$' }
                accountId: { type: string }
                name: { type: string, maxLength: 80 }
                description: { type: [string, 'null'], maxLength: 500 }
                defaults: { $ref: '#/components/schemas/StrategyDefaults' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Strategy' }
        '409': { description: CONFLICT }
  /strategies/{strategyId}:
    parameters: [{ in: path, name: strategyId, required: true, schema: { type: string } }]
    get:
      tags: [Copy]
      summary: One strategy
      x-scope: read
      responses:
        '200':
          description: Strategy
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Strategy' }
    patch:
      tags: [Copy]
      summary: Rename, describe or change the defaults
      x-scope: copy
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 80 }
                description: { type: [string, 'null'], maxLength: 500 }
                defaults: { $ref: '#/components/schemas/StrategyDefaults' }
      responses:
        '200':
          description: Strategy
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Strategy' }
    delete:
      tags: [Copy]
      summary: Remove a strategy
      description: 'Refused while the master still has active copies (`409 CONFLICT`, `details.activeCopies`); pause them first.'
      x-scope: copy
      responses: { '204': { description: Removed }, '409': { description: CONFLICT } }
  /strategies/{strategyId}/logs:
    get:
      tags: [Copy]
      summary: The copy events of every copy of this strategy's master
      x-scope: read
      parameters:
        - { in: path, name: strategyId, required: true, schema: { type: string } }
        - { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
      responses:
        '200':
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/AuditEvent' } }

  /max/home:
    get:
      tags: [Copy]
      summary: Everything the MAX copy trading page shows, in one read
      description: |
        MAX copy trading: a provider pays for spots, and the people they invite copy the provider's trades
        into their own accounts and pay nothing for the copying. This is the single read `/app/max` makes —
        the offer, the accounts that could be copied, what the hosting can serve right now, the one invite
        link, the subscriber lines, the outstanding invites, the renewal and the last payments, plus the
        caller's own places as someone who copies. It never charges and never starts an account; the only
        thing it may create is the invite link of an offer whose spots are already paid for.
      x-scope: read
      parameters:
        - { name: program, in: query, required: false, schema: { type: string }, description: "Which of the caller's offers to show. The default is the newest live one." }
      responses:
        '200':
          description: The page
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaxHome' }
        '401': { description: UNAUTHORIZED }
  /max/join/{code}:
    get:
      tags: [Copy]
      summary: What an invite link opens on
      description: |
        The one read the page behind an invite makes, and the only MAX route that answers with no key and no
        session. `state` is `open`, or the reason the link cannot be used right now: `invalid`, `expired`,
        `revoked`, `used`, `paused` (with `reason` `unpaid` or `joins_closed`), `closed` or `full`. A link
        that cannot be used still names who sent it, so the page can say what to do about it, and carries
        nothing else about the offer. The code is a secret in the path; an unknown one answers the same
        `state: invalid` body rather than confirming that some other code exists.
        Called with a bearer it also answers `yours`, `emailMatches` and `membership`, so somebody who has
        already joined lands on their own progress instead of on the form again.
      security: []
      parameters:
        - { name: code, in: path, required: true, schema: { type: string }, description: The code from the invite link. }
      responses:
        '200':
          description: The invite, usable or not
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaxJoinInfo' }
    post:
      tags: [Copy]
      summary: Take a spot, and start copying
      description: |
        Takes one spot on the offer behind `code` and starts the copying. The caller is the person joining:
        this route never acts for somebody else.

        The account to copy into is given ONE of two ways. `accountId` uses a MetaTrader account the caller
        already has here (refused when it already copies somebody, when other people copy it, or when the
        offer takes real money only). Otherwise `platform`, `server`, `login` and `password` are all required
        and a hosted terminal is started for them, paid for by the provider spot. An invite whose details the
        provider filled in is no exception: `prefill` on the GET is what the page shows, not a stand-in for
        these fields, because no password travels with an invite.

        `consent: true` is required and the wording is recorded word for word. When the offer asks for
        confirmed addresses, an open link answers `422 MAX_EMAIL_NOT_VERIFIED` until the address is confirmed.
        That refusal mails a fresh confirmation link itself, through the same channel as sign-up, and its
        `details` say so: `sentTo` (the masked address), `sent` (whether a letter really went out, so a page
        never claims one did) and `method: link`. There is no code to type and no field to send back.
        Nothing is created and no spot is taken. A broker that refuses the password does not cost the spot either: the place is made and the
        answer says `login_rejected`, which `PUT /max/memberships/{placeId}/credentials` fixes in place.
      x-scope: provision
      parameters:
        - { name: code, in: path, required: true, schema: { type: string }, description: The code from the invite link. }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MaxJoinRequest' }
      responses:
        '201':
          description: The place, as the person who joined sees it
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaxMembership' }
        '400': { description: 'platform, server, login or password missing or malformed' }
        '401': { description: UNAUTHORIZED }
        '422': { description: 'MAX_INVALID, MAX_EXPIRED, MAX_REVOKED, MAX_USED, MAX_PAUSED, MAX_CLOSED, MAX_FULL, MAX_OWN_PROGRAM, MAX_INVITE_NOT_YOURS, MAX_ALREADY_MEMBER, MAX_REMOVED_BY_PROVIDER, MAX_EMAIL_NOT_VERIFIED, MAX_CONSENT_REQUIRED, MAX_CONSENT_VERSION, MAX_PLATFORM_UNSUPPORTED, MAX_DEMO_NOT_ALLOWED, MAX_ACCOUNT_ALREADY_COPYING or MAX_ACCOUNT_IS_COPIED' }
  /max/memberships:
    get:
      tags: [Copy]
      summary: Every place the caller holds as somebody who copies
      description: |
        The caller's own side of MAX: one entry per place, the newest first, including places that have
        ended (they stay visible for a few days). This is the follower's view, never the provider's - it
        carries the caller's own account, their progress, their problem and what they consented to.
      x-scope: read
      responses:
        '200':
          description: The caller's places
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { $ref: '#/components/schemas/MaxMembership' } }
        '401': { description: UNAUTHORIZED }
  /max/memberships/{placeId}:
    get:
      tags: [Copy]
      summary: One place the caller holds
      description: |
        The same shape as the list, for one place. The page behind an invite polls this while an account is
        starting: `progress` is the four honest lines it shows, `problem.fix` says which of them the person
        can do something about, and `release` says what is still open after they leave.
      x-scope: read
      parameters:
        - { name: placeId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The place
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaxMembership' }
        '401': { description: UNAUTHORIZED }
        '404': { description: NOT_FOUND }
  /max/programs/{programId}/onboard:
    post:
      tags: [Copy]
      summary: Add a subscriber yourself, when you hold their trading account details
      description: |
        The secondary way to add someone. `mode: invite` (the default and the safe one) emails the person a
        personal link with the platform, the server, the account number and demo-or-live already filled in:
        no spot is taken, nothing is provisioned, and no password is asked for or stored - the one box they
        fill when they open the link is their own broker password, which can only be sealed once it belongs
        to them and to an account id - a password sent in this mode is refused, not dropped. `mode: manage` states that
        the provider manages the account for that person: it makes their sign-in, takes a spot, starts the
        account at once, and records the provider's attestation with the consent wording. It answers
        `503 MAIL_UNAVAILABLE` and creates nothing when the person cannot be emailed, because an account
        nobody can reach is worse than a refusal.
      x-scope: copy
      parameters:
        - { name: programId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/MaxOnboardRequest' }
      responses:
        '201':
          description: The subscriber, or the invite that was sent
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaxOnboardResult' }
        '400': { description: 'A password sent in `invite` mode (`details.field: password`), or a malformed field' }
        '401': { description: UNAUTHORIZED }
        '422': { description: 'SPOTS_NONE_FREE, MAX_ONBOARD_ATTEST_REQUIRED, MAX_ONBOARD_EMAIL_IN_PROGRAM, MAX_OWN_PROGRAM, MAX_DEMO_NOT_ALLOWED, TERMINAL_ACCOUNT_ALREADY_OWNED or SPOTS_NO_ROOM' }
        '503': { description: 'MAIL_UNAVAILABLE — nothing was created' }
  /max/memberships/{placeId}/manage:
    get:
      tags: [Copy]
      summary: What "manage this account yourself" costs on one copied account
      description: |
        An account that arrived through a provider's invite is view-only across the whole of MirroAPI until
        its owner buys Manage: they can watch it, close a copied trade and stop copying, and nothing else.
        Manage (4.00 USD per 30 days) makes it an ordinary account while the copying continues and the
        provider's spot still pays the hosting. Whoever holds the older 2.00 USD access keeps it to the end
        of their period and renews at 4.00.
      x-scope: read
      parameters:
        - { name: placeId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The offer and its price
          content:
            application/json:
              schema:
                type: object
                properties:
                  manage: { $ref: '#/components/schemas/MaxManage' }
                  quote:
                    type: object
                    properties:
                      amountUsd: { type: string, example: '4.00' }
                      days: { type: integer, example: 30 }
                      charge: { type: object, properties: { currency: { type: string }, amount: { type: string }, rate: { type: [string, 'null'] } } }
        '401': { description: UNAUTHORIZED }
        '404': { description: NOT_FOUND }
    patch:
      tags: [Copy]
      summary: Turn renewing of Manage on or off
      x-scope: provision
      parameters:
        - { name: placeId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [autoRenew], properties: { autoRenew: { type: boolean } } }
      responses:
        '200':
          description: The place
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MaxMembership' }
        '401': { description: UNAUTHORIZED }
        '404': { description: NOT_FOUND }
  /max/memberships/{placeId}/manage/checkout:
    post:
      tags: [Copy]
      summary: Pay for Manage on one copied account
      description: Session only, as every checkout is. The copying is untouched by it, and the spot still pays for the hosting.
      x-scope: provision
      parameters:
        - { name: placeId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, properties: { returnUrl: { type: string } } }
      responses:
        '201':
          description: The payment, and where to pay it
          content:
            application/json:
              schema:
                type: object
                properties:
                  payment: { $ref: '#/components/schemas/MaxPayment' }
                  authorizationUrl: { type: [string, 'null'] }
                  membership: { oneOf: [{ $ref: '#/components/schemas/MaxMembership' }, { type: 'null' }] }
        '401': { description: UNAUTHORIZED }
        '403': { description: FORBIDDEN }
        '422': { description: 'SPOTS_NO_ROOM, or a refusal from the catalogue' }
  /billing/card-checkouts:
    post:
      tags: [Billing]
      summary: Open a card checkout for service credit
      description: |
        Session-only, on a gateway with card payments enabled (`503 CARD_PAYMENTS_DISABLED` otherwise).
        The processor's page takes the card; this API never sees a card number. A confirmed email
        address is needed (`422 EMAIL_REQUIRED`). Only `amount`, `requestId` and `returnUrl` are
        accepted; `requestId` is a UUID reused when retrying the same top-up. Whole cents only
        (`400 CARD_AMOUNT_NOT_SUPPORTED`), at least the minimum top-up (`400 PAYMENT_MINIMUM_TOPUP`);
        at most five payments wait at once (`503 PAYMENT_PENDING_LIMIT`); a card that cannot be charged
        from your country is `503 CARD_CURRENCY_UNAVAILABLE`; a page that could not be opened is
        `503 PAYMENT_CHECKOUT_UNAVAILABLE`. On every 503 nothing has been charged.
      x-scope: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount, requestId, returnUrl]
              properties:
                amount: { type: string, description: USD of service credit, a decimal string. }
                requestId: { type: string, format: uuid }
                returnUrl: { type: string, format: uri, description: A page of the console the person comes back to. }
      responses:
        '201':
          description: Opened; send the person to `authorizationUrl`
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CardCheckout' }
  /billing/card-checkouts/{reference}/confirm:
    post:
      tags: [Billing]
      summary: Ask the gateway to ask the processor whether a checkout was paid
      description: The reference only says which checkout; it is not evidence. The gateway reads the processor's own answer and credits the balance once, whatever the page said.
      x-scope: admin
      parameters: [{ in: path, name: reference, required: true, schema: { type: string } }]
      responses:
        '200':
          description: The checkout as the processor reports it
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CardCheckout' }
        '404': { description: NOT_FOUND }
  /billing/plans:
    get:
      tags: [Billing]
      summary: The account plan price, your credit, and the plan of every account of yours
      description: |
        Session-only. An account plan is one price per account per 30 days (`plan.priceUsd`, `plan.periodDays`,
        formatted once by the gateway: a page shows them and never writes the number itself), every API surface
        included and nothing metered while it is paid. Hours no plan covers are pay as you go at the hourly rates
        (`GET /usage` says which hours a plan covered). `plans` has one entry per account that ever had a plan in
        this environment (`active` or `ended`) or has a payment open for one (`none`, with `pending`); an account
        with no entry never had one (`Account.plan` says `none`). When payments are off the answer is `mode: off`,
        the price, `credit: null` and no plans.
      x-scope: admin
      responses:
        '200':
          description: The price, the credit a plan can be paid from, and the plans
          content:
            application/json:
              schema:
                type: object
                required: [mode, plan, credit, plans]
                properties:
                  mode: { type: string, enum: [off, sandbox, live] }
                  plan: { $ref: '#/components/schemas/PlanPrice' }
                  credit:
                    type: [object, 'null']
                    description: 'The service credit in this environment, which `payWith: credit` spends; null when payments are off.'
                    properties:
                      balanceUsd: { type: string }
                  plans: { type: array, items: { $ref: '#/components/schemas/Plan' } }
        '401': { description: Missing or invalid console session }
        '403': { description: API keys cannot access user billing }
  /billing/plans/checkout:
    post:
      tags: [Billing]
      summary: Buy or renew one account's plan, from credit or directly
      description: |
        Session-only (`503 PAYMENTS_DISABLED` when payments are off). Buys one period of the plan for the account
        named, at the price `GET /billing/plans` states; an account whose plan is active is renewed, and the new
        period starts at its `paidThrough`, never earlier. `requestId` is a UUID reused when retrying the same
        purchase: it answers the same payment (`409 CONFLICT` if reused for another account, or once that payment
        was closed).

        `payWith: credit` settles at once from the service credit: `payment.status` is `paid`, `plan.status`
        `active`, and an account that was waiting for its plan (`POST /accounts` with `start: false`) is started.
        Too little credit is `402 INSUFFICIENT_BALANCE` (`details.neededUsd`, `details.balanceUsd`); nothing is kept.

        Otherwise the plan is paid directly, for exactly the plan amount. `method: card` (the default) opens a
        card page as `POST /billing/card-checkouts` does: `checkout` and `authorizationUrl` are answered,
        `returnUrl` is required and must be a page of the console, and the refusals are the same
        (`503 CARD_PAYMENTS_DISABLED`, `422 EMAIL_REQUIRED`, `503 PAYMENT_CHECKOUT_UNAVAILABLE`; nothing
        charged on any 503). `method: crypto` creates an `invoice` on the `rail` named, as
        `POST /billing/payment-invoices` does. The money credits the balance and the plan is settled from it in
        the same database transaction, so the ledger shows the `payment` and then the `plan` entry. Until then
        `payment.status` is `pending` and `plan.pending` names it; a second checkout for the same account while
        one is pending answers that payment again for the same provider, and `409 CONFLICT` (`details.pending`)
        for another. A pending payment expires after 24 hours.
      x-scope: admin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required: [accountId, requestId]
              properties:
                accountId: { type: string, format: uuid }
                requestId: { type: string, format: uuid }
                payWith: { type: string, enum: [credit], description: 'Settle from the service credit now; `method`, `provider`, `rail` and `returnUrl` are then not read.' }
                method: { type: string, enum: [card, crypto], default: card, description: How to pay directly. }
                provider: { type: string, enum: [stripe, paystack], description: 'The card processor, one of those `GET /billing/payments` offers you in `cards`; the first of them when left out.' }
                rail: { type: string, enum: [base-sepolia-usdc, tron-nile-usdt, base-usdc, tron-usdt], description: 'The crypto rail (`method: crypto`), one of this environment''s.' }
                returnUrl: { type: string, format: uri, description: 'Required with `method: card`: a page of the console the person comes back to.' }
      responses:
        '201':
          description: 'The payment, the plan as it now stands, and where to pay: `authorizationUrl` and `checkout` for a card, `invoice` for crypto, neither from credit'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlanCheckout' }
        '400': { description: 'A missing or malformed field (`BAD_REQUEST`, `details.field`), or a `returnUrl` that is not a MirroAPI page' }
        '401': { description: Missing or invalid console session }
        '402': { description: 'Too little credit for `payWith: credit` (`INSUFFICIENT_BALANCE`, `details.neededUsd`, `details.balanceUsd`); nothing is kept' }
        '403': { description: API keys cannot access user billing }
        '404': { description: 'The account is not yours, or was removed (`NOT_FOUND`)' }
        '409': { description: 'A payment page is already open for this account with another provider (`CONFLICT`, `details.pending`); `requestId` was used for another purchase; or its payment was already closed (`details.status`)' }
        '422': { description: 'A card needs a confirmed email address (`EMAIL_REQUIRED`); that card processor is not offered in your country (`CARD_PROVIDER_UNAVAILABLE`); the plan could not be settled from credit (`PAYMENT_UNAVAILABLE`, `details.reason`)' }
        '503': { description: '`PAYMENTS_DISABLED`, `CARD_PAYMENTS_DISABLED`, `PAYMENT_PENDING_LIMIT`, `CARD_CURRENCY_UNAVAILABLE` or `PAYMENT_CHECKOUT_UNAVAILABLE`; nothing has been charged' }
  /billing/plans/{accountId}:
    parameters: [{ $ref: '#/components/parameters/accountId' }]
    patch:
      tags: [Billing]
      summary: Turn renewing of one account's plan on or off
      description: |
        Session-only. With `autoRenew` on (the default) the plan is renewed from the service credit when
        `paidThrough` passes; when the credit is short the plan ends (`renewFailedAt`, `renewFailure: insufficient`),
        the account keeps running with its hours metered by the hour, and the gateway tries again every hour
        until it is paid, from credit or directly. With it off the plan ends at `paidThrough`.
      x-scope: admin
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [autoRenew], properties: { autoRenew: { type: boolean } } }
      responses:
        '200':
          description: The plan
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Plan' }
        '400': { description: '`autoRenew` missing, not a boolean, or sent with other fields (`BAD_REQUEST`, `details.field`)' }
        '401': { description: Missing or invalid console session }
        '403': { description: API keys cannot access user billing }
        '404': { description: 'The account is not yours, has never had a plan, or was removed (`NOT_FOUND`)' }
        '503': { description: '`PAYMENTS_DISABLED`' }

  /firm/storefront:
    get:
      tags: [Firm]
      summary: "Published firm website and available challenge programs"
      description: "Public allowlisted content only; an unpublished, suspended or engine-only firm returns 404. Availability checks matching balance, currency and expiry more than one hour away; it is not an account reservation. Checkout stays disabled until the operator configures the merchant and hosting coverage."
      security: []
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmStorefront"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
      parameters: [{"in": "query","name": "slug","required": true,"schema": {"type": "string"}}]
  /firm/business:
    get:
      tags: [Firm]
      summary: "Firm business overview for staff"
      description: "Requires control_room ability and no account-scoped key. Lists latest 250 orders, 500 stock accounts, 250 tickets and 100 audit events; these are bounded lists; counts contains all-firm totals. Coverage counts matching unexpired stock for every active program. Launch preparation is evidence-based and checks recorded company coverage and available hosting places; historical delivery alone cannot prove live readiness. No stock credentials are returned."
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmBusiness"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/orders:
    get:
      tags: [Firm]
      summary: "Search the full firm order history"
      description: "Requires active firm staff with control_room ability. Account-scoped keys are refused. Results use stable newest-first cursor pagination across the full matching history. The delivery filter includes paid and connecting. Total is the full matching count, not the page count. Concurrent updates may change totals between pages."
      parameters: [{"in": "query", "name": "q", "description": "Literal case-insensitive search by customer, email, reference or program; limited to orders you can access", "schema": {"type": "string", "maxLength": 120}}, {"in": "query", "name": "status", "schema": {"type": "string", "enum": ["all", "delivery", "awaiting_payment", "paid", "connecting", "ready", "review", "cancelled"], "default": "all"}}, {"in": "query", "name": "limit", "schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}}, {"in": "query", "name": "after", "description": "Opaque nextCursor from the preceding page; keep the same search and filters", "schema": {"type": "string"}}]
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrderPage"}}}}, "400": {"description": "Invalid search or cursor"}, "403": {"description": "Role or key scope does not allow this operation"}, "404": {"description": "Order not found or not owned by this user"}}
  /firm/orders/{orderId}:
    get:
      tags: [Firm]
      summary: "Read an order and its recorded timeline"
      description: "Requires active firm staff with control_room ability. Account-scoped keys are refused. Timeline contains at most 100 allowlisted events in chronological order. Credentials and internal notes are never returned."
      parameters: [{"in": "path", "name": "orderId", "required": true, "schema": {"type": "string", "format": "uuid"}}]
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrderDetail"}}}}, "400": {"description": "Invalid search or cursor"}, "403": {"description": "Role or key scope does not allow this operation"}, "404": {"description": "Order not found or not owned by this user"}}
  /me/firm-orders/page:
    get:
      tags: [Firm]
      summary: "Search your own orders"
      description: "Returns only orders owned by the authenticated user. Account-scoped keys are refused. Results use stable newest-first cursor pagination across the full matching history. The delivery filter includes paid and connecting. Total is the full matching count, not the page count. Concurrent updates may change totals between pages."
      parameters: [{"in": "query", "name": "q", "description": "Literal case-insensitive search by customer, email, reference or program; limited to orders you can access", "schema": {"type": "string", "maxLength": 120}}, {"in": "query", "name": "status", "schema": {"type": "string", "enum": ["all", "delivery", "awaiting_payment", "paid", "connecting", "ready", "review", "cancelled"], "default": "all"}}, {"in": "query", "name": "limit", "schema": {"type": "integer", "minimum": 1, "maximum": 50, "default": 20}}, {"in": "query", "name": "after", "description": "Opaque nextCursor from the preceding page; keep the same search and filters", "schema": {"type": "string"}}]
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrderPage"}}}}, "400": {"description": "Invalid search or cursor"}, "403": {"description": "Role or key scope does not allow this operation"}, "404": {"description": "Order not found or not owned by this user"}}
  /me/firm-orders/{orderId}:
    get:
      tags: [Firm]
      summary: "Read your order and saved purchase rules"
      description: "Returns only orders owned by the authenticated user. Account-scoped keys are refused. Timeline contains at most 100 allowlisted events in chronological order. Credentials and internal notes are never returned."
      parameters: [{"in": "path", "name": "orderId", "required": true, "schema": {"type": "string", "format": "uuid"}}]
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrderDetail"}}}}, "400": {"description": "Invalid search or cursor"}, "403": {"description": "Role or key scope does not allow this operation"}, "404": {"description": "Order not found or not owned by this user"}}
  /firm/policies/public/{slug}/{kind}/{version}:
    get:
      tags: [Firm]
      summary: Read a published version of a firm's policy
      security: []
      description: Returns only an immutable publication, never a website draft. Links remain stable across later edits and website unpublication. Suspended firms and disabled website products are unavailable. Text is plain text, not HTML
      parameters:
        - {in: path, name: slug, required: true, schema: {type: string}}
        - {in: path, name: kind, required: true, schema: {enum: [terms, privacy]}}
        - {in: path, name: version, required: true, schema: {type: string, pattern: '^[a-f0-9]{64}$'}}
      responses:
        '200': {description: Published policy text and firm identity, content: {application/json: {schema: {type: object}}}}
        '404': {description: No matching published policy}
  /firm/website-studio:
    get:
      tags: [Firm]
      summary: "Read the website draft, media library and saved versions"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned. Writes require the current revision; stale edits return 409. Save and restore never change the published snapshot. Firm-written Terms and Privacy pages use policies; publication creates immutable links for checkout. The normalized website document is bounded to 180 KB and the save request to 200 KB. Publication validates image descriptions, company legal fields and media ownership. The latest 50 saved versions can be restored if their referenced files still exist."
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmWebsiteStudio"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
    put:
      tags: [Firm]
      summary: "Save a website draft without changing the public website"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned. Writes require the current revision; stale edits return 409. Save and restore never change the published snapshot. Firm-written Terms and Privacy pages use policies; publication creates immutable links for checkout. The normalized website document is bounded to 180 KB and the save request to 200 KB. Publication validates image descriptions, company legal fields and media ownership. The latest 50 saved versions can be restored if their referenced files still exist."
      x-scope: trade
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["revision", "document"], "properties": {"revision": {"type": "integer", "minimum": 0}, "document": {"$ref": "#/components/schemas/FirmWebsiteDocument"}}}}}}
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/website-studio/publish:
    post:
      tags: [Firm]
      summary: "Publish the saved website revision atomically"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned. Writes require the current revision; stale edits return 409. Save and restore never change the published snapshot. Firm-written Terms and Privacy pages use policies; publication creates immutable links for checkout. The normalized website document is bounded to 180 KB and the save request to 200 KB. Publication validates image descriptions, company legal fields and media ownership. The latest 50 saved versions can be restored if their referenced files still exist."
      x-scope: trade
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["revision"], "properties": {"revision": {"type": "integer", "minimum": 0}}}}}}
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/website-studio/unpublish:
    post:
      tags: [Firm]
      summary: "Take the public website offline without removing trader access"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned. Writes require the current revision; stale edits return 409. Save and restore never change the published snapshot. Firm-written Terms and Privacy pages use policies; publication creates immutable links for checkout. The normalized website document is bounded to 180 KB and the save request to 200 KB. Publication validates image descriptions, company legal fields and media ownership. The latest 50 saved versions can be restored if their referenced files still exist."
      x-scope: trade
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["revision"], "properties": {"revision": {"type": "integer", "minimum": 0}}}}}}
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/website-studio/restore:
    post:
      tags: [Firm]
      summary: "Restore a saved version into a new draft"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned. Writes require the current revision; stale edits return 409. Save and restore never change the published snapshot. Firm-written Terms and Privacy pages use policies; publication creates immutable links for checkout. The normalized website document is bounded to 180 KB and the save request to 200 KB. Publication validates image descriptions, company legal fields and media ownership. The latest 50 saved versions can be restored if their referenced files still exist."
      x-scope: trade
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["revision", "sourceRevision"], "properties": {"revision": {"type": "integer", "minimum": 0}, "sourceRevision": {"type": "integer", "minimum": 0}}}}}}
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/site-assets:
    post:
      tags: [Firm]
      summary: "Upload an image or PDF into the firm media library"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned. Send a JSON body with standard base64, up to 2 MB decoded per file, 40 files and 25 MB per firm. Only signature-checked PNG, JPEG, WebP and PDF are accepted. Images are limited to 24 megapixels and 8000 pixels per dimension. Exact content is deduplicated per firm."
      x-scope: trade
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["name", "mime", "base64"], "properties": {"name": {"type": "string", "maxLength": 120}, "mime": {"enum": ["image/png", "image/jpeg", "image/webp", "application/pdf"]}, "base64": {"type": "string", "contentEncoding": "base64", "maxLength": 2796204}}}}}}
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmWebsiteAsset"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/site-assets/{assetId}:
    parameters: [{"in": "path", "name": "assetId", "required": true, "schema": {"type": "string", "format": "uuid"}}]
    get:
      tags: [Firm]
      summary: "Read a private media file for editing"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned."
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}, "mime": {"type": "string"}, "base64": {"type": "string", "contentEncoding": "base64"}}}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
    delete:
      tags: [Firm]
      summary: "Remove an unused media file"
      description: "Requires an unrestricted staff session. Website reads require control_room; edits and publication require firm.manage. Website feature must be enabled, except taking a website offline. No API keys or provider secrets are returned. Files referenced by the saved draft or published website cannot be deleted. Old revisions referencing a deleted file cannot be restored until content is adjusted."
      x-scope: trade
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/site-assets/public/{slug}/{assetId}:
    parameters: [{"in": "path", "name": "slug", "required": true, "schema": {"type": "string"}}, {"in": "path", "name": "assetId", "required": true, "schema": {"type": "string", "format": "uuid"}}]
    get:
      tags: [Firm]
      summary: "Read a file referenced by the current public website"
      description: "Only files referenced by the current published website of an active firm are public. Draft and removed files return 404. PDFs download as attachments. Responses use no-store and nosniff."
      security: []
      responses: {"200": {"description": "Success", "content": {"image/png": {"schema": {"type": "string", "format": "binary"}}, "image/jpeg": {"schema": {"type": "string", "format": "binary"}}, "image/webp": {"schema": {"type": "string", "format": "binary"}}, "application/pdf": {"schema": {"type": "string", "format": "binary"}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/launch:
    get:
      tags: [Firm]
      summary: "Read firm launch and integration evidence"
      description: "Read-only unrestricted staff session or trade/admin API key check of saved configuration and recorded activity. Active keys are live, unexpired, unrestricted staff keys with trade or admin scope. Receivers must belong to active staff, cover all four challenge events, and have no account restriction or revoked key. Recent success means a challenge-event delivery within seven days to such a receiver. Stock coverage is computed for every active program phase. This is not end-to-end certification and does not enable checkout."
      responses: {"200": {"description": "Success", "content": {"application/json": {"schema": {"type": "object", "properties": {"mode": {"enum": ["full", "engine"]}, "name": {"type": "string"}, "apiOrigin": {"type": "string", "format": "uri"}, "checkedAt": {"type": "string", "format": "date-time"}, "integration": {"type": "object", "properties": {"activeKeys": {"type": "integer"}, "lastKeyUse": {"type": ["string", "null"]}, "activeWebhooks": {"type": "integer"}, "failingWebhooks": {"type": "integer"}, "lastWebhookDelivery": {"type": ["string", "null"]}}}, "coverage": {"type": "array", "items": {"type": "object", "properties": {"programId": {"type": "string", "format": "uuid"}, "name": {"type": "string"}, "phaseIndex": {"type": "integer"}, "phaseName": {"type": ["string", "null"]}, "available": {"type": "integer"}}}}, "delivery": {"type": "object", "description": "Tenant-scoped historical handovers and current stage queue counts, not certification of a current program or provider", "properties": {"phaseHandovers": {"type": "integer", "minimum": 0}, "fundedHandovers": {"type": "integer", "minimum": 0}, "fundedContinuations": {"type": "integer", "minimum": 0}, "awaitingApproval": {"type": "integer", "minimum": 0}, "awaitingStock": {"type": "integer", "minimum": 0}, "connecting": {"type": "integer", "minimum": 0}, "needsReview": {"type": "integer", "minimum": 0}, "lastHandover": {"type": ["string", "null"], "format": "date-time"}}}, "deployment": {"type": "object", "description": "Credential-free deployment brief with selected mode, database preparation steps, required services and remaining launch evidence. A dedicated selection is a request, not a connected database."}}}}}}, "400": {"description": "Invalid content or missing publication details"}, "403": {"description": "Role, product or firm state does not permit this operation"}, "404": {"description": "File or saved revision not found"}, "409": {"description": "The revision changed or this file is still in use"}}
  /firm/site:
    put:
      tags: [Firm]
      summary: "Save or publish the firm website (owner)"
      description: "Replaces website settings. Publishing requires company name, HTTPS terms and privacy URLs, headline, introduction and risk disclosure. Publishing does not attach DNS or enable checkout."
      x-scope: "trade"
      requestBody: {"required": true,"content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmSite"}}}}
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmSite"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/stock:
    post:
      tags: [Firm]
      summary: "Add one firm-owned demo account to stock (owner or operations)"
      description: "Password is sealed at rest and never trimmed. phaseIndex selects the zero-based stage pool (default 0). The account must match the program balance and currency and expire more than one hour from now. Connection is verified at delivery, not import."
      x-scope: "trade"
      requestBody: {"required": true,"content": {"application/json": {"schema": {"type": "object","properties": {"programId": {"type": "string","format": "uuid"},"phaseIndex": {"type":"integer","minimum":0,"maximum":3,"default":0},"platform": {"enum": ["mt4","mt5"]},"environment": {"const": "demo"},"ownedByFirm": {"const": true},"login": {"type": "string"},"server": {"type": "string"},"password": {"type": "string","minLength": 4,"maxLength": 128,"writeOnly": true},"currency": {"type": "string"},"startingBalance": {"type": ["number","string"]},"expiresAt": {"type": "string","format": "date-time"}},"required": ["programId","platform","environment","ownedByFirm","login","server","password","currency","startingBalance","expiresAt"]}}}}
      responses: {"201": {"description": "Success","content": {"application/json": {"schema": {"type": "object","properties": {"id": {"type": "string","format": "uuid"}}}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/stock/{stockId}/retire:
    post:
      tags: [Firm]
      summary: "Retire unassigned stock (owner or operations)"
      x-scope: "trade"
      parameters: [{"in": "path","name": "stockId","required": true,"schema": {"type": "string","format": "uuid"}}]
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"type": "object","properties": {"id": {"type": "string","format": "uuid"}}}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /me/firm-orders:
    get:
      tags: [Firm]
      summary: "Your challenge orders"
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"type": "array","items": {"$ref": "#/components/schemas/FirmOrder"}}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
    post:
      tags: [Firm]
      summary: "Reserve stock and prepare challenge checkout"
      description: "Signed-in trader session only. Reuse the same requestId after an uncertain response. The order freezes price, program rules and accepted policy URLs. Only first-stage stock is reserved. Direct-funded programs are not sold through this flow. No stock means no payment initialization. May return 200 for an existing order. Only server-to-provider verification can mark it paid; never use the browser callback as proof."
      x-scope: "trade"
      requestBody: {"required": true, "content": {"application/json": {"schema": {"type": "object", "properties": {"programId": {"type": "string", "format": "uuid"}, "requestId": {"type": "string", "format": "uuid"}, "acceptTerms": {"const": true}, "reviewVersion": {"type": "string", "pattern": "^[0-9a-f]{64}$", "description": "The reviewVersion returned with this storefront program; changed rules, fees or site policies require a fresh review"}, "paymentProvider": {"type": "string", "enum": ["paystack", "squad", "transactpay", "korapay"], "description": "Required when multiple payment options are available — unchanged on replay"}, "customer": {"type": "object", "additionalProperties": false, "required": ["firstName", "lastName", "phone", "country"], "description": "Required for TransactPay only", "properties": {"firstName": {"type": "string", "maxLength": 50}, "lastName": {"type": "string", "maxLength": 50}, "phone": {"type": "string", "pattern": "^\\+?[0-9]{7,15}$"}, "country": {"type": "string", "pattern": "^[A-Z]{2}$"}}}}, "required": ["programId", "requestId", "acceptTerms", "reviewVersion"]}}}}
      responses: {"201": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrder"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"},"200": {"description": "Existing order for the same requestId","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrder"}}}}}
  /me/firm-orders/{orderId}/check:
    post:
      tags: [Firm]
      summary: "Check payment and account delivery for your order"
      description: "Checks the exact provider reference, merchant, mode, amount, currency, email, tenant and order. Resumes an interrupted checkout initialization using the same reference. Returns checkoutUrl only while payment is pending. It never sends a trade."
      x-scope: "trade"
      parameters: [{"in": "path","name": "orderId","required": true,"schema": {"type": "string","format": "uuid"}}]
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrder"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /me/firm-orders/{orderId}/cancel:
    post:
      tags: [Firm]
      summary: "Cancel your unpaid checkout"
      description: "Checks for payment before cancellation, then releases reserved stock. Do not use the checkout link afterwards. A later verified charge is held for staff review/refund and cannot claim released stock."
      x-scope: "trade"
      parameters: [{"in": "path","name": "orderId","required": true,"schema": {"type": "string","format": "uuid"}}]
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"type": "object","properties": {"ok": {"const": true}}}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /me/firm-orders/{orderId}/credentials:
    post:
      tags: [Firm]
      summary: "View your delivered account credentials"
      description: "Session-only, owned ready order only, no-store response. Viewing is recorded in the audit trail. Do not store credentials in browser persistence or logs."
      x-scope: "trade"
      parameters: [{"in": "path","name": "orderId","required": true,"schema": {"type": "string","format": "uuid"}}]
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"type": "object","properties": {"platform": {"type": "string"},"environment": {"const": "demo"},"login": {"type": "string"},"server": {"type": "string"},"password": {"type": "string"}}}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/orders/{orderId}/retry:
    post:
      tags: [Firm]
      summary: "Retry a paid delivery held for review (owner or operations)"
      description: "Keeps the original account ID and stock reservation. Cancelled orders with late payments cannot be retried; reconcile/refund those with the payment provider. Hosting and account checks are never bypassed."
      x-scope: "trade"
      parameters: [{"in": "path","name": "orderId","required": true,"schema": {"type": "string","format": "uuid"}}]
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmOrder"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/support:
    get:
      tags: [Firm]
      summary: "Your support requests, or the firm queue for owner and operations"
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"type": "array","items": {"$ref": "#/components/schemas/FirmSupportTicket"}}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
    post:
      tags: [Firm]
      summary: "Open a support request"
      x-scope: "trade"
      requestBody: {"required": true,"content": {"application/json": {"schema": {"type": "object","properties": {"subject": {"type": "string","maxLength": 140},"message": {"type": "string","maxLength": 6000},"category": {"enum": ["account","challenge","payment","payout","other"]},"orderId": {"type": "string","format": "uuid"}},"required": ["subject","message","category"]}}}}
      responses: {"201": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmSupportTicket"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/support/{ticketId}:
    get:
      tags: [Firm]
      summary: "Read a support conversation"
      description: "A customer sees only their own ticket and public replies. Internal notes are returned only to active owner and operations roles with members.manage ability. Latest listing is bounded; a conversation returns its first 500 messages."
      parameters: [{"in": "path","name": "ticketId","required": true,"schema": {"type": "string","format": "uuid"}}]
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"$ref": "#/components/schemas/FirmSupportTicket"}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/support/{ticketId}/replies:
    post:
      tags: [Firm]
      summary: "Reply or add a staff note"
      description: "Only owner and operations roles can set internal or status. A customer reply reopens their ticket. Replies are in-product; this endpoint does not promise email delivery."
      x-scope: "trade"
      parameters: [{"in": "path","name": "ticketId","required": true,"schema": {"type": "string","format": "uuid"}}]
      requestBody: {"required": true,"content": {"application/json": {"schema": {"type": "object","properties": {"message": {"type": "string","maxLength": 6000},"internal": {"type": "boolean"},"status": {"enum": ["open","waiting","resolved"]}},"required": ["message"]}}}}
      responses: {"200": {"description": "Success","content": {"application/json": {"schema": {"type": "object","properties": {"ok": {"const": true}}}}}},"403": {"description": "The active firm role or session does not allow this operation"},"409": {"description": "Setup, stock, payment or account state needs attention"}}
  /firm/pool:
    get:
      tags: [Firm]
      summary: "Stage stock, creation sessions and account deliveries"
      description: "Firm staff only. Latest 50 sessions and 250 deliveries; no passwords or private session tokens."
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FirmPool"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/stock-batches:
    post:
      tags: [Firm]
      summary: "Prepare a private Exness demo creation session"
      description: "Owner or operations. Requires PUBLIC_API_ORIGIN HTTPS. Counts follow program stage order, with 1\u201350 accounts total. Stock expiry must be one hour to 30 days away. Session expires in two hours. Operator runs the returned script in their signed-in Exness Personal Area. No browser session is collected by MIRRO; broker contract changes stop the script. Connection is independently checked before trader delivery."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"programId":{"type":"string","format":"uuid"},"counts":{"type":"array","items":{"type":"integer","minimum":0,"maximum":50}},"stockExpiresAt":{"type":"string","format":"date-time"}},"required":["programId","counts","stockExpiresAt"]}}}}
      responses: {"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"expiresAt":{"type":"string","format":"date-time"},"script":{"type":"string","description":"One-time private operator script containing a two-hour pool-import capability. Never log or share publicly."}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/stock-batches/{batchId}/revoke:
    post:
      tags: [Firm]
      summary: "Revoke a demo creation session"
      description: "Owner or operations. Stops further slot claims and imports, without deleting already-created broker accounts."
      x-scope: "trade"
      parameters: [{"in":"path","name":"batchId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/stock-batches/{batchId}/claim:
    post:
      tags: [Firm]
      summary: "Reserve one creation slot"
      description: "Capability-authenticated operator adapter endpoint. Private token in request body. A saved slot returns saved=true; a previously claimed, unsaved slot is refused to prevent duplicate broker creation after an uncertain response."
      security: []
      parameters: [{"in":"path","name":"batchId","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$","writeOnly":true},"itemIndex":{"type":"integer","minimum":0}},"required":["token","itemIndex"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"saved":{"type":"boolean"},"claimed":{"type":"boolean"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/stock-batches/{batchId}/import:
    post:
      tags: [Firm]
      summary: "Save a confirmed Exness demo into its stage pool"
      description: "Private capability only. Must own an already-claimed slot and match frozen balance, currency and phase. Exact retries are idempotent; changed identities conflict. Password is sealed in the vault. This is not proof of terminal readiness."
      security: []
      parameters: [{"in":"path","name":"batchId","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$","writeOnly":true},"itemIndex":{"type":"integer","minimum":0},"phaseIndex":{"type":"integer","minimum":0},"platform":{"enum":["mt4","mt5"]},"environment":{"const":"demo"},"login":{"type":"string"},"server":{"type":"string"},"password":{"type":"string","writeOnly":true},"balance":{"type":"number","exclusiveMinimum":0},"currency":{"type":"string"}},"required":["token","itemIndex","phaseIndex","platform","environment","login","server","password","balance","currency"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"saved":{"type":"boolean"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/factory:
    get:
      tags: [Firm]
      summary: "Broker account factory: broker setups and account requests"
      description: "Firm staff only. Brokers whose hosted MT5 web terminal opens demo accounts, as verified against MetaQuotes' public directory, with the firm's requests and per-slot counts. Never a password or a verification code. Answers 501 where MIRRO_API_ACCOUNT_FACTORY is off."
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"enabled":{"type":"boolean"},"brokers":{"type":"array","items":{"$ref":"#/components/schemas/FactoryBroker"}},"requests":{"type":"array","items":{"$ref":"#/components/schemas/FactoryRequest"}}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/directory:
    get:
      tags: [Firm]
      summary: "MT5 servers in MetaQuotes' directory and their demo policy"
      description: "Firm staff only. At most eight names matching q (three characters or more), each with `terminal` (a terminal may open demos there), `website` (the broker sends people to demoUrl; MIRRO cannot open there), `none` or `unknown`."
      parameters: [{"in":"query","name":"q","required":true,"schema":{"type":"string","minLength":3}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"server":{"type":"string"},"company":{"type":["string","null"]},"opening":{"enum":["terminal","website","none","unknown"]},"demoUrl":{"type":["string","null"]},"leverages":{"type":"array","items":{"type":"integer"}}}}}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/discover:
    post:
      tags: [Firm]
      summary: "Find a broker's hosted web terminal under its domain"
      description: "Owner or operations. Probes the hostnames brokers usually give their MetaQuotes-hosted terminal under the given domain and returns those that are web terminals listing the server. Never guesses across domains."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"domain":{"type":"string"},"server":{"type":"string"}},"required":["domain"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"hosts":{"type":"array","items":{"type":"object","properties":{"url":{"type":"string"},"build":{"type":"integer"},"broker":{"type":"string"},"servers":{"type":"array","items":{"type":"string"}}}}}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/terminal:
    post:
      tags: [Firm]
      summary: "Read a hosted web terminal: its server and demo groups"
      description: "Owner or operations. The terminal address must be https://<host>/terminal; the public web.metatrader.app host is refused because it is pinned to MetaQuotes' own demo server."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"terminalUrl":{"type":"string"}},"required":["terminalUrl"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"},"build":{"type":"integer"},"buildDate":{"type":"string"},"broker":{"type":"object","properties":{"name":{"type":"string"},"url":{"type":"string"}}},"servers":{"type":"array","items":{"type":"object","properties":{"server":{"type":"string"},"type":{"type":"string"},"groups":{"type":"array","items":{"$ref":"#/components/schemas/FactoryGroup"}}}}}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/brokers:
    post:
      tags: [Firm]
      summary: "Add a broker setup"
      description: "Owner or operations. Verified before it is saved: the directory must say the server opens demos from a terminal, the terminal must list the server, and the chosen demo group must exist with this currency and leverage. {n} in the holder name and e-mail pattern becomes a per-broker serial. Starts disabled unless `enabled` is true."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactoryBrokerInput"}}}}
      responses: {"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactoryBroker"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/brokers/{brokerId}:
    put:
      tags: [Firm]
      summary: "Change a broker setup"
      description: "Owner or operations. Send the `revision` you edited; a changed row is refused with 409. Verified again before it is saved."
      x-scope: "trade"
      parameters: [{"in":"path","name":"brokerId","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/FactoryBrokerInput"},{"type":"object","properties":{"revision":{"type":"integer"}},"required":["revision"]}]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactoryBroker"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such broker"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/brokers/{brokerId}/verify:
    post:
      tags: [Firm]
      summary: "Verify a broker setup again"
      description: "Owner or operations. Re-reads the directory and the terminal; `problem` names what no longer holds, and the row loses its verification until it does."
      x-scope: "trade"
      parameters: [{"in":"path","name":"brokerId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/FactoryBroker"},{"type":"object","properties":{"problem":{"type":["string","null"]}}}]}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such broker"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/requests:
    post:
      tags: [Firm]
      summary: "Ask a broker for accounts"
      description: "Owner or operations. 1–50 accounts for one program stage at a verified, enabled broker whose demo group matches the program currency; the deposit is the program's starting balance. One account is opened per firm per tick, paced by the broker setup. A lost reply pauses the request with an `uncertain` slot for a person to settle; nothing is retried by code."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"brokerId":{"type":"string","format":"uuid"},"programId":{"type":"string","format":"uuid"},"phaseIndex":{"type":"integer","minimum":0,"maximum":3},"count":{"type":"integer","minimum":1,"maximum":50},"stockExpiresAt":{"type":"string","format":"date-time"}},"required":["brokerId","programId","count","stockExpiresAt"]}}}}
      responses: {"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactoryRequest"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such program or broker"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/requests/{requestId}:
    get:
      tags: [Firm]
      summary: "One account request with its slots and events"
      description: "Firm staff only. Slots carry the holder name and e-mail the broker saw, the login once opened, and whether a code was entered; never the code or a password."
      parameters: [{"in":"path","name":"requestId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/FactoryRequest"},{"type":"object","properties":{"slots":{"type":"array","items":{"$ref":"#/components/schemas/FactorySlot"}},"events":{"type":"array","items":{"type":"object"}}}}]}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such request"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/requests/{requestId}/pause:
    post:
      tags: [Firm]
      summary: "Pause a request"
      x-scope: "trade"
      parameters: [{"in":"path","name":"requestId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such request"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/requests/{requestId}/resume:
    post:
      tags: [Firm]
      summary: "Resume a paused request"
      description: "Refused while an `uncertain` slot is unresolved."
      x-scope: "trade"
      parameters: [{"in":"path","name":"requestId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such request"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/requests/{requestId}/cancel:
    post:
      tags: [Firm]
      summary: "Cancel a request"
      description: "Open and code-waiting slots are discarded; accounts already opened stay in stock."
      x-scope: "trade"
      parameters: [{"in":"path","name":"requestId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such request"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/requests/{requestId}/slots/{index}/codes:
    post:
      tags: [Firm]
      summary: "Enter the code the broker sent"
      description: "Owner or operations. For a slot in `awaiting_code`; the next tick retries the opening with the codes."
      x-scope: "trade"
      parameters: [{"in":"path","name":"requestId","required":true,"schema":{"type":"string","format":"uuid"}},{"in":"path","name":"index","required":true,"schema":{"type":"integer","minimum":0}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"emailCode":{"type":"string","pattern":"^[0-9]{4,8}$","writeOnly":true},"phoneCode":{"type":"string","pattern":"^[0-9]{4,8}$","writeOnly":true}}}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/requests/{requestId}/slots/{index}/resolve:
    post:
      tags: [Firm]
      summary: "Settle an uncertain slot by hand"
      description: "Owner or operations. `created` with the login the broker shows saves the account to stock with its passwords sealed; `discard` closes the slot. Required after any lost reply: code never retries an uncertain opening."
      x-scope: "trade"
      parameters: [{"in":"path","name":"requestId","required":true,"schema":{"type":"string","format":"uuid"}},{"in":"path","name":"index","required":true,"schema":{"type":"integer","minimum":0}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"outcome":{"enum":["created","discard"]},"login":{"type":"string"},"password":{"type":"string","writeOnly":true},"investorPassword":{"type":"string","writeOnly":true},"note":{"type":"string","maxLength":500}},"required":["outcome"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/stock/{stockId}/credentials:
    post:
      tags: [Firm]
      summary: "A stock account's credentials"
      description: "Owner or operations, from a signed-in session only; every read writes a `firm.stock_credentials_viewed` audit event. Returns the trading password and, when the account arrived with one, the investor (read-only) password."
      x-scope: "trade"
      parameters: [{"in":"path","name":"stockId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"platform":{"type":"string"},"login":{"type":"string"},"server":{"type":"string"},"currency":{"type":"string"},"startingBalance":{"type":"number"},"holderName":{"type":["string","null"]},"source":{"enum":["manual","exness_browser","factory"]},"state":{"type":"string"},"password":{"type":"string"},"investorPassword":{"type":["string","null"]}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such stock account"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/stock/{stockId}/activate:
    post:
      tags: [Firm]
      summary: "Take a stock account into direct use"
      description: "Owner or operations. An available, unexpired stock row becomes a hosted MetaTrader account of the signed-in member (or `userId`, an active member) through the same provisioning as POST /accounts; hosting coverage is checked, not bypassed. The row is marked assigned and never offered to a challenge again."
      x-scope: "trade"
      parameters: [{"in":"path","name":"stockId","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"userId":{"type":"string","format":"uuid"}}}}}}
      responses: {"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"accountId":{"type":"string","format":"uuid"},"workflow":{"type":"object"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such stock account"},"409":{"description":"Account, stage or evidence needs review"},"422":{"description":"The platform is not offered or provisioning refused the account"},"501":{"description":"Broker account opening is not switched on for this gateway"}}
  /firm/factory/open:
    post:
      tags: [Firm]
      summary: "Open one account now, optionally for a trader"
      description: "Owner or operations, or an API key with trade scope. Requires MIRRO-enabled demo_provisioning and a broker without verification codes. Opens ONE demo account at the broker in this request through the same request/slot ledger as POST /firm/factory/requests, skipping the pace but not the hourly limit. Answers 201 with the login, trading password and investor password; with `userId` (an active member) the account is also connected as that trader's hosted terminal and `accountId` is returned. 200 with `awaiting_code`, `uncertain` or `failed` when the broker did not open it; an uncertain opening is settled by hand from the request, never retried by code."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"brokerId":{"type":"string","format":"uuid"},"programId":{"type":"string","format":"uuid"},"phaseIndex":{"type":"integer","minimum":0,"maximum":3},"userId":{"type":"string","format":"uuid"},"stockExpiresAt":{"type":"string","format":"date-time"}},"required":["brokerId","programId"]}}}}
      responses: {"201":{"description":"Opened","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactoryOpenResult"}}}},"200":{"description":"Not opened: awaiting_code, uncertain or failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FactoryOpenResult"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such program or broker"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /demo-brokers:
    get:
      tags: [Demo accounts]
      parameters: [{"in":"query","name":"q","schema":{"type":"string","maxLength":60},"description":"searches the broker, its server, its demo group and its currency"},{"in":"query","name":"limit","schema":{"type":"integer","minimum":1,"maximum":1000},"description":"default 500; `total` says how many matched"}]
      summary: "Brokers anyone may open a demo account at"
      description: "Signed in or any key. The vendor's catalogue: brokers whose hosted MetaTrader web terminal opens demo accounts, each with its currency, leverage and starting-balance choices. `dailyCap` is how many openings one person may make in 24 hours."
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DemoBroker"}},"total":{"type":"integer","description":"how many rows matched, which may be more than were returned"},"gateway":{"type":"object","description":"whether the MetaTrader 4 brokers can be opened right now","properties":{"available":{"type":"boolean"},"until":{"type":"string","format":"date-time"},"coolingMinutes":{"type":"integer"},"reason":{"type":"string"}}},"dailyCap":{"type":"integer"}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /demo-accounts:
    get:
      tags: [Demo accounts]
      summary: "Your demo accounts"
      description: "Latest 100, never a password."
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DemoAccount"}}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
    post:
      tags: [Demo accounts]
      summary: "Open a demo account now"
      description: "Trade scope. Opens the account at the catalogue broker in YOUR name and e-mail through the broker's own web terminal and answers in the same request: 201 with `login`, `password` and `investorPassword` when the broker confirmed; 200 with `awaiting_code` (the broker e-mailed you a code; send it to /codes), `uncertain` (the broker did not confirm; support settles it) or `failed`. `connect:true` also starts a hosted terminal for it (hosting and balance rules apply; a problem there comes back as `connectProblem` with the account still opened). Five openings per person per 24 hours."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"brokerId":{"type":"string","format":"uuid"},"leverage":{"type":"integer"},"deposit":{"type":"number"},"connect":{"type":"boolean"}},"required":["brokerId"]}}}}
      responses: {"201":{"description":"Opened","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoAccount"}}}},"200":{"description":"Not opened: awaiting_code, uncertain or failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoAccount"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such demo broker"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /demo-accounts/{id}:
    get:
      tags: [Demo accounts]
      summary: Check your demo account opening
      description: Returns the current opening state for a demo account owned by the authenticated user in this tenant. No account is created by this read. Never returns passwords.
      parameters: [{in: path, name: id, required: true, schema: {type: string, format: uuid}}]
      responses:
        '200': {description: Current opening, content: {application/json: {schema: {$ref: '#/components/schemas/DemoAccount'}}}}
        '404': {description: No matching account owned by this user}
        '501': {description: Demo account service unavailable}
  /relay/demo-codes:
    post:
      tags: [Demo accounts]
      summary: Internal broker email relay
      description: Infrastructure endpoint for MIRRO's configured email worker only, not a firm integration or account-creation API. Requires the configured relay HMAC signature in X-Relay-Signature; a customer API key does not grant access. Returns 404 when the relay is unavailable or no opening is waiting on this address. Automatic firm provisioning excludes brokers requiring verification codes.
      security: [{relaySignature: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, receivedAt]
              properties:
                token: {type: string, writeOnly: true}
                receivedAt: {type: string, format: date-time}
                from: {type: string, maxLength: 320}
                subject: {type: string, maxLength: 500}
                text: {type: string, maxLength: 65536, writeOnly: true}
      responses:
        '200': {description: Relay processed or duplicate ignored}
        '400': {description: Invalid relay body}
        '403': {description: Invalid relay signature}
        '404': {description: No configured relay or waiting account}
  /demo-accounts/{id}/codes:
    post:
      tags: [Demo accounts]
      summary: "Send the code the broker e-mailed you"
      description: "Trade scope. For an account in `awaiting_code`; the opening is tried again at once and answers like POST /demo-accounts."
      x-scope: "trade"
      parameters: [{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"emailCode":{"type":"string","pattern":"^[0-9]{4,8}$","writeOnly":true},"phoneCode":{"type":"string","pattern":"^[0-9]{4,8}$","writeOnly":true}}}}}}
      responses: {"201":{"description":"Opened","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoAccount"}}}},"200":{"description":"Still not opened","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoAccount"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such demo broker"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /demo-accounts/{id}/credentials:
    post:
      tags: [Demo accounts]
      summary: "Your demo account's credentials"
      description: "Signed-in session only; every read writes a `demo.credentials_viewed` audit event. Returns the trading password and, when the broker gave one, the investor (read-only) password."
      x-scope: "trade"
      parameters: [{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoAccount"}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such open demo account"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /demo-accounts/{id}/connect:
    post:
      tags: [Demo accounts]
      summary: "Connect a demo account to MIRRO"
      description: "Trade scope. The open account becomes your hosted MetaTrader account through the same provisioning as POST /accounts; hosting and balance rules apply."
      x-scope: "trade"
      parameters: [{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"accountId":{"type":"string","format":"uuid"},"workflow":{"type":"object"}}}}}},"403":{"description":"Firm role, session or private capability is not allowed"},"404":{"description":"No such open demo account"},"409":{"description":"Account, stage or evidence needs review"},"422":{"description":"The platform is not offered or provisioning refused the account"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /ops/demo-brokers:
    get:
      tags: [Ops]
      summary: "The demo-broker catalogue, every row"
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DemoBrokerOps"}}}}}}},"403":{"description":"Operators only"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
    post:
      tags: [Ops]
      summary: "Add a catalogue broker"
      description: "Verified against MetaQuotes' directory, the broker's hosted terminal and the chosen demo group (every listed leverage must be offered) before it saves; starts disabled unless `enabled` is true."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoBrokerInput"}}}}
      responses: {"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoBrokerOps"}}}},"400":{"description":"Invalid details"},"403":{"description":"Operators only"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /ops/demo-brokers/terminal:
    post:
      tags: [Ops]
      summary: "Read a hosted web terminal for the catalogue"
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"terminalUrl":{"type":"string"}},"required":["terminalUrl"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"},"build":{"type":"integer"},"buildDate":{"type":"string"},"broker":{"type":"object"},"servers":{"type":"array","items":{"type":"object"}}}}}}},"403":{"description":"Operators only"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /ops/demo-brokers/import:
    post:
      tags: [Ops]
      summary: "Queue MetaQuotes' MetaTrader 4 demo servers for the catalogue"
      description: "Operators only. Reads MetaQuotes' directory ONCE and queues every demo-looking MetaTrader 4 trade server that is not already on the shelf, or just the `servers` given. Nothing is looked up per server here: a worker walks the queue at one server every 90 seconds, which keeps the gateway far below the directory's per-address throttle of about a hundred lookups. Audited as `demo.catalogue_import_queued`."
      x-scope: "trade"
      requestBody: {"required":false,"content":{"application/json":{"schema":{"type":"object","properties":{"servers":{"type":"array","items":{"type":"string"},"description":"name them yourself instead of taking the directory"}}}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"queued":{"type":"integer"},"considered":{"type":"integer"},"gatewayRows":{"type":"integer"},"counts":{"type":"object","additionalProperties":{"type":"integer"}}}}}}},"403":{"description":"Operators only"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"},"503":{"description":"MetaQuotes' multi-broker terminal is not reachable from this gateway"}}
    get:
      tags: [Ops]
      summary: "How the catalogue is filling"
      description: "Operators only. The queue's counts (queued, imported, refused by the broker, to look at), how many gateway rows are on the shelf, and the last twenty servers that were refused or got stuck, with the words the directory used."
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"counts":{"type":"object","additionalProperties":{"type":"integer"}},"gatewayRows":{"type":"integer"},"problems":{"type":"array","items":{"type":"object","properties":{"server":{"type":"string"},"state":{"type":"string"},"problem":{"type":"string"},"at":{"type":"string","format":"date-time"}}}}}}}}},"403":{"description":"Operators only"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /ops/demo-brokers/gateway:
    post:
      tags: [Ops]
      summary: "Read MetaQuotes' multi-broker terminal for one server"
      description: "Operators only. Says whether that trade server opens demo accounts through MetaQuotes' multi-broker terminal and, when it does, the broker's company name, the account types it offers (the group a `gateway` catalogue row uses) and the leverages. A server whose demo opening is switched off answers 409 with `details.reason` `demo_not_offered`, and MIRRO leaves it at that. As of October 2026 only MetaTrader 4 servers are open this way."
      x-scope: "trade"
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"server":{"type":"string"}},"required":["server"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"server":{"type":"string"},"platform":{"const":"mt4"},"company":{"type":"string"},"gateway":{"type":"string"},"groups":{"type":"array","items":{"type":"string"}},"leverages":{"type":"array","items":{"type":"integer"}}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Operators only"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"},"503":{"description":"MetaQuotes' multi-broker terminal is not reachable from this gateway"}}
  /ops/demo-brokers/{id}:
    put:
      tags: [Ops]
      summary: "Change a catalogue broker"
      description: "Send the `revision` you edited; verified again before it saves."
      x-scope: "trade"
      parameters: [{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/DemoBrokerInput"},{"type":"object","properties":{"revision":{"type":"integer"}},"required":["revision"]}]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoBrokerOps"}}}},"400":{"description":"Invalid details"},"403":{"description":"Operators only"},"409":{"description":"Account, stage or evidence needs review"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /ops/demo-brokers/{id}/verify:
    post:
      tags: [Ops]
      summary: "Verify a catalogue broker again"
      x-scope: "trade"
      parameters: [{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/DemoBrokerOps"},{"type":"object","properties":{"problem":{"type":["string","null"]}}}]}}}},"403":{"description":"Operators only"},"404":{"description":"No such demo broker"},"501":{"description":"Demo account opening is not switched on for this gateway"}}
  /ops/demo-accounts:
    get:
      tags: [Ops]
      summary: "Every demo-account opening, across tenants"
      description: "Operators only. The product's openings newest first, with the tenant, the person, the broker, the state and why it is stuck. Never a password: `hasCredentials` says whether one is sealed on the row."
      parameters: [{"in":"query","name":"state","schema":{"enum":["open","awaiting_code","uncertain","failed"]}},{"in":"query","name":"limit","schema":{"type":"integer","minimum":1,"maximum":200}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/DemoAccountOps"}}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Operators only"},"503":{"description":"The operators' desk needs the engine connection"}}
  /ops/demo-accounts/{id}:
    get:
      tags: [Ops]
      summary: "One opening, with the broker's mail"
      description: "Operators only. Adds every message the broker sent to this opening's relay address: who it came from, its subject, the codes read out of it, what came of it and how long the body is. The body itself is never served."
      parameters: [{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/DemoAccountOps"},{"type":"object","properties":{"mail":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"from":{"type":"string"},"subject":{"type":"string"},"codes":{"type":"array","items":{"type":"integer"}},"outcome":{"type":"string"},"receivedAt":{"type":"string","format":"date-time"},"bodyChars":{"type":"integer"}}}}}}]}}}},"403":{"description":"Operators only"},"404":{"description":"No such demo account"},"503":{"description":"The operators' desk needs the engine connection"}}
  /ops/demo-accounts/{id}/settle:
    post:
      tags: [Ops]
      summary: "Settle an opening a person had to look at"
      description: "Operators only. An opening whose reply was lost is recorded `uncertain`: the account may exist at the broker and only a person can check. `outcome: \"open\"` records the login and the passwords the broker shows, sealed for the customer's own tenant so they read them like any other opening; `outcome: \"failed\"` records that nothing was created. Audited as `demo.settled`. An already-open row is refused."
      x-scope: "trade"
      parameters: [{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"outcome":{"enum":["open","failed"]},"login":{"type":"string","pattern":"^[0-9]{1,40}$"},"password":{"type":"string","writeOnly":true},"investorPassword":{"type":"string","writeOnly":true},"note":{"type":"string","maxLength":500}},"required":["outcome"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DemoAccountOps"}}}},"400":{"description":"Invalid details"},"403":{"description":"Operators only"},"404":{"description":"No such demo account"},"409":{"description":"This demo account is already open"},"503":{"description":"The operators' desk needs the engine connection"}}
  /me/stage-deliveries:
    get:
      tags: [Firm]
      summary: "Your stage handovers"
      description: "Only the signed-in member\u2019s deliveries, latest 250."
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FirmStageDelivery"}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /me/challenges/{challengeId}/accounts:
    get:
      tags: [Firm]
      summary: "Challenge account history"
      description: "Account lineage across stages and funded cycles, including retired accounts. Only your own challenge."
      parameters: [{"in":"path","name":"challengeId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FirmStageAccount"}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/challenges/{challengeId}/accounts:
    get:
      tags: [Firm]
      summary: "Challenge account history (staff)"
      description: "Account lineage across stages and funded cycles, including retired accounts. Staff only."
      parameters: [{"in":"path","name":"challengeId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FirmStageAccount"}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /me/stage-accounts/{accountId}/credentials:
    post:
      tags: [Firm]
      summary: "View your current stage credentials"
      description: "Signed-in trader session only. Current active/funded account only; refused during handover or after retirement. Audited and not cached."
      x-scope: "trade"
      parameters: [{"in":"path","name":"accountId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string"},"login":{"type":"string"},"server":{"type":"string"},"password":{"type":"string"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/stage-deliveries/{deliveryId}/approve:
    post:
      tags: [Firm]
      summary: "Approve a funded handover"
      description: "Owner or risk role. Both identity and signed agreement must have been reviewed. Records a staff attestation; it does not perform external KYC."
      x-scope: "trade"
      parameters: [{"in":"path","name":"deliveryId","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"identityReviewed":{"const":true},"agreementReviewed":{"const":true}},"required":["identityReviewed","agreementReviewed"]}}}}
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/stage-deliveries/{deliveryId}/retry:
    post:
      tags: [Firm]
      summary: "Retry reviewed account delivery"
      description: "Owner or operations. Stable target account ID, reserved stock and fenced delivery lease are retained."
      x-scope: "trade"
      parameters: [{"in":"path","name":"deliveryId","required":true,"schema":{"type":"string","format":"uuid"}}]
      responses: {"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}
  /firm/challenges/{challengeId}/continuations:
    post:
      tags: [Firm]
      summary: "Record a completed payout and queue funded continuation"
      description: "Owner only. Staff-attested settlement, not a transfer or provider verification. Requires current funded account, no open trades/orders/copy links/unsettled executions and fresh broker data. Exactly one successor per source account and one use per payout reference. Retries must retain all evidence. A fresh account at the original balance and rules starts the next funded cycle; this endpoint does not implement balance scaling."
      x-scope: "trade"
      parameters: [{"in":"path","name":"challengeId","required":true,"schema":{"type":"string","format":"uuid"}}]
      requestBody: {"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"sourceAccountId":{"type":"string","format":"uuid"},"reference":{"type":"string","minLength":1,"maxLength":120},"amount":{"type":["string","number"]},"currency":{"type":"string","pattern":"^[A-Z]{3}$"},"note":{"type":"string","minLength":10,"maxLength":1000},"payoutCompleted":{"const":true}},"required":["sourceAccountId","reference","amount","currency","note","payoutCompleted"]}}}}
      responses: {"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FirmStageDelivery"}}}},"400":{"description":"Invalid details"},"403":{"description":"Firm role, session or private capability is not allowed"},"409":{"description":"Account, stage or evidence needs review"}}

components:
  securitySchemes:
    relaySignature:
      type: apiKey
      in: header
      name: X-Relay-Signature
      description: Internal email worker HMAC, not a customer API key
    consoleSession:
      type: http
      scheme: bearer
      description: An ms_ bearer session minted by the MirroAPI gateway. API keys are not accepted.
    apiKey:
      type: http
      scheme: bearer
      description: API key with scopes.
  parameters:
    accountId: { in: path, name: accountId, required: true, schema: { type: string } }
    copyId: { in: path, name: copyId, required: true, schema: { type: string } }
    keyId: { in: path, name: keyId, required: true, schema: { type: string } }
    ticket: { in: path, name: ticket, required: true, schema: { type: string } }
    symbol: { in: path, name: symbol, required: true, schema: { type: string } }
    platform: { in: path, name: platform, required: true, schema: { $ref: '#/components/schemas/PlatformId' } }
    limit: { in: query, name: limit, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 } }
    cursor: { in: query, name: cursor, schema: { type: string } }
    idempotencyKey: { in: header, name: Idempotency-Key, schema: { type: string } }
  responses:
    Account:
      description: Account
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Account' }
    KeyList:
      description: Keys
      content:
        application/json:
          schema: { type: array, items: { $ref: '#/components/schemas/ApiKey' } }
  schemas:
    MiraWebSearchInput:
      type: object
      required: [query]
      additionalProperties: false
      properties:
        query: { type: string, minLength: 1, maxLength: 500 }
        limit: { type: integer, minimum: 1, maximum: 10, default: 5 }
        domains: { type: array, maxItems: 8, items: { type: string }, description: Include these hostnames and their subdomains }
        maxAgeDays: { type: integer, minimum: 1, maximum: 365, default: 30, description: Maximum crawl age rather than publication age }
    MiraWebFetchInput:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url: { type: string, format: uri, maxLength: 1800 }
        maxChars: { type: integer, minimum: 1000, maximum: 30000, default: 12000 }
        fresh: { type: boolean, default: false, description: Bypass the short caller-scoped cache }
        query: { type: string, minLength: 1, maxLength: 500, description: Public research keywords to select relevant passages from long pages }
    MiraWebSource:
      type: object
      properties:
        url: { type: string, format: uri }
        title: { type: string }
        fetchedAt: { type: string, format: date-time }
        publishedAt: { type: [string, 'null'], format: date-time, description: Publisher-supplied date when available }
        contentHash: { type: string }
    MiraWebSearchResult:
      type: object
      properties:
        object: { type: string, enum: [mira.web.search] }
        query: { type: string }
        results:
          type: array
          items:
            allOf:
              - { $ref: '#/components/schemas/MiraWebSource' }
              - type: object
                properties:
                  id: { type: string }
                  rank: { type: integer }
                  excerpt: { type: string }
                  match: { type: string, enum: [all_terms, partial] }
        corpus:
          type: object
          properties:
            documents: { type: integer }
            latestCrawl: { type: [string, 'null'], format: date-time }
            oldestCrawl: { type: [string, 'null'], format: date-time }
            engine: { type: string }
            coverage: { type: string }
        partial: { type: boolean }
        durationMs: { type: number }
        source: { type: string, enum: [mirro-index] }
        notice: { type: string }
    MiraWebPage:
      allOf:
        - { $ref: '#/components/schemas/MiraWebSource' }
        - type: object
          properties:
            object: { type: string, enum: [mira.web.page] }
            requestedUrl: { type: string, format: uri }
            selection: { type: string, enum: [beginning, query_passages, no_query_match], description: Lines preserve original source positions when passages are selected }
            markdown: { type: string }
            lines: { type: array, items: { type: object, properties: { number: { type: integer }, text: { type: string } } } }
            contentType: { type: string }
            truncated: { type: boolean }
            cache: { type: string, enum: [hit, miss] }
            durationMs: { type: number }
            links: { type: array, items: { type: object, properties: { url: { type: string, format: uri }, title: { type: string } } } }
            source: { type: string, enum: [mirro-reader] }
            notice: { type: string }
    MiraChatRequest:
      type: object
      required: [model, messages]
      description: The standard Chat Completions body. Fields not listed here are refused by name, except the ignored sampling fields.
      properties:
        model: { type: string, enum: [mira, mira-deep] }
        messages:
          type: array
          minItems: 1
          maxItems: 200
          description: system, developer, user (text and base64 image_url parts), assistant (content and tool_calls) and tool messages. The last message is from the user or a tool; every tool call needs its tool message.
          items: { type: object, additionalProperties: true, required: [role], properties: { role: { type: string, enum: [system, developer, user, assistant, tool] } } }
        tools:
          type: array
          maxItems: 32
          items:
            type: object
            required: [type, function]
            properties:
              type: { type: string, enum: [function] }
              function:
                type: object
                required: [name]
                properties:
                  name: { type: string, pattern: '^[a-zA-Z0-9_-]{1,64}$', description: Names starting with mira_ are reserved }
                  description: { type: string, maxLength: 4000 }
                  parameters: { type: object, additionalProperties: true }
                  strict: { type: boolean, description: Arguments are checked against parameters and repaired once }
        tool_choice: { description: 'auto, none, required, or { type: function, function: { name } }', oneOf: [{ type: string, enum: [auto, none, required] }, { type: object, additionalProperties: true }] }
        parallel_tool_calls: { type: boolean, default: true, description: false returns at most one tool call }
        stop: { description: Up to 4 sequences; the reply is cut at the first one, also in a stream, oneOf: [{ type: string }, { type: array, maxItems: 4, items: { type: string, maxLength: 200 } }] }
        response_format: { type: object, additionalProperties: true, description: 'text, json_object, or json_schema { name, schema }; the reply is checked and repaired once' }
        stream: { type: boolean, default: false }
        stream_options: { type: object, properties: { include_usage: { type: boolean } } }
        max_completion_tokens: { type: integer, minimum: 1, description: For the whole completion including Mira's tool steps; capped at the configured model maximum }
        max_tokens: { type: integer, minimum: 1 }
        user: { type: string, maxLength: 256 }
        mira:
          type: object
          additionalProperties: false
          properties:
            tools: { type: boolean, default: true, description: "Mira's own tools" }
            web: { type: boolean, default: true, description: Offer MIRRO web tools when configured; also requires mira.tools }
            knowledge: { type: boolean, default: true, description: Retrieval from your knowledge base and Mira's guides }
            max_steps: { type: integer, minimum: 1, maximum: 8, default: 6 }
            knowledge_max_age_seconds: { type: integer, minimum: 60, maximum: 31536000, description: Excludes older business documents by observation or saved date; does not refresh sources or filter profile facts }
    MiraChatCompletion:
      type: object
      properties:
        id: { type: string }
        object: { type: string, enum: [chat.completion] }
        created: { type: integer }
        model: { type: string }
        system_fingerprint: { type: string }
        choices:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              finish_reason: { type: string, enum: [stop, tool_calls, length] }
              message:
                type: object
                properties:
                  role: { type: string, enum: [assistant] }
                  content: { type: [string, 'null'] }
                  refusal: { type: [string, 'null'] }
                  tool_calls: { type: array, items: { type: object, properties: { id: { type: string }, type: { type: string, enum: [function] }, function: { type: object, properties: { name: { type: string }, arguments: { type: string } } } } } }
        usage: { type: object, properties: { prompt_tokens: { type: integer }, completion_tokens: { type: integer }, total_tokens: { type: integer }, prompt_tokens_details: { type: object, properties: { cached_tokens: { type: integer } } } } }
        mira:
          type: object
          description: Mira's extension; Chat Completions client libraries keep it (reply.mira, reply.model_extra["mira"])
          properties:
            steps: { type: integer }
            tools_used: { type: array, items: { type: string } }
            web_sources: { type: array, description: Sources supplied to the model rather than guaranteed claim support, items: { type: object, properties: { url: { type: string, format: uri }, title: { type: string }, fetchedAt: { type: string, format: date-time }, contentHash: { type: string }, kind: { type: string, enum: [indexed_excerpt, page_read] } } } }
            research:
              type: object
              description: Present for plain text research using only web tools. Source lines and URLs are checked against actual reads and claims receive an automated support check, not independent fact verification.
              properties:
                status: { type: string, enum: [source_supported, insufficient_evidence] }
                reason: { type: [string, 'null'] }
                claims:
                  type: array
                  items:
                    type: object
                    properties:
                      text: { type: string }
                      citations:
                        type: array
                        items:
                          type: object
                          properties:
                            source: { type: string }
                            url: { type: string, format: uri }
                            title: { type: string }
                            contentHash: { type: string }
                            quote: { type: string, description: Text copied by the server from the cited source lines }
                            startLine: { type: integer, minimum: 1 }
                            endLine: { type: integer, minimum: 1 }
            knowledge: { type: array, items: { type: object, properties: { source: { type: string, enum: [business, mira] }, title: { type: string }, section: { type: string } } } }
            format_valid: { type: boolean }
            cost_usd: { type: string, description: Provider-cost estimate, not an invoice }
            billing: { type: string }
    MiraModel:
      type: object
      properties:
        id: { type: string, enum: [mira, mira-deep] }
        object: { type: string, enum: [model] }
        created: { type: integer }
        owned_by: { type: string, enum: [mirro] }
    MiraProfile:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name: { type: string, maxLength: 120 }
        industry: { type: string, enum: [prop_firm, brokerage, trading_education, fintech, ecommerce, saas, services, other], default: other }
        description: { type: string, maxLength: 2000 }
        website: { type: string, format: uri, description: https only }
        audience: { type: string, maxLength: 500 }
        voice: { type: string, maxLength: 500 }
        language: { type: string, maxLength: 35, description: auto, a language tag (en, pt-BR) or a language name }
        support: { type: object, additionalProperties: false, properties: { email: { type: string, format: email }, url: { type: string, format: uri }, hours: { type: string, maxLength: 200 }, escalation: { type: string, maxLength: 500 } } }
        products: { type: string, maxLength: 4000 }
        facts: { type: array, maxItems: 40, items: { type: object, required: [label, value], additionalProperties: false, properties: { label: { type: string, maxLength: 80 }, value: { type: string, maxLength: 500 } } } }
        rules: { type: object, additionalProperties: false, properties: { always: { type: array, maxItems: 20, items: { type: string, maxLength: 300 } }, never: { type: array, maxItems: 20, items: { type: string, maxLength: 300 } } } }
        compliance: { type: string, maxLength: 1500 }
    MiraProfileAnswer:
      type: object
      properties:
        object: { type: string, enum: [mira.profile] }
        profile: { oneOf: [{ $ref: '#/components/schemas/MiraProfile' }, { type: 'null' }] }
        industries: { type: array, items: { type: string } }
    MiraKnowledgeInput:
      type: object
      required: [title, text]
      additionalProperties: false
      properties:
        title: { type: string, maxLength: 200 }
        text: { type: string, maxLength: 500000, description: Plain text or Markdown; headings become sections }
        externalId: { type: string, maxLength: 200, pattern: '^[\w.:/@-]+$', description: Your id; re-posting it replaces the document }
        sourceUrl: { type: string, format: uri, description: https only }
        tags: { type: array, maxItems: 16, items: { type: string, maxLength: 40 } }
    MiraKnowledgeDocument:
      type: object
      properties:
        id: { type: string, format: uuid }
        object: { type: string, enum: [mira.knowledge.document] }
        title: { type: string }
        externalId: { type: [string, 'null'] }
        sourceUrl: { type: [string, 'null'] }
        sourceKind: { type: string, enum: [manual, website] }
        checkedAt: { type: [string, 'null'], format: date-time, description: Last website observation; not verification of its claims }
        truncated: { type: boolean, description: Only part of the page was captured }
        tags: { type: array, items: { type: string } }
        characters: { type: integer }
        sections: { type: integer }
        version: { type: integer }
        sha256: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        unchanged: { type: boolean }
        replaced: { type: boolean }
    MiraKnowledgeHit:
      type: object
      properties:
        source: { type: string, enum: [business, mira] }
        title: { type: string }
        section: { type: [string, 'null'] }
        text: { type: string }
        documentId: { type: string, format: uuid }
        id: { type: string, description: The Mira guide id (source mira) }
        sourceUrl: { type: [string, 'null'] }
        score: { type: number }
    MiraInput:
      type: object
      additionalProperties: false
      required: [requestId]
      properties:
        requestId: { type: string, format: uuid }
        input: { type: string, maxLength: 24000 }
        instructions: { type: string, maxLength: 8000 }
        mode: { type: string, enum: [balanced, deep], default: balanced }
        previousResponseId: { type: string, format: uuid }
        tools:
          type: array
          maxItems: 16
          items:
            type: object
            required: [name, description, parameters]
            properties:
              name: { type: string, pattern: '^\w{1,64}$' }
              description: { type: string, maxLength: 2000 }
              parameters: { type: object, additionalProperties: true, description: Object JSON schema; validate proposed arguments in your application }
        toolResults:
          type: array
          maxItems: 8
          items:
            type: object
            required: [callId, result]
            properties:
              callId: { type: string, maxLength: 160 }
              result: { type: string, maxLength: 24000 }
        images:
          type: array
          maxItems: 2
          items:
            type: object
            required: [name, mime, base64]
            description: At most 1 MB and 2048 pixels per side; raster content is validated
            properties:
              name: { type: string }
              mime: { type: string, enum: [image/png, image/jpeg, image/webp] }
              base64: { type: string }
    MiraResponse:
      type: object
      required: [id, requestId, status, model, usage, createdAt, expiresAt]
      properties:
        id: { type: string, format: uuid }
        requestId: { type: string, format: uuid }
        status: { type: string, enum: [running, completed, requires_action, incomplete, failed, uncertain] }
        model: { type: string }
        outputText: { type: string }
        toolCalls:
          type: array
          items:
            type: object
            required: [id, name, args]
            properties:
              id: { type: string }
              name: { type: string }
              args: { type: object, additionalProperties: true }
        usage:
          type: object
          properties:
            inputTokens: { type: integer, minimum: 0 }
            outputTokens: { type: integer, minimum: 0 }
            cachedInputTokens: { type: integer, minimum: 0 }
            cacheWriteTokens: { type: integer, minimum: 0 }
            estimatedCostUsd: { type: [string, 'null'], description: Provider-cost estimate; null if usage is not confirmed }
            reservedUsd: { type: string }
            billing: { type: string }
        error:
          type: object
          properties:
            code: { type: string, enum: [MIRA_MONTHLY_BUDGET, MIRA_DAILY_BUDGET, MIRA_PROVIDER_BILLING, MIRA_KEY_REVOKED, MIRA_PROVIDER_UNAVAILABLE] }
            message: { type: string }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        replayed: { type: boolean }
    FirmAccountRequest:
      type: object
      required: [id, reference, programId, userId, state, accountId, challengeId, message]
      properties:
        id: {type: string, format: uuid}
        reference: {type: string}
        programId: {type: string, format: uuid}
        userId: {type: string, format: uuid}
        state: {type: string, enum: [queued, opening, connecting, ready, needs_review, cancelled]}
        accountId: {type: [string, 'null'], format: uuid}
        challengeId: {type: [string, 'null'], format: uuid}
        broker: {type: string}
        platform: {type: string}
        server: {type: string}
        login: {type: [string, 'null']}
        holderName: {type: [string, 'null']}
        programName: {type: [string, 'null']}
        phase: {type: [string, 'null']}
        message: {type: string}
        createdAt: {type: string, format: date-time}
    FirmWebsiteSection:
      type: "object"
      required: ["id", "type", "tone", "title"]
      properties: {"id": {"type": "string", "maxLength": 60}, "type": {"enum": ["hero", "features", "story", "image", "steps", "faq", "programs", "cta", "contact", "downloads", "text", "metrics", "quotes", "scene"]}, "tone": {"enum": ["light", "soft", "dark"]}, "eyebrow": {"type": "string", "maxLength": 60}, "title": {"type": "string", "maxLength": 140}, "body": {"type": "string", "maxLength": 2000}, "mediaId": {"type": ["string", "null"], "format": "uuid"}, "alt": {"type": "string", "maxLength": 160}, "button": {"type": "string", "maxLength": 40}, "target": {"type": "string", "maxLength": 40}, "items": {"type": "array", "maxItems": 8, "items": {"type": "object", "properties": {"title": {"type": "string", "maxLength": 100}, "body": {"type": "string", "maxLength": 600}}}}, "style": {"type": "object", "properties": {"layout": {"enum": ["auto", "split", "image-left", "center", "banner"]}, "align": {"enum": ["left", "center"]}, "spacing": {"enum": ["compact", "regular", "spacious"]}, "heading": {"enum": ["small", "medium", "large"]}, "imageFit": {"enum": ["cover", "contain"]}, "imagePosition": {"enum": ["center", "top", "bottom", "left", "right"]}}}, "scene": {"type": "object", "additionalProperties": false, "description": "Only on a scene section (a motion hero). Colours come from the template tokens, never the document. shader, particles and glass draw with WebGL; depth and counters need none; counters animate up to four firm-supplied items. One scene per page, no mediaId, style.layout auto|image-left|center; publishing needs alt (counters: complete items). Missing settings default to shader, brand, medium, motion true", "properties": {"preset": {"enum": ["shader", "particles", "glass", "depth", "counters"]}, "palette": {"enum": ["brand", "secondary", "contrast"]}, "intensity": {"enum": ["low", "medium", "high"]}, "motion": {"type": "boolean"}}}}
    FirmWebsitePalette:
      type: object
      additionalProperties: false
      description: 'Firm-owned colours — publication requires main and secondary text contrast of at least 4.5:1 against page and card backgrounds'
      required: [background, surface, text, muted, secondary]
      properties:
        background: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        surface: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        text: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        muted: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        secondary: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
    FirmAppearance:
      type: [object, 'null']
      description: 'Design from the active published website only, shared with sign-in and the trader workspace — never a draft'
      properties:
        template: { enum: [editorial, midnight, canvas, apex, summit, nexus, horizon, prestige, velocity, precision] }
        accent: { type: string, pattern: '^#[0-9a-fA-F]{6}$' }
        palette: { $ref: '#/components/schemas/FirmWebsitePalette' }
        typography: { enum: [sora, system, editorial] }
        corners: { enum: [soft, sharp, rounded] }
        logoId: { type: [string, 'null'], format: uuid }
    FirmPolicyDraft:
      type: object
      required: [mode, sections, reviewed]
      description: Plain text authored by the firm, up to 40,000 characters per policy; drafts are never public
      properties:
        mode: {enum: [write, link]}
        reviewed: {type: boolean}
        sections:
          type: array
          maxItems: 24
          items:
            type: object
            required: [heading, body]
            properties:
              heading: {type: string, maxLength: 120}
              body: {type: string, maxLength: 6000}
    FirmWebsiteDocument:
      type: "object"
      required: ["version", "template", "accent", "pages"]
      properties: {"version": {"const": 1}, "template": {"enum": ["editorial", "midnight", "canvas", "apex", "summit", "nexus", "horizon", "prestige", "velocity", "precision"]}, "accent": {"type": "string", "pattern": "^#[0-9a-fA-F]{6}$"}, "logoId": {"type": ["string", "null"], "format": "uuid"}, "company": {"type": "object", "properties": {"legalName": {"type": "string", "maxLength": 150}, "termsUrl": {"type": "string", "maxLength": 500}, "privacyUrl": {"type": "string", "maxLength": 500}, "riskText": {"type": "string", "maxLength": 1000}, "supportEmail": {"type": "string", "maxLength": 254}}}, "pages": {"type": "array", "minItems": 1, "maxItems": 32, "items": {"type": "object", "required": ["id", "slug", "title", "sections"], "properties": {"id": {"type": "string", "maxLength": 60}, "slug": {"type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$"}, "title": {"type": "string", "maxLength": 50}, "description": {"type": "string", "maxLength": 180}, "sections": {"type": "array", "maxItems": 24, "items": {"$ref": "#/components/schemas/FirmWebsiteSection"}}, "navigation": {"type": "boolean", "default": true}}}}, "typography": {"enum": ["sora", "system", "editorial"]}, "corners": {"enum": ["soft", "sharp", "rounded"]}, "palette": {"$ref": "#/components/schemas/FirmWebsitePalette"}, "policies": {"type": "object", "additionalProperties": false, "properties": {"terms": {"$ref": "#/components/schemas/FirmPolicyDraft"}, "privacy": {"$ref": "#/components/schemas/FirmPolicyDraft"}}}, "announcement": {"type": ["object", "null"], "description": "Optional site-wide bar above navigation. Null uses the template default; enabled=false hides it. Sliding motion respects reduced motion and can be paused.", "additionalProperties": false, "required": ["enabled"], "properties": {"enabled": {"type": "boolean"}, "text": {"type": "string", "maxLength": 200}, "motion": {"enum": ["slide", "static"], "default": "slide"}, "speed": {"enum": ["slow", "normal"], "default": "normal"}, "tone": {"enum": ["brand", "neutral"], "default": "brand"}}}}
    FirmWebsiteAsset:
      type: "object"
      properties: {"id": {"type": "string", "format": "uuid"}, "name": {"type": "string", "maxLength": 120}, "mime": {"type": "string"}, "size": {"type": "integer"}, "width": {"type": ["integer", "null"]}, "height": {"type": ["integer", "null"]}, "createdAt": {"type": "string", "format": "date-time"}}
    FirmWebsiteStudio:
      type: "object"
      properties: {"revision": {"type": "integer", "minimum": 0}, "document": {"oneOf": [{"$ref": "#/components/schemas/FirmWebsiteDocument"}, {"type": "null"}]}, "publishedRevision": {"type": ["integer", "null"]}, "publishedAt": {"type": ["string", "null"]}, "publishedPages": {"oneOf": [{"type": "array", "maxItems": 32, "items": {"$ref": "#/components/schemas/FirmWebsitePageHash"}}, {"type": "null"}], "description": "Pages of the live publication (publishedRevision), in site order; null when nothing is published"}, "draftPages": {"oneOf": [{"type": "array", "maxItems": 32, "items": {"$ref": "#/components/schemas/FirmWebsitePageHash"}}, {"type": "null"}], "description": "Pages of the saved draft (revision), in site order; null before the first save"}, "assets": {"type": "array", "items": {"$ref": "#/components/schemas/FirmWebsiteAsset"}}, "history": {"type": "array", "maxItems": 50, "items": {"type": "object", "properties": {"revision": {"type": "integer", "minimum": 0}, "createdAt": {"type": "string", "format": "date-time"}}}}, "limits": {"type": "object"}}
    FirmWebsitePageHash:
      type: "object"
      description: "One page for a live-versus-draft comparison, matched by id. hash covers everything on the page except its id (address, name, description, navigation, sections), normalised as publishing does; site-wide settings are in no page's hash"
      properties: {"id": {"type": "string"}, "slug": {"type": "string"}, "title": {"type": "string"}, "hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}}
      required: ["id", "slug", "title", "hash"]
    FirmStageDelivery:
      type: object
      properties: {"id":{"type":"string","format":"uuid"},"challengeId":{"type":"string","format":"uuid"},"userId":{"type":"string","format":"uuid"},"programName":{"type":"string"},"trader":{"type":"object","properties":{"name":{"type":["string","null"]},"email":{"type":["string","null"]}}},"phaseIndex":{"type":"integer","minimum":0},"phase":{"type":["string","null"]},"cycleIndex":{"type":"integer","minimum":0},"reason":{"enum":["phase","continuation"]},"state":{"enum":["waiting_review","waiting_stock","connecting","review","ready"]},"accountId":{"type":["string","null"]},"lastError":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"settlementSource":{"enum":["staff_recorded",null]}}
    FirmStageAccount:
      type: object
      properties: {"accountId":{"type":"string","format":"uuid"},"phaseIndex":{"type":"integer","minimum":0},"cycleIndex":{"type":"integer","minimum":0},"login":{"type":"string"},"server":{"type":"string"},"platform":{"type":"string"},"assignedAt":{"type":"string","format":"date-time"},"retiredAt":{"type":["string","null"],"format":"date-time"}}
    FirmPoolCoverage:
      type: object
      properties: {"program_id":{"type":"string","format":"uuid"},"name":{"type":"string"},"currency":{"type":"string"},"starting_balance":{"type":"string"},"phases":{"type":"array","items":{"$ref":"#/components/schemas/ProgramPhase"}},"phase_index":{"type":"integer","minimum":0},"phase":{"type":"string"},"available":{"type":"integer","minimum":0},"reserved":{"type":"integer","minimum":0},"assigned":{"type":"integer","minimum":0}}
    FirmPoolSession:
      type: object
      properties: {"id":{"type":"string","format":"uuid"},"program_id":{"type":"string","format":"uuid"},"program_name":{"type":"string"},"counts":{"type":"array","items":{"type":"integer","minimum":0}},"currency":{"type":"string"},"starting_balance":{"type":"string"},"expires_at":{"type":"string","format":"date-time"},"revoked_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"saved":{"type":"integer","minimum":0}}
    FactoryOpenResult:
      type: object
      properties: {"requestId":{"type":"string","format":"uuid"},"state":{"enum":["created","awaiting_code","uncertain","failed","open"]},"stockId":{"type":["string","null"],"format":"uuid"},"login":{"type":["string","null"]},"server":{"type":["string","null"]},"holderName":{"type":["string","null"]},"email":{"type":["string","null"]},"channels":{"type":["object","null"]},"error":{"type":["string","null"]},"password":{"type":["string","null"]},"investorPassword":{"type":["string","null"]},"accountId":{"type":"string","format":"uuid"},"userId":{"type":"string","format":"uuid"},"workflow":{"type":"object"}}
    DemoBroker:
      type: object
      properties: {"available":{"type":"boolean","description":"false while MetaQuotes is refusing this gateway's address: a gateway broker cannot be opened until it answers again, and pressing Open would only fail"},"id":{"type":"string","format":"uuid"},"key":{"type":"string"},"label":{"type":"string"},"platform":{"enum":["mt4","mt5"]},"source":{"enum":["terminal","gateway"],"description":"gateway = MetaQuotes' multi-broker terminal (MetaTrader 4)"},"server":{"type":"string"},"currency":{"type":["string","null"],"description":"null when the account type did not name one"},"leverages":{"type":"array","items":{"type":"integer"}},"defaultLeverage":{"type":"integer"},"deposits":{"type":"array","items":{"type":"number"}},"defaultDeposit":{"type":"number"},"blurb":{"type":"string"},"website":{"type":"string"},"country":{"type":"string"},"groupName":{"type":"string"},"company":{"type":["string","null"]},"codeRequired":{"type":"boolean","description":"true when the broker e-mails a code before opening (read from the terminal group flags)"},"terminalHost":{"type":["string","null"]},"countries":{"type":"array","items":{"type":"string"},"description":"the countries this demo group is for, when the broker restricts it; empty means it does not"}}
    DemoBrokerInput:
      type: object
      properties: {"key":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{1,39}$"},"label":{"type":"string"},"source":{"enum":["terminal","gateway"],"default":"terminal","description":"terminal = the broker's own hosted MetaTrader 5 web terminal; gateway = MetaQuotes' multi-broker terminal, which opens MetaTrader 4 accounts"},"platform":{"enum":["mt4","mt5"],"description":"follows source: mt5 for a terminal row, mt4 for a gateway row"},"server":{"type":"string"},"terminalUrl":{"type":"string","description":"only for source=terminal"},"groupId":{"type":"string"},"groupName":{"type":"string"},"currency":{"type":"string","pattern":"^[A-Z]{3}$"},"leverages":{"type":"array","items":{"type":"integer"}},"defaultLeverage":{"type":"integer"},"deposits":{"type":"array","items":{"type":"number"}},"defaultDeposit":{"type":"number"},"blurb":{"type":"string","maxLength":240},"website":{"type":"string"},"country":{"type":"string"},"enabled":{"type":"boolean"},"sortOrder":{"type":"integer"},"hourlyLimit":{"type":"integer"}}
      required: [key, label, server, groupId, currency, leverages, deposits]
    DemoAccountOps:
      type: object
      description: "An opening as an operator sees it: who, where, what state and why. Never a password."
      properties: {"id":{"type":"string","format":"uuid"},"tenant":{"type":"string"},"person":{"type":"object","properties":{"email":{"type":["string","null"]},"name":{"type":["string","null"]}}},"broker":{"type":"string"},"brokerKey":{"type":"string"},"server":{"type":"string"},"login":{"type":["string","null"]},"currency":{"type":["string","null"],"description":"null when the account type did not name one (MetaTrader 4 through the gateway)"},"deposit":{"type":"number"},"leverage":{"type":"integer"},"holderName":{"type":"string"},"state":{"enum":["open","awaiting_code","uncertain","failed"]},"reason":{"type":["string","null"]},"channels":{"type":["object","null"]},"codeSource":{"enum":["person","relay"]},"relayEmail":{"type":["string","null"]},"email":{"type":"string"},"hasPhone":{"type":"boolean"},"attempts":{"type":"integer"},"brokerCode":{"type":["integer","null"]},"build":{"type":["integer","null"]},"mails":{"type":"integer","description":"messages the broker sent to the relay address"},"hasCredentials":{"type":"boolean"},"accountId":{"type":["string","null"],"format":"uuid"},"connectedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"settledAt":{"type":["string","null"],"format":"date-time"},"opsNote":{"type":["string","null"]}}
    DemoBrokerOps:
      allOf:
        - $ref: '#/components/schemas/DemoBroker'
        - type: object
          properties: {"terminalUrl":{"type":"string"},"groupId":{"type":"string"},"enabled":{"type":"boolean"},"sortOrder":{"type":"integer"},"hourlyLimit":{"type":"integer"},"verifiedAt":{"type":["string","null"],"format":"date-time"},"policy":{"type":"object"},"revision":{"type":"integer"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}
    DemoAccount:
      type: object
      properties: {"id":{"type":"string","format":"uuid"},"brokerId":{"type":"string","format":"uuid"},"brokerLabel":{"type":["string","null"]},"platform":{"enum":["mt4","mt5"]},"server":{"type":"string"},"login":{"type":["string","null"]},"currency":{"type":["string","null"],"description":"null when the account type did not name one (MetaTrader 4 through the gateway)"},"deposit":{"type":"number"},"leverage":{"type":"integer"},"holderName":{"type":"string"},"state":{"enum":["open","awaiting_code","failed","uncertain"]},"reason":{"type":["string","null"]},"channels":{"type":["object","null"]},"accountId":{"type":["string","null"],"format":"uuid"},"connectedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"hasInvestorPassword":{"type":"boolean"},"hasPhone":{"type":"boolean"},"codeSource":{"enum":["person","relay"],"description":"relay = the broker's e-mailed code goes to MIRRO and the account opens by itself"},"relayEmail":{"type":["string","null"]},"password":{"type":["string","null"],"description":"only in the answer to an opening, a code or a credentials read"},"investorPassword":{"type":["string","null"]},"connectProblem":{"type":"string"}}
    FactoryGroup:
      type: object
      properties: {"group":{"type":"string"},"name":{"type":"string"},"currency":{"type":"string"},"leverages":{"type":"array","items":{"type":"integer"}},"accountType":{"type":"integer"},"agreements":{"type":"integer"}}
    FactoryBrokerInput:
      type: object
      properties: {"key":{"type":"string","pattern":"^[a-z0-9][a-z0-9-]{1,39}$"},"label":{"type":"string","maxLength":80},"server":{"type":"string","maxLength":100},"terminalUrl":{"type":"string","description":"https://<host>/terminal"},"groupId":{"type":"string"},"groupName":{"type":"string"},"currency":{"type":"string","pattern":"^[A-Z]{3}$"},"leverage":{"type":"integer","minimum":1,"maximum":3000},"nameFirst":{"type":"string","maxLength":64},"nameSecond":{"type":"string","maxLength":64,"description":"{n} becomes the serial"},"emailPattern":{"type":"string","maxLength":120,"description":"for example pool+{n}@yourfirm.com"},"phone":{"type":"string"},"country":{"type":"string"},"enabled":{"type":"boolean"},"paceSeconds":{"type":"integer","minimum":5,"maximum":3600},"hourlyLimit":{"type":"integer","minimum":1,"maximum":500}}
      required: [key, label, server, terminalUrl, groupId, currency, leverage, nameFirst, emailPattern]
    FactoryBroker:
      allOf:
        - $ref: '#/components/schemas/FactoryBrokerInput'
        - type: object
          properties: {"id":{"type":"string","format":"uuid"},"platform":{"enum":["mt4","mt5"]},"nextSerial":{"type":"integer"},"verifiedAt":{"type":["string","null"],"format":"date-time"},"policy":{"type":"object","description":"what the directory and the terminal said when the row was verified"},"revision":{"type":"integer"},"example":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"}}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}
    FactoryRequest:
      type: object
      properties: {"id":{"type":"string","format":"uuid"},"brokerId":{"type":"string","format":"uuid"},"brokerLabel":{"type":["string","null"]},"server":{"type":["string","null"]},"programId":{"type":"string","format":"uuid"},"programName":{"type":["string","null"]},"phaseIndex":{"type":"integer"},"phase":{"type":["string","null"]},"count":{"type":"integer"},"state":{"enum":["queued","running","paused","done","cancelled"]},"counts":{"type":"object","properties":{"open":{"type":"integer"},"claimed":{"type":"integer"},"awaitingCode":{"type":"integer"},"created":{"type":"integer"},"uncertain":{"type":"integer"},"failed":{"type":"integer"},"discarded":{"type":"integer"}}},"stockExpiresAt":{"type":"string","format":"date-time"},"nextAt":{"type":"string","format":"date-time"},"lastError":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}
    FactorySlot:
      type: object
      properties: {"requestId":{"type":"string","format":"uuid"},"index":{"type":"integer"},"state":{"enum":["open","claimed","awaiting_code","created","uncertain","failed","discarded"]},"serial":{"type":["integer","null"]},"holderName":{"type":["string","null"]},"email":{"type":["string","null"]},"channels":{"type":["object","null"],"properties":{"email":{"type":"boolean"},"phone":{"type":"boolean"}}},"codesEntered":{"type":"boolean"},"attempts":{"type":"integer"},"stockId":{"type":["string","null"],"format":"uuid"},"login":{"type":["string","null"]},"brokerCode":{"type":["integer","null"]},"lastError":{"type":["string","null"]},"claimedAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}
    FirmPool:
      type: object
      properties: {"coverage":{"type":"array","items":{"$ref":"#/components/schemas/FirmPoolCoverage"}},"batches":{"type":"array","items":{"$ref":"#/components/schemas/FirmPoolSession"}},"deliveries":{"type":"array","items":{"$ref":"#/components/schemas/FirmStageDelivery"}}}
    FirmSite:
      type: "object"
      properties: {"published": {"type": "boolean"}, "headline": {"type": "string", "maxLength": 100}, "intro": {"type": "string", "maxLength": 320}, "legalName": {"type": "string", "maxLength": 150}, "termsUrl": {"type": "string", "maxLength": 500}, "privacyUrl": {"type": "string", "maxLength": 500}, "riskText": {"type": "string", "maxLength": 1000}}
    FirmPaymentGateway:
      type: object
      properties:
        provider: {type: string}
        providerId: {enum: [paystack, squad, transactpay, korapay]}
        collectionOwner: {const: firm}
        currencies: {type: array, items: {type: string}}
        fields: {type: array, items: {type: string}}
        help: {type: string}
        configured: {type: boolean}
        revision: {type: integer}
        mode: {enum: [test, live]}
        currency: {type: string}
        enabled: {type: boolean}
        keyChecked: {type: boolean}
        checkedAt: {type: [string, 'null']}
        webhookAt: {type: [string, 'null']}
        webhookUrl: {type: [string, 'null'], description: Owner-only address — includes a private capability for TransactPay, never publish it}
        callbackUrl: {type: [string, 'null']}
        dashboardUrl: {type: string}
        legacyConfigured: {type: boolean}
        canEnable: {type: boolean}
        blocks: {type: array, items: {type: string}}
        rehearsal: {type: boolean}
        history: {type: array, items: {type: object}}
    FirmCheckout:
      type: "object"
      properties: {"provider": {"type": "string", "enum": ["Paystack", "Squad", "TransactPay", "KoraPay"]}, "configured": {"type": "boolean"}, "enabled": {"type": "boolean"}, "mode": {"type": ["string", "null"], "enum": ["test", "live", null]}, "currencies": {"type": "array", "items": {"type": "string"}}, "origin": {"type": ["string", "null"]}, "hostingReady": {"type": "boolean"}, "providerId": {"type": "string", "enum": ["paystack", "squad", "transactpay", "korapay"]}, "gateways": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string", "enum": ["paystack", "squad", "transactpay", "korapay"]}, "name": {"type": "string"}, "mode": {"enum": ["test", "live"]}, "currencies": {"type": "array", "items": {"type": "string"}}, "requiresCustomer": {"type": "boolean"}}}}}
    FirmOrder:
      type: "object"
      properties: {"id": {"type": "string", "format": "uuid"}, "userId": {"type": "string", "format": "uuid"}, "programId": {"type": "string", "format": "uuid"}, "reference": {"type": "string"}, "email": {"type": "string"}, "programName": {"type": "string"}, "amountMinor": {"type": "string"}, "currency": {"type": "string"}, "createdAt": {"type": "string"}, "updatedAt": {"type": "string"}, "name": {"type": ["string", "null"]}, "paidAt": {"type": ["string", "null"]}, "accountId": {"type": ["string", "null"]}, "challengeId": {"type": ["string", "null"]}, "lastError": {"type": ["string", "null"]}, "checkoutUrl": {"type": ["string", "null"]}, "deliveryRetryAllowed": {"type": "boolean"}, "state": {"type": "string", "enum": ["awaiting_payment", "paid", "connecting", "ready", "review", "cancelled"]}, "paymentMode": {"enum": ["test", "live"]}, "paymentProvider": {"enum": ["paystack", "squad", "transactpay", "korapay"]}}
      required: ["id", "reference", "state", "amountMinor", "currency"]
    FirmStock:
      type: "object"
      properties: {"phaseIndex":{"type":"integer","minimum":0,"maximum":3},"id": {"type": "string", "format": "uuid"}, "programId": {"type": "string", "format": "uuid"}, "programName": {"type": "string"}, "platform": {"type": "string"}, "login": {"type": "string"}, "server": {"type": "string"}, "currency": {"type": "string"}, "startingBalance": {"type": "string"}, "state": {"type": "string"}, "expiresAt": {"type": "string"}, "createdAt": {"type": "string"}, "availabilityNote": {"type": ["string", "null"], "description": "Explains why unreserved stock cannot be assigned, for example expiry or a balance/currency mismatch"}}
    FirmSupportTicket:
      type: "object"
      properties: {"id": {"type": "string", "format": "uuid"}, "user_id": {"type": "string", "format": "uuid"}, "subject": {"type": "string"}, "category": {"type": "string"}, "status": {"type": "string"}, "created_at": {"type": "string"}, "updated_at": {"type": "string"}, "order_id": {"type": ["string", "null"]}, "email": {"type": "string"}, "user_name": {"type": ["string", "null"]}, "messages": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "by_user": {"type": "string", "format": "uuid"}, "staff": {"type": "boolean"}, "internal": {"type": "boolean"}, "message": {"type": "string"}, "created_at": {"type": "string"}}}}}
    FirmStoreProgram:
      type: "object"
      properties: {"id": {"type": "string", "format": "uuid"}, "name": {"type": "string"}, "currency": {"type": "string"}, "startingBalance": {"type": "string"}, "price": {"type": "string"}, "phases": {"type": "array", "items": {"type": "object", "properties": {"name": {"type": "string"}, "targetPct": {"type": ["number", "null"]}, "minTradingDays": {"type": "integer"}}}}, "maxDrawdownPct": {"type": "number"}, "dailyLossPct": {"type": ["number", "null"]}, "profitSplitPct": {"type": "number"}, "drawdownMode": {"type": "string"}, "drawdownBasis": {"type": "string"}, "minHoldSeconds": {"type": ["integer", "null"]}, "maxShortHolds": {"type": ["integer", "null"]}, "maxDayProfitSharePct": {"type": ["number", "null"]}, "available": {"type": "boolean"}}
    FirmStorefront:
      type: "object"
      properties: {"slug": {"type": "string"}, "name": {"type": "string"}, "branding": {"$ref": "#/components/schemas/FirmBranding"}, "site": {"$ref": "#/components/schemas/FirmSite"}, "programs": {"type": "array", "items": {"$ref": "#/components/schemas/FirmStoreProgram"}}, "checkout": {"$ref": "#/components/schemas/FirmCheckout"}}
    FirmOrderPage:
      type: "object"
      required: ["items", "total", "nextCursor"]
      properties: {"items": {"type": "array", "items": {"$ref": "#/components/schemas/FirmOrder"}}, "total": {"type": "integer", "minimum": 0}, "nextCursor": {"type": ["string", "null"]}}
    FirmOrderDetail:
      type: "object"
      properties: {"order": {"$ref": "#/components/schemas/FirmOrder"}, "timeline": {"type": "array", "maxItems": 100, "items": {"type": "object", "properties": {"id": {"type": "string"}, "at": {"type": "string", "format": "date-time"}, "title": {"type": "string"}, "detail": {"type": "string"}}}}, "review": {"type": "object", "properties": {"program": {"type": "string"}, "rules": {"$ref": "#/components/schemas/ProgramInput"}, "terms": {"type": "object", "properties": {"legalName": {"type": "string"}, "termsUrl": {"type": "string"}, "acceptedAt": {"type": "string", "format": "date-time"}}}}}}
    FirmStockCoverage:
      type: "object"
      properties: {"programId": {"type": "string"}, "name": {"type": "string"}, "currency": {"type": "string"}, "startingBalance": {"type": "string"}, "priceCurrency": {"type": "string"}, "price": {"type": ["string", "null"]}, "available": {"type": "integer", "minimum": 0}, "reserved": {"type": "integer", "minimum": 0}, "expiringSoon": {"type": "integer", "minimum": 0}, "unavailable": {"type": "integer", "minimum": 0}}
    FirmLaunchStep:
      type: "object"
      properties: {"id": {"type": "string"}, "title": {"type": "string"}, "detail": {"type": "string"}, "owner": {"type": "string"}, "page": {"type": "string"}, "state": {"type": "string", "enum": ["done", "action", "blocked"]}}
    FirmBusiness:
      type: "object"
      properties: {"counts": {"type": "object", "properties": {"totalOrders": {"type": "integer"}, "stockAvailable": {"type": "integer"}, "inDelivery": {"type": "integer"}, "openConversations": {"type": "integer"}, "needsReview": {"type": "integer", "minimum": 0}, "awaitingPayment": {"type": "integer", "minimum": 0}, "verifiedPayments": {"type": "integer", "minimum": 0}, "deliveredOrders": {"type": "integer", "minimum": 0}}}, "site": {"$ref": "#/components/schemas/FirmSite"}, "stock": {"type": "array", "items": {"$ref": "#/components/schemas/FirmStock"}}, "orders": {"type": "array", "items": {"$ref": "#/components/schemas/FirmOrder"}}, "tickets": {"type": "array", "items": {"$ref": "#/components/schemas/FirmSupportTicket"}}, "events": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "kind": {"type": "string"}, "message": {"type": "string"}, "email": {"type": ["string", "null"]}, "created_at": {"type": "string"}}}}, "checkout": {"$ref": "#/components/schemas/FirmCheckout"}, "mailReady": {"type": "boolean"}, "integrations": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "name": {"type": "string"}, "status": {"type": "string"}, "detail": {"type": "string"}}}}, "coverage": {"type": "array", "items": {"$ref": "#/components/schemas/FirmStockCoverage"}}, "launch": {"type": "array", "items": {"$ref": "#/components/schemas/FirmLaunchStep"}}}
    Health:
      type: object
      required: [ok, version, at, checks]
      properties:
        ok: { type: boolean, description: true when no check failed. }
        version: { type: string, example: 1.0.0 }
        release: { type: [string, 'null'], description: The release commit the gateway runs, when it knows it. }
        at: { type: string, format: date-time }
        failing: { type: array, items: { type: string, enum: [db, engine] }, description: 'Present on a 503; the checks that failed. `terminals` is never on it: the heartbeat age is reported, not judged.' }
        checks:
          type: object
          description: 'One entry per dependency. A probe not wired on this gateway answers `{ ok: null, reason }` and never fails the report; each wired probe has one second to answer.'
          properties:
            db: { $ref: '#/components/schemas/HealthCheck' }
            engine: { $ref: '#/components/schemas/HealthCheck' }
            terminals: { $ref: '#/components/schemas/HealthCheck' }
    HealthCheck:
      type: object
      properties:
        ok: { type: [boolean, 'null'], description: null when the probe is not wired on this gateway. }
        ms: { type: integer, description: How long the probe took. }
        error: { type: string, enum: [timeout, unreachable, auth, error], description: 'On a failed `db` or `engine` probe: which kind of failure, as a stable word (`timeout`: no answer within the second; `unreachable`: connection refused or no route; `auth`: the database refused the role or the database name; `error`: anything else). The driver''s own message — host, role and database names — never leaves the gateway; it goes to its log.' }
        reason: { type: [string, 'null'], description: 'Why an engine probe answered but is not ready (its own word, or `http_<status>`), why a probe is unwired, or why the heartbeat age was not read (`heartbeat age not read: <word>`).' }
        loop:
          type: [object, 'null']
          description: 'The engine event loop as its `/readyz` reports it, when the engine probe answered; null when it did not say. A level, never a switch: `critical` does not fail the report.'
          properties:
            level: { type: [string, 'null'], enum: [ok, warn, critical, null], description: '`warn` from 100 ms of p99 loop delay, `critical` from 250 ms.' }
            p99Ms: { type: [number, 'null'], description: The loop delay's p99 in milliseconds over the engine's window. }
        newestHeartbeatAt: { type: [string, 'null'], format: date-time, description: 'The newest terminal heartbeat (`terminals`).' }
        newestHeartbeatAgeSec: { type: [integer, 'null'] }
    ConfigurationLink:
      type: object
      required: [url, token, expiresAt, purpose]
      properties:
        url: { type: string, format: uri, description: The console page the trader opens. }
        token: { type: string, description: Shown once; the gateway keeps a hash. }
        expiresAt: { type: string, format: date-time }
        purpose: { type: string, enum: [create, update] }
    ConfigureInfo:
      type: object
      properties:
        purpose: { type: string, enum: [create, update] }
        platform: { $ref: '#/components/schemas/PlatformId' }
        name: { type: [string, 'null'] }
        environment: { type: string, enum: [live, demo] }
        expiresAt: { type: string, format: date-time }
        account:
          type: [object, 'null']
          description: For an `update` link, the account it re-credentials.
          properties:
            login: { type: [string, 'null'] }
            server: { type: [string, 'null'] }
        liveConfirmation: { type: [string, 'null'], description: 'The exact phrase a live `create` link needs on POST; null when none is needed.' }
    Quota:
      type: object
      properties:
        max: { type: [integer, 'null'], description: null means no cap. }
        used: { type: integer }
    Quotas:
      type: object
      properties:
        plan: { type: string, example: early-access }
        limits:
          type: object
          properties:
            accounts: { $ref: '#/components/schemas/Quota' }
            hostedTerminals: { $ref: '#/components/schemas/Quota' }
            apiKeys: { $ref: '#/components/schemas/Quota' }
            webhooks: { $ref: '#/components/schemas/Quota' }
            readRequestsPerMinute: { type: integer }
            tradeRequestsPerMinute: { type: integer }
    QuotaRequest:
      type: object
      properties:
        id: { type: string }
        kind: { type: string, enum: [accounts, hostedTerminals, apiKeys, webhooks, rateLimits] }
        requested: { type: integer }
        reason: { type: string }
        status: { type: string, enum: [open, granted, declined] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    RequestLogEntry:
      type: object
      properties:
        id: { type: string, description: The X-Request-Id of that request. }
        at: { type: string, format: date-time }
        method: { type: string }
        path: { type: string, description: 'The route pattern, e.g. /v1/accounts/:accountId/positions.' }
        status: { type: integer }
        ms: { type: integer }
        principal: { type: string, enum: [session, api-key] }
        keyPrefix: { type: [string, 'null'] }
        code: { type: [string, 'null'], description: The error code on a 4xx or 5xx, else null. }
    SupportTicket:
      type: object
      properties:
        id: { type: string }
        category: { type: string, enum: [accounts, trading, copy, billing, api, other] }
        subject: { type: string }
        message: { type: string }
        status: { type: string }
        accountId: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    StrategyDefaults:
      type: object
      description: 'The copy settings a subscriber starts from. An unknown key is refused (`400`, `details.field`).'
      properties:
        sizing: { $ref: '#/components/schemas/Sizing' }
        symbolFilter: { type: array, items: { type: string }, maxItems: 200 }
        direction: { type: string, enum: [same, reverse] }
        copyStopLoss: { type: boolean }
    Strategy:
      type: object
      properties:
        id: { type: string }
        accountId: { type: string }
        name: { type: string }
        description: { type: [string, 'null'] }
        defaults: { $ref: '#/components/schemas/StrategyDefaults' }
        subscribers:
          type: object
          properties:
            total: { type: integer }
            active: { type: integer }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    CardCheckout:
      type: object
      properties:
        id: { type: string }
        mode: { type: string, enum: [sandbox, live] }
        provider: { type: string, enum: [paystack] }
        reference: { type: string }
        authorizationUrl: { type: string, format: uri }
        amountUsd: { type: string }
        chargeAmount: { type: string }
        chargeCurrency: { type: string }
        status: { type: string, enum: [awaiting_payment, credited, failed, expired, review] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
    BillingOffer:
      type: object
      required: [id, rateVersion, termsVersion, statement, rates]
      properties:
        id: { type: string, format: uuid }
        rateVersion: { type: string }
        termsVersion: { type: string }
        statement: { type: string, description: The exact operator-approved billing statement to show before affirmative acceptance }
        rates:
          type: object
          required: [apiPlatformUsd, metaTraderStandardUsd]
          properties:
            apiPlatformUsd: { type: string, description: Exact USD per cTrader or TradeLocker account-hour }
            metaTraderStandardUsd: { type: string, description: Exact USD per standard MetaTrader account-hour }
    BillingEnrollment:
      type: object
      required: [mode, offer, enrollment]
      properties:
        mode: { type: string, enum: [off, sandbox, live] }
        offer: { anyOf: [{ $ref: '#/components/schemas/BillingOffer' }, { type: 'null' }] }
        enrollment:
          anyOf:
            - type: 'null'
            - type: object
              required: [startsAt, active, rateVersion, acceptedAt, evidenceSha256, offer]
              properties:
                startsAt: { type: string, format: date-time }
                active: { type: boolean, description: The enrollment start time has passed; this does not certify healthy metering or launch readiness }
                rateVersion: { type: string }
                acceptedAt: { type: [string, 'null'], format: date-time }
                evidenceSha256: { type: [string, 'null'], description: Hash of immutable acceptance evidence; null for an older operator-created enrollment }
                offer: { anyOf: [{ $ref: '#/components/schemas/BillingOffer' }, { type: 'null' }] }
    CryptoInvoice:
      type: object
      required: [id, mode, rail, label, token, address, amount, received, creditUsd, creditedUsd, status, createdAt, expiresAt]
      properties:
        id: { type: string, format: uuid }
        mode: { type: string, enum: [sandbox, live] }
        rail: { type: string, enum: [base-sepolia-usdc, tron-nile-usdt, base-usdc, tron-usdt] }
        label: { type: string }
        token: { type: string, description: Exact allowlisted token contract; Tron contracts use 41-prefixed hex }
        address: { type: string, description: Invoice-specific destination; checksummed base58 for Tron }
        amount: { type: string }
        received: { type: string, description: All observed finalized transfers including amounts requiring review }
        creditUsd: { type: string, description: Quoted service credit in this environment }
        refundPending: { type: string, description: Token amount reserved for manual refund handling; not a completed transfer }
        refunded: { type: string, description: Token amount recorded as refunded with operator-reviewed finalized receipt evidence; no service balance debit }
        creditedUsd: { type: string, description: Actually posted service credit in this environment }
        status: { type: string, enum: [awaiting_payment, partial, credited, expired, review] }
        createdAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
    PaymentSummary:
      description: MIRRO HQ uses account_plan billing only ($6 per linked account per 30 days); hourly usage is never charged. The developer product retains its separately accepted plan_or_usage policy.
      type: object
      required: [mode, liveCharging, balanceUsd, rails, invoices, plan, plans]
      properties:
        billingModel: { type: string, enum: [account_plan, plan_or_usage], description: Authenticated tenant billing policy; never selected by an untrusted product header. }
        deploymentBillingRequired: { type: boolean, description: Live mode requires every customer to accept billing and hold sufficient service credit before a new deployment or deployment retry }
        mode: { type: string, enum: [off, sandbox, live] }
        liveCharging: { type: boolean, description: True only for an approved live launch and a current user billing enrollment }
        billingHealthy: { type: boolean, description: Whether usage accounting is current and not halted by reconciliation }
        hosting:
          type: array
          description: Up to 50 live hosted-account policy states. Insufficient coverage starts a 24-hour grace. Automatic stop additionally requires separate activation, fresh whole-account flat proof, no active copy links and no unresolved execution. Open or uncertain accounts require review and are never automatically liquidated. checkedAt is the observation time, not a guarantee of current state.
          items:
            type: object
            properties:
              accountId: { type: string, format: uuid }
              status: { type: string, enum: [funded, grace, review, ready, stopped, exempt] }
              reason: { type: string }
              depletedSince: { type: [string, 'null'], format: date-time }
              graceEndsAt: { type: [string, 'null'], format: date-time }
              checkedAt: { type: string, format: date-time }
        minimumTopupUsd: { type: string, description: Minimum for new invoices; 5.000000 live and 0.000001 sandbox }
        maximumTopupUsd: { type: string, const: '20000.000000' }
        balanceUsd: { type: [string, 'null'], description: Ledger balance in the configured environment; null when payments are off }
        plan: { $ref: '#/components/schemas/PlanPrice' }
        plans:
          type: array
          description: 'Your account plans in this environment, one per account that ever had one or has a payment open for one: the list `GET /billing/plans` answers. Answered with `plan` even when payments are off (`mode: off`, then empty), so a page never hard-codes the price.'
          items: { $ref: '#/components/schemas/Plan' }
        invoices: { type: array, maxItems: 50, items: { $ref: '#/components/schemas/CryptoInvoice' } }
        entries:
          type: array
          maxItems: 50
          items:
            type: object
            required: [id, kind, amountUsd, createdAt, accountId, hour, rateVersion]
            properties:
              id: { type: string, format: uuid }
              kind: { type: string, enum: [payment, usage, reversal, grant, max_spots, plan], description: '`payment` (a top-up) and `grant` credit the balance; `usage` (one account-hour), `plan` (one account plan period), `max_spots` (MAX spots) and `reversal` debit it.' }
              amountUsd: { type: string, description: Positive amount; the kind says whether it credits or debits the balance }
              createdAt: { type: string, format: date-time }
              accountId: { type: [string, 'null'], format: uuid, description: The account of a usage hour or of a plan period; null for the other kinds }
              hour: { type: [string, 'null'], format: date-time }
              rateVersion: { type: [string, 'null'], description: Captured usage-price version; null for every kind but usage }
        rails:
          type: array
          items:
            type: object
            required: [rail, label, ready]
            properties:
              rail: { type: string, enum: [base-sepolia-usdc, tron-nile-usdt, base-usdc, tron-usdt] }
              label: { type: string }
              ready: { type: [boolean, 'null'], description: Whether this rail is enabled and its scanner recently caught up; address allocation can still be refused }
              token: { type: string }
              checkedAt: { type: [string, 'null'], format: date-time }
    PlanPrice:
      type: object
      description: 'The one price of an account plan, formatted once by the gateway (`packages/api/plans.mjs`, `PLAN`): the console and the site show it and never write the number themselves. Excluding tax.'
      required: [priceUsd, periodDays, currency]
      properties:
        priceUsd: { type: string, example: '6.00', description: USD for one account for one period. }
        periodDays: { type: integer, example: 30 }
        currency: { type: string, const: USD }
    Plan:
      type: object
      description: |
        One account's plan in this environment: `active` while `paidThrough` is ahead, `ended` once it has
        passed, `none` while no period was paid yet and a direct payment is open for one (`pending`). An ended
        plan stops nothing by itself: the account's hours fall back to pay as you go where
        the person has a budget for them (an enrollment and credit), and a hosted account with neither goes
        through the depletion path (`PaymentSummary.hosting`, reason `plan_required`). A period bought while
        the plan is active starts at the current `paidThrough`, never earlier.
      required: [accountId, status, startedAt, paidThrough, autoRenew, renewFailedAt, renewFailure, pending]
      properties:
        accountId: { type: string, format: uuid }
        status: { type: string, enum: [active, ended, none] }
        startedAt: { type: [string, 'null'], format: date-time, description: 'When the first period began; null while `none`.' }
        paidThrough: { type: [string, 'null'], format: date-time, description: 'null while `none`.' }
        autoRenew: { type: [boolean, 'null'], description: 'Default true: renewed from the service credit when `paidThrough` passes (`PATCH /billing/plans/{accountId}`); null while `none`.' }
        renewFailedAt: { type: [string, 'null'], format: date-time, description: When the last renewal from credit failed; the gateway tries again every hour. }
        renewFailure: { type: [string, 'null'], enum: [insufficient, null] }
        pending:
          type: [object, 'null']
          description: The direct payment this plan is waiting on (a card page or a crypto invoice, open for 24 hours), or null.
          properties:
            id: { type: string, format: uuid }
            provider: { type: string, enum: [credit, card, crypto] }
            kind: { type: string, enum: [start, renew, auto_renew] }
            createdAt: { type: string, format: date-time }
            checkoutReference: { type: [string, 'null'], description: 'The card checkout, for `POST /billing/card-checkouts/{reference}/confirm`.' }
            authorizationUrl: { type: [string, 'null'], format: uri, description: The card page, while that checkout is open. }
            invoiceId: { type: [string, 'null'], format: uuid, description: 'The crypto invoice, one of `PaymentSummary.invoices`.' }
    PlanPayment:
      type: object
      description: One purchase of a plan period. From `credit` it settles at once; by `card` or `crypto` it waits for the money and settles in the same transaction that credits it.
      properties:
        id: { type: string, format: uuid }
        accountId: { type: string, format: uuid }
        mode: { type: string, enum: [sandbox, live] }
        requestId: { type: string, format: uuid }
        kind: { type: string, enum: [start, renew, auto_renew], description: '`auto_renew` is the gateway renewing from credit on its own.' }
        provider: { type: string, enum: [credit, card, crypto] }
        amountUsd: { type: string, example: '6.00' }
        amountUsdMicros: { type: string, example: '6000000', description: 'The same amount in the ledger''s unit: a USD is 1,000,000 micros.' }
        days: { type: integer, example: 30 }
        status: { type: string, enum: [pending, paid, failed, abandoned, expired] }
        periodFrom: { type: [string, 'null'], format: date-time }
        periodTo: { type: [string, 'null'], format: date-time }
        failure: { type: [string, 'null'], description: 'Why it is `failed`: `insufficient` for a renewal from credit, or the processor''s word.' }
        checkoutId: { type: [string, 'null'], format: uuid, description: 'The card checkout a direct payment waits on (`CardCheckout.id`).' }
        invoiceId: { type: [string, 'null'], format: uuid, description: 'The crypto invoice a direct payment waits on (`CryptoInvoice.id`).' }
        createdAt: { type: string, format: date-time }
        paidAt: { type: [string, 'null'], format: date-time }
    PlanCheckout:
      type: object
      required: [payment, plan, authorizationUrl, checkout, invoice]
      properties:
        payment: { $ref: '#/components/schemas/PlanPayment' }
        plan: { $ref: '#/components/schemas/Plan' }
        authorizationUrl: { type: [string, 'null'], format: uri, description: The card page to send the person to; null unless paid by card. }
        checkout: { oneOf: [{ $ref: '#/components/schemas/CardCheckout' }, { type: 'null' }] }
        invoice: { oneOf: [{ $ref: '#/components/schemas/CryptoInvoice' }, { type: 'null' }] }
    Scope:
      type: string
      enum: [read, trade, copy, provision, admin, ai]
      description: AI must be explicitly selected; admin does not imply ai and ai alone grants no trading-data access
    PlatformId:
      type: string
      enum: [mt5, mt4, ctrader, tradelocker, matchtrader, dxtrade]
      description: '`mt5` and `mt4` are hosted MetaTrader terminals (one per account, of the matching version); the rest are API platforms with no terminal. `matchtrader` is a historical value: Match-Trader was withdrawn from the product, the gateway registry has no row for it, `GET /platforms` never lists it and `POST /accounts` refuses it as an unknown platform (`400 BAD_REQUEST`). It stays in this enum only because the database still admits the value, so a client reading an `Account` written before the withdrawal can parse it; never send it.'
    Reliability:
      type: string
      enum: [standard, high]
      description: '`standard` is what every account runs: one hosted terminal (MetaTrader 4 or 5), or no terminal for an API platform. `high` names a standby terminal on a second server, planned for MetaTrader 5 only; no product yet and none is in service, so `high` is refused on every platform (MetaTrader 4 and 5 alike: `high` is refused with 422 RELIABILITY_UNAVAILABLE, details.field reliability). Automatic cross-host failover is not in service.'
    Money:
      type: object
      required: [amount, currency]
      properties:
        amount: { type: string, example: '1523.40' }
        currency: { type: string, example: USD }
    ApiKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        prefix: { type: string, example: mk_live_3f9a }
        scopes: { type: array, items: { $ref: '#/components/schemas/Scope' } }
        accountIds: { type: array, items: { type: string } }
        environment: { type: string, enum: [live, test] }
        rateLimits:
          type: object
          properties:
            readPerMinute: { type: integer }
            tradePerMinute: { type: integer }
        createdAt: { type: string, format: date-time }
        lastUsedAt: { type: [string, 'null'], format: date-time }
        revokedAt: { type: [string, 'null'], format: date-time }
        expiresAt: { type: [string, 'null'], format: date-time }
    Usage:
      type: object
      properties:
        period: { type: object, properties: { from: { type: string, format: date-time }, to: { type: string, format: date-time } } }
        accountHours:
          type: array
          items:
            type: object
            properties:
              accountId: { type: string }
              type:
                type: string
                enum: [api-platform, mt5-standard, mt4-standard, mt5-high]
                description: '`mt5-standard` and `mt4-standard` are a hosted MetaTrader terminal hour at the same rate (USD 0.0125). `mt5-high` (USD 0.0222) is priced for the planned standby tier; no account can carry it while no standby is in service. `api-platform` (USD 0.0056) is an hour on cTrader or TradeLocker.'
              hours: { type: number }
              rate: { oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }], description: 'Reference hourly rate for this row — not a replacement for its captured billing price' }
              coveredHours: { type: number, description: 'Of `hours`, how many are paid for: every one of them under a MAX program spot, else those the account''s own plan paid for (`GET /billing/plans`); 0 when nothing did.' }
              coveredBy: { type: [string, 'null'], enum: [max, plan, null], description: 'What paid for `coveredHours`: `max` (a MAX program spot, the provider pays) or `plan` (the account''s own plan, even when it covered only some of the hours); null when nothing did.' }
              covered: { type: boolean, description: 'true when every hour of the row is paid for, by a MAX program spot or by an account plan; `charge` is then 0.0000.' }
              charge:
                description: 'The hours nothing covered (`hours` less `coveredHours`) at the kind''s rate; null when this gateway has no price for the kind, and such an hour is left out of `total`.'
                oneOf:
                  - $ref: '#/components/schemas/Money'
                  - type: 'null'
        requests: { type: integer }
        tradeRequests: { type: integer }
        rejected: { type: integer }
        total: { $ref: '#/components/schemas/Money', description: 'Exact sum of priced reference estimates, with six decimal places — not rounded to cents' }
        complete: { type: boolean, description: 'False when a row could not be priced — total is then only a partial estimate' }
        billing:
          type: [object, 'null']
          description: 'Captured hourly billing evidence in the selected period, using immutable original prices and coverage. Null when the payment data layer is off. Only captured hours are represented; missing observation is not treated as evidence of free usage. Excludes account-plan purchases, grants, top-ups and MAX purchases. A restricted API key sees only its allowed accounts.'
          properties:
            mode: { type: string, enum: [live, sandbox] }
            capturedHours: { type: integer }
            postedHours: { type: integer }
            pendingHours: { type: integer }
            capturedUsd: { type: string, description: 'Exact total amount captured in USD, six decimal places' }
            postedUsd: { type: string, description: 'Credits actually deducted for these hours, including deductions posted after the selected period ended' }
            pendingUsd: { type: string, description: 'Captured but not yet deducted; may include the current unfinished hour' }
        note: { type: string, description: 'Explanation of reference estimates, captured billing and request counts' }
    Platform:
      type: object
      properties:
        id: { $ref: '#/components/schemas/PlatformId' }
        name: { type: string }
        authorisation: { type: string, enum: [oauth, credentials, terminal] }
        terminalRequired: { type: boolean }
        events:
          type: string
          enum: [stream, poll]
          description: '`stream`: pushed as it happens (MetaTrader 5 terminal socket; cTrader and TradeLocker push to the adapter). `poll`: fetched on a timer (MetaTrader 4 over HTTP long-poll or the file carrier; DXtrade).'
        status:
          type: string
          enum: [available, alpha, on-request, per-firm, unsupported]
          description: '`available`: a connection method is configured. cTrader and TradeLocker use `selfOnboard.via`, not `POST /accounts`. `alpha`: listed but the separate connection method is disabled. `per-firm` (`dxtrade`): the firm switches API access on, so `POST /accounts` refuses it (`FIRM_PERMISSION_REQUIRED`). `on-request` is in the enum for a platform offered on request; no row carries it today (Match-Trader was withdrawn). `unsupported` is answered for `mt4` only by a gateway started with `MIRRO_API_MT4=0`, which then refuses its accounts with `PLATFORM_UNSUPPORTED`.'
        selfOnboard:
          type: object
          description: 'Whether a trader can connect the platform alone with credentials they already hold, on this gateway, today. One table (packages/adapters/platforms.mjs API_OFFER) answers this row and decides the `POST /accounts` refusal, so `ok: false` carries `reason`: the exact code `POST /accounts` answers for that platform — `USE_OAUTH` (422, cTrader), `USE_PLATFORM_CONNECT` (422, TradeLocker with its connection flow configured), `NOT_IMPLEMENTED` (501, TradeLocker without it), `FIRM_PERMISSION_REQUIRED` (422, DXtrade), `PLATFORM_UNSUPPORTED` (422, `mt4` on a gateway started with `MIRRO_API_MT4=0`).'
          properties:
            ok: { type: boolean }
            via: { type: string, description: 'When present, use this separate connection flow instead of POST /accounts. cTrader uses /v1/ctrader/authorize; TradeLocker uses /v1/tradelocker/discover.' }
            auth: { type: string, enum: [password, oauth2, key], description: How the trader authorises it. }
            reason: { type: string, enum: [USE_OAUTH, USE_PLATFORM_CONNECT, NOT_IMPLEMENTED, FIRM_PERMISSION_REQUIRED, PLATFORM_UNSUPPORTED], description: 'Present only when `ok` is false: the code `POST /accounts` refuses this platform with. The console prints its plain-words twin.' }
        capabilities:
          type: object
          description: What a client can branch on for this platform on this gateway release. Facts, not prose.
          properties:
            nativeReads: { type: boolean, description: 'The terminal reads (`/symbols/{symbol}/price`, `/tick`, `/server-time?source=terminal`) are answered by the terminal itself: hosted MetaTrader only.' }
            hostedTerminal: { type: boolean, description: MirroAPI runs a terminal for the account, billed by the hour while deployed. }
            margin: { type: [string, 'null'], description: '`null`: no margin read. Otherwise the EA version `POST /margin` needs on this platform, `ea>=1.9.1`; a terminal on an older EA is refused, never guessed.' }
            stream: { type: [array, 'null'], items: { type: string, enum: [account, positions, orders, commands] }, description: 'The `/stream` channels served for accounts on this platform; `null` when none.' }
            candles: { type: boolean, description: '`false` on every platform today.' }
            historyOrders: { type: boolean, description: '`false` on every platform today.' }
        firmCanDisable: { type: boolean, description: A firm or broker can switch this platform's application access off. }
        reachable: { type: boolean, description: Whether this gateway offers a connection method; true for selfOnboard.ok or a configured selfOnboard.via. This is not proof of broker connectivity. }
        notes: { type: [string, 'null'], description: 'One sentence for the developer on how the platform connects and why it is refused when it is. Nothing operator-facing leaves the gateway.' }
      example:
        id: mt4
        name: MetaTrader 4
        authorisation: terminal
        terminalRequired: true
        events: poll
        status: available
        selfOnboard: { ok: true, auth: password }
        capabilities: { candles: false, historyOrders: false, margin: 'ea>=1.9.1', stream: [account, positions, orders, commands], nativeReads: true, hostedTerminal: true }
        firmCanDisable: false
        reachable: true
        notes: 'MirroAPI runs a MetaTrader 4 terminal for the account with the login, password and server the trader holds; commands ride an HTTP long-poll, and a partial close gives the remainder a new ticket.'
    AccountCreate:
      type: object
      required: [platform, login, password, server]
      properties:
        platform: { type: string, enum: [mt5, mt4, ctrader, tradelocker, dxtrade], description: 'The five ids the gateway registry knows; anything else is `400 BAD_REQUEST` naming them. Only mt5 and mt4 are connected here today: ctrader is 422 USE_OAUTH, tradelocker 422 USE_PLATFORM_CONNECT when enabled (otherwise 501 NOT_IMPLEMENTED), dxtrade 422 FIRM_PERMISSION_REQUIRED — the same codes `GET /platforms` reports in `selfOnboard.reason`.' }
        name: { type: string, description: Optional label. }
        login: { type: string, description: 'MetaTrader login (MT4 or MT5), digits only' }
        password: { type: string, writeOnly: true, minLength: 4, maxLength: 128 }
        server: { type: string, description: 'Broker server name as the terminal shows it, e.g. `ICMarketsSC-Live07`. See `GET /platforms/{platform}/servers`.' }
        environment: { type: string, enum: [live, demo], default: live }
        role: { type: string, enum: [follow, master, both, read], default: follow, description: '`read` provisions the terminal with execution left off.' }
        currency: { type: string, default: USD, description: 'Three letters. A placeholder: the terminal reports the real deposit currency on its first heartbeat.' }
        reliability: { $ref: '#/components/schemas/Reliability' }
        start:
          type: boolean
          default: true
          description: |
            `false` creates a hosted MetaTrader account without starting it and without the credit check:
            it waits as `state: UNDEPLOYED` with `deployment.error: plan_required` until its plan is paid
            (`POST /billing/plans/checkout`, which starts it) or `POST /accounts/{accountId}/deploy` is
            called. `true`, or left out, provisions and starts it at once as before.
      example:
        platform: mt4
        name: Atlas 50k (MT4)
        login: '2100488'
        password: '********'
        server: AtlasFunded-Live4
    Account:
      type: object
      properties:
        id: { type: string, example: acc_7d2f }
        platform: { $ref: '#/components/schemas/PlatformId' }
        name: { type: string }
        broker: { type: string }
        server: { type: string }
        login: { type: string }
        currency: { type: string }
        leverage: { type: integer }
        environment: { type: [string, 'null'], enum: [live, demo, null] }
        state: { type: string, enum: [CREATED, DEPLOYING, DEPLOYED, UNDEPLOYING, UNDEPLOYED, FAILED] }
        connection: { type: string, enum: [CONNECTED, DISCONNECTED, BROKER_DOWN, FIRM_UNREACHABLE] }
        reliability: { $ref: '#/components/schemas/Reliability' }
        roles:
          type: object
          properties:
            master: { type: boolean }
            follower: { type: boolean, description: The execute role a trade needs. }
        executionCertified: { type: boolean }
        consent: { type: string, enum: [none, recorded] }
        tags: { type: array, items: { type: string }, description: Always empty; tags are not stored. }
        deployment:
          type: [object, 'null']
          description: Hosted terminals only (MetaTrader 4 and 5); null for API platforms.
          properties:
            stage: { type: [string, 'null'] }
            error: { type: [string, 'null'] }
            updatedAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
        lastHeartbeatAt: { type: [string, 'null'], format: date-time }
        max: { $ref: '#/components/schemas/MaxRole' }
        plan:
          type: [object, 'null']
          description: |
            The account plan (`GET /billing/plans`: one price per account per 30 days, every API surface
            included, nothing metered while it is paid). `none` when the account never had one, `ended` when
            its last period passed (its hours are metered by the hour again, where the person has a budget
            for them), `active` otherwise. null when payments are off.
          properties:
            status: { type: string, enum: [active, ended, none] }
            paidThrough: { type: [string, 'null'], format: date-time, description: 'null while `none`.' }
            autoRenew: { type: [boolean, 'null'], description: 'null while `none`.' }
      example:
        id: acc_4c1e
        platform: mt4
        name: Atlas 50k (MT4)
        broker: Atlas Funded
        server: AtlasFunded-Live4
        login: '2100488'
        currency: USD
        leverage: 100
        state: DEPLOYED
        connection: CONNECTED
        environment: live
        reliability: standard
        roles: { master: false, follower: true }
        executionCertified: true
        consent: none
        tags: []
        deployment: { stage: active, error: null, updatedAt: '2026-09-17T09:12:40.000Z' }
        createdAt: '2026-09-17T09:11:02.000Z'
        lastHeartbeatAt: '2026-09-17T09:14:59.250Z'
        plan: { status: active, paidThrough: '2026-10-17T09:11:02.000Z', autoRenew: true }
    Consent:
      type: object
      properties:
        accountId: { type: string }
        traderName: { type: string }
        traderEmail: { type: string }
        statement: { type: string }
        ip: { type: string }
        recordedAt: { type: string, format: date-time }
    AccountInformation:
      type: object
      properties:
        broker: { type: string }
        currency: { type: string }
        server: { type: string }
        balance: { type: string }
        equity: { type: string }
        margin: { type: string }
        freeMargin: { type: string }
        marginLevel: { type: [string, 'null'], description: 'Equity over used margin, in percent; null while no margin is in use.' }
        dayStartEquity: { type: [string, 'null'], description: The equity anchor the daily-loss rule measures from. }
        leverage: { type: integer }
        type: { type: [string, 'null'], enum: [hedging, netting, null], description: 'MetaTrader 4 accounts are always hedging. null until the terminal has reported.' }
        tradeAllowed: { type: [boolean, 'null'] }
        openPositions: { type: [integer, 'null'], description: As the terminal last counted them (market orders only on MetaTrader 4). }
        brokerConnected: { type: [boolean, 'null'] }
        updatedAt: { type: [string, 'null'], format: date-time, description: The terminal's last heartbeat. }
    Position:
      type: object
      description: |
        `ticket` is the ticket a `close`, `close-partial` or `modify-position` must name: the
        current live broker ticket. `positionId` is the stable identity of the position for its
        whole life: on MetaTrader 5 the position id (equal to `ticket`); on MetaTrader 4 the
        ORIGIN ticket, kept when a partial close re-tickets the remainder; on API platforms the
        platform's position id. On MetaTrader 4 `ticket` changes after a partial close and
        `positionId` does not; a command naming a superseded ticket is re-addressed to the live
        one. `Command.result.ticket` after `close-partial` on MetaTrader 4 is the remainder ticket.
        The terminal reports `openedAt` on the broker's clock. When the terminal has reported its
        broker's UTC offset, the API subtracts it and `timeBasis` is `utc`; until then `openedAt`
        is the broker clock reading written as if it were UTC and `timeBasis` is `broker`. On
        cTrader, TradeLocker and DXtrade the adapter reads the broker's own UTC timestamps, so
        `timeBasis` is always `utc` on those platforms and nothing is shifted.

        An account that is both a strategy and an execute account still appears once here per
        position.
      properties:
        ticket: { type: string, description: Current live broker ticket. Name this in a close or modify. }
        positionId: { type: string, description: Stable identity (MT5 position id; MT4 origin ticket; API platform position id). MT4 partial closes may change the live ticket; this does not change. }
        symbol: { type: [string, 'null'] }
        side: { type: [string, 'null'], enum: [buy, sell, null] }
        volume: { type: [string, 'null'] }
        openPrice: { type: [string, 'null'] }
        currentPrice: { type: [string, 'null'] }
        stopLoss: { type: [string, 'null'] }
        takeProfit: { type: [string, 'null'] }
        profit: { type: [string, 'null'], description: 'Floating profit including swap and commission. null from terminals that do not report it (MT4 EA before 1.7.1, MT5 EA before 1.7.2).' }
        magic: { type: [integer, 'null'] }
        comment: { type: [string, 'null'] }
        openedAt: { type: [string, 'null'], format: date-time }
        timeBasis: { type: string, enum: [utc, broker], description: '`utc`: openedAt is UTC — corrected by the broker offset on MetaTrader, native on API platforms. `broker`: no offset on record yet, openedAt is the broker clock.' }
        updatedAt: { type: string, format: date-time }
      example:
        ticket: '48120377'
        positionId: '48120301'
        symbol: EURUSD
        side: buy
        volume: '0.05'
        openPrice: '1.08341'
        currentPrice: '1.08402'
        stopLoss: '1.08000'
        takeProfit: '1.09000'
        profit: '2.70'
        magic: null
        comment: 'from #48120301'
        openedAt: '2026-09-17T08:55:10.000Z'
        timeBasis: utc
        updatedAt: '2026-09-17T09:14:59.250Z'
    Order:
      type: object
      description: |
        A working (pending) order as the terminal or adapter last reported it. `ticket` is the
        order ticket a `modify-order` or `cancel` must name. `positionId` is set once the order
        has filled into a position (MetaTrader 5 position id; MetaTrader 4 origin ticket), else
        null. `type` is one of the four order types every hosted terminal takes; stop-limit is
        not offered. `validity` is what the terminal reported (`gtc`, `day`, `specified`).
        `placedAt` and `expiresAt` follow the same `timeBasis` rule as `Position.openedAt`:
        corrected on MetaTrader once the terminal has reported its broker's UTC offset, already
        UTC on cTrader, TradeLocker and DXtrade. `expiresAt` is null when the order has no expiry
        (`gtc`) or the terminal reports none.
      properties:
        ticket: { type: string }
        symbol: { type: [string, 'null'] }
        side: { type: [string, 'null'], enum: [buy, sell, null] }
        type: { type: [string, 'null'], enum: [buy-limit, sell-limit, buy-stop, sell-stop, null] }
        volume: { type: [string, 'null'] }
        price: { type: [string, 'null'] }
        stopLoss: { type: [string, 'null'] }
        takeProfit: { type: [string, 'null'] }
        validity: { type: [string, 'null'], enum: [gtc, day, specified, null] }
        expiresAt: { type: [string, 'null'], format: date-time, description: 'When the broker expires this working order, under the same `timeBasis` as `placedAt`. null when there is none (`gtc`) or the terminal does not report one.' }
        placedAt: { type: [string, 'null'], format: date-time }
        timeBasis: { type: string, enum: [utc, broker] }
        positionId: { type: [string, 'null'] }
        updatedAt: { type: string, format: date-time }
      example:
        ticket: '48120410'
        symbol: EURUSD
        side: sell
        type: sell-limit
        volume: '0.10'
        price: '1.09000'
        stopLoss: '1.09500'
        takeProfit: '1.08000'
        validity: gtc
        expiresAt: null
        placedAt: '2026-09-17T09:01:00.000Z'
        timeBasis: utc
        positionId: null
        updatedAt: '2026-09-17T09:14:59.250Z'
    Deal:
      type: object
      description: |
        On MetaTrader 4 a deal is derived from a closed order: `positionId` is the origin ticket
        of the position (kept across partial closes) and `entry` is synthesised (`in` for the
        open, `out` for each close).
      properties:
        ticket: { type: string }
        positionId: { type: [string, 'null'] }
        symbol: { type: [string, 'null'] }
        type: { type: string, enum: [buy, sell, balance, credit, commission, swap] }
        entry: { type: [string, 'null'], enum: [in, out, inout, null] }
        volume: { type: [string, 'null'] }
        price: { type: [string, 'null'] }
        profit: { type: [string, 'null'], description: null when MIRRO could not price the deal. }
        commission: { type: [string, 'null'] }
        swap: { type: [string, 'null'] }
        time: { type: string, format: date-time }
    SymbolSpecification:
      type: object
      description: What the catalogue stores for the symbol; a field it does not hold is null, never inferred.
      properties:
        symbol: { type: string, description: The broker's symbol, exact case. }
        normalized:
          type: [string, 'null']
          description: MirroAPI's canonical instrument id, e.g. `FX:EURUSD`, shared across brokers.
        description: { type: [string, 'null'] }
        baseCurrency: { type: [string, 'null'] }
        profitCurrency: { type: [string, 'null'] }
        digits: { type: [integer, 'null'] }
        contractSize: { type: [string, 'null'] }
        volumeMin: { type: [string, 'null'] }
        volumeMax: { type: [string, 'null'] }
        volumeStep: { type: [string, 'null'] }
        tickSize: { type: [string, 'null'], description: Not stored yet; always null. }
        tickValue: { type: [string, 'null'], description: Not stored yet; always null. }
        tradeMode: { type: [string, 'null'], description: The platform's trade mode number as text, when stored. }
        tradable: { type: [boolean, 'null'] }
        source: { type: string, enum: [account-catalogue, broker-catalogue], description: 'The account''s own ingested catalogue, or the shared broker catalogue used only before the account''s exists.' }
        verifiedAt: { type: [string, 'null'], format: date-time }
    Price:
      type: object
      description: A bid/ask snapshot read from the account's terminal on request (see `GET /symbols/{symbol}/price`). By default a stale quote, or one whose broker time cannot be verified, is refused with 503 QUOTE_STALE. This is a latest-quote request, not a history or a full tick feed.
      required: [symbol, bid, ask, time, timeBasis, ageSeconds, stale, source, observedAt, receivedAt]
      properties:
        symbol: { type: string }
        bid: { type: string }
        ask: { type: string }
        time: { type: [string, 'null'], format: date-time, description: 'The quote''s own time as the terminal read it: UTC when `timeBasis` is `utc`, else the broker clock reading.' }
        timeBasis: { type: string, enum: [utc, broker], description: '`utc` once the terminal''s broker clock offset is on record; `broker` until then.' }
        ageSeconds: { type: [number, 'null'], description: 'How old the quote was when received; null when `timeBasis` is `broker` (the age cannot be known).' }
        stale: { type: [boolean, 'null'], description: 'Older than `maxAgeSeconds` (or more than 5 seconds in the future); null when the age is unknown. Only answered when `allowStale=true`.' }
        source: { type: string, enum: [terminal] }
        observedAt: { type: string, format: date-time, description: The terminal's UTC clock when it read the quote. }
        receivedAt: { type: string, format: date-time, description: When the gateway received the reading. }
    Tick:
      type: object
      description: The same snapshot `Price` is; MetaTrader reports no last price or tick volume, so there is none.
      required: [symbol, bid, ask, time, timeBasis, ageSeconds, stale, source, observedAt, receivedAt]
      properties:
        symbol: { type: string }
        bid: { type: string }
        ask: { type: string }
        time: { type: [string, 'null'], format: date-time }
        timeBasis: { type: string, enum: [utc, broker] }
        ageSeconds: { type: [number, 'null'] }
        stale: { type: [boolean, 'null'] }
        source: { type: string, enum: [terminal] }
        observedAt: { type: string, format: date-time }
        receivedAt: { type: string, format: date-time }
    MarginCheck:
      type: object
      description: The terminal's own margin arithmetic for a hypothetical order (see `POST /accounts/{accountId}/margin`). Which of `margin` and the free-margin trio is set depends on `method`.
      required: [symbol, side, volume, price, method, margin, freeMarginBefore, freeMarginAfter, sufficientFunds, time, timeBasis, ageSeconds, source, observedAt, receivedAt]
      properties:
        symbol: { type: string }
        side: { type: string, enum: [buy, sell] }
        volume: { type: string, description: As sent; not rounded. }
        price: { type: string, description: The price the calculation used (the current broker price, or on MetaTrader 5 the one sent). }
        method: { type: string, enum: [order_calc_margin, free_margin_check], description: '`order_calc_margin` on MetaTrader 5; `free_margin_check` on MetaTrader 4.' }
        margin: { description: 'Required margin (MetaTrader 5). null on MetaTrader 4.', oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }] }
        freeMarginBefore: { description: 'Free margin now (MetaTrader 4). null on MetaTrader 5.', oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }] }
        freeMarginAfter: { description: 'Free margin if the order were placed (MetaTrader 4). null on MetaTrader 5.', oneOf: [{ $ref: '#/components/schemas/Money' }, { type: 'null' }] }
        sufficientFunds: { type: [boolean, 'null'], description: 'MetaTrader 4 only; null on MetaTrader 5, which does not certify affordability.' }
        time: { type: [string, 'null'], format: date-time, description: The quote the calculation used, under `timeBasis`. }
        timeBasis: { type: string, enum: [utc, broker] }
        ageSeconds: { type: [number, 'null'] }
        source: { type: string, enum: [terminal] }
        observedAt: { type: string, format: date-time }
        receivedAt: { type: string, format: date-time }
    Book:
      x-not-served: true
      type: object
      properties:
        symbol: { type: string }
        time: { type: string, format: date-time }
        bids: { type: array, items: { type: object, properties: { price: { type: string }, volume: { type: string } } } }
        asks: { type: array, items: { type: object, properties: { price: { type: string }, volume: { type: string } } } }
    Candle:
      x-not-served: true
      type: object
      properties:
        time: { type: string, format: date-time }
        open: { type: string }
        high: { type: string }
        low: { type: string }
        close: { type: string }
        volume: { type: string }
    TradeRequest:
      type: object
      required: [action]
      discriminator: { propertyName: action }
      properties:
        action:
          type: string
          enum: [market, pending, modify-position, modify-order, close, close-partial, cancel]
        symbol: { type: string, description: The broker's symbol name, verbatim (case matters). }
        side: { type: string, enum: [buy, sell] }
        type: { type: string, enum: [limit, stop], description: 'pending only. stop-limit is refused (INVALID_REQUEST, field type): no hosted terminal takes it and MetaTrader 4 has no such order.' }
        volume: { type: string, description: 'market, pending and close-partial. Exact requested lots: MT4 1.9.7 / MT5 1.9.5 and newer reject a value outside the symbol minimum, maximum or lot grid instead of rounding it, so read this account''s symbol specification first. Refused on modify-order (INVALID_REQUEST, field volume): a pending order''s volume cannot change in place on MetaTrader; cancel and place again.' }
        price: { type: string, description: pending (required) and modify-order }
        stopLoss: { type: [string, 'null'] }
        takeProfit: { type: [string, 'null'] }
        stopLossOffset: { type: string, description: 'market only. A SIGNED price distance from the fill (negative below it, positive above), resolved against the price the order actually filled at. A market order does not know its own entry, so a caller who means "twenty pips away" has to guess it and gets twenty pips ± the slippage — on a risk-sized order that quietly changes the risk. Refused with stopLoss on the same leg, and refused on the wrong side of the fill (a buy''s stop is below its entry) rather than corrected.' }
        takeProfitOffset: { type: string, description: 'market only. As stopLossOffset, the other way round: positive above the fill for a buy.' }
        ticket: { type: string, description: 'For modify / close / cancel. On MetaTrader 4 name Position.ticket (the live ticket); the origin ticket or a ticket superseded by a partial close is re-addressed to the remainder.' }
        ticketResolvedFrom: { type: string, readOnly: true, description: 'Set by the gateway on a MetaTrader 4 command it re-addressed: the ticket you sent. `ticket` is then the live ticket the command went to.' }
        entryPrice: { type: string, description: 'modify-position only, required: the position''s open price. Stops are applied as offsets from it. Naming one of stopLoss / takeProfit keeps the other leg from the book.' }
        deviationPoints: { type: integer }
        clientId: { type: string, description: Your own id; echoed on fills and in the audit log }
      description: |
        `comment` is not accepted on any action (INVALID_REQUEST, field comment): the broker
        order's comment is MIRRO's own dedup tag. `expiration` is not accepted on any action
        (INVALID_REQUEST, field expiration): it is not carried to the terminals yet. Neither is
        dropped silently.
    MaxRole:
      type: [object, 'null']
      description: |
        What an account is inside MAX copy trading, on every ordinary account and copy read. `follower`: it
        arrived through somebody's invite and is the follower's own property — view-only until they buy
        Manage, and `allowed` / `refused` say exactly which actions the gateway will take on it. `master`:
        it is the account other people copy. `null` for every other account.
      properties:
        role: { type: string, enum: [follower, master] }
        placeId: { type: string, description: 'follower only.' }
        programId: { type: string }
        name: { type: string, description: The one public name; the person's own name is never published. }
        status:
          description: |
            TWO DIFFERENT ENUMS, one per `role`, which is why this is not a single `$ref`. On a `follower` it is
            a `MaxPlaceStatus` (starting, waiting_for_room, copying, paused, disconnected, attention, left). On
            a `master` it is the offer's own status (active, grace, lapsed, closed) — never a place status.
          oneOf:
            - $ref: '#/components/schemas/MaxPlaceStatus'
            - { type: string, enum: [active, grace, lapsed, closed], description: 'master only.' }
        joinedAt: { type: string, format: date-time, description: 'follower only.' }
        spotUntil: { type: [string, 'null'], format: date-time, description: 'follower only.' }
        viewOnly: { type: boolean, description: 'follower only. false once Manage is bought.' }
        manage:
          type: object
          description: 'follower only.'
          properties:
            status:
              type: string
              enum: [none, active, grace, lapsed]
              description: |
                The same four words as `MaxManage.status`, read the same way (apiStatus), so this page and the
                follower's own MAX page cannot disagree about whether Manage lapsed. What may be DONE with the
                account is the separate question `viewOnly` / `allowed` / `refused` answer: those fold `grace`
                in with `active` and `lapsed` in with `none`, so a spot inside its grace still works.
                The dates behind the word (`paidThrough`, `autoRenew`, the card) are on `MaxManage`.
            priceUsd: { type: string, example: '4.00' }
        allowed: { type: array, items: { type: string }, description: 'follower only. Closing a copied trade is always on this list.' }
        refused: { type: array, items: { type: string }, description: 'follower only.' }
        subscribers: { type: integer, description: 'master only.' }
    MaxPlaceStatus:
      type: string
      enum: [starting, waiting_for_room, copying, paused, disconnected, attention, left]
      description: The seven words a place is ever in, the same on both sides of it.
    MaxProgram:
      type: object
      description: One provider's MAX copy trading, as the provider sees it.
      properties:
        id: { type: string }
        name: { type: string, description: The one public name, used in the invite header, the "Copying …" pill and every email. }
        description: { type: [string, 'null'] }
        status: { type: string, enum: [draft, active, grace, lapsed, closed] }
        joinsOpen: { type: boolean }
        autoRenew: { type: boolean }
        demoAllowed: { type: boolean, description: Default true. Turned off, demo accounts are refused. }
        verifyEmail:
          type: boolean
          description: |
            Default FALSE (migration 0089). Turned on, an open link refuses anybody whose email address
            MirroAPI has not confirmed (`422 MAX_EMAIL_NOT_VERIFIED`, `details.sentTo` the masked address).
            There is no code to type and no field to send: the refusal mails a fresh confirmation link itself
            (`details.sent` says whether one really went out) and `POST /auth/verify/resend` sends another.
            A personal invite is exempt, because the mail that carried it already proved the address.
        spots:
          type: object
          properties:
            paid: { type: integer }
            used: { type: integer }
            free: { type: integer }
            minimum: { type: integer, example: 100 }
            max: { type: integer, example: 1000 }
        paidThrough: { type: [string, 'null'], format: date-time }
        graceUntil: { type: [string, 'null'], format: date-time }
        lapsedAt: { type: [string, 'null'], format: date-time }
        endsPlacesAt: { type: [string, 'null'], format: date-time, description: When unrenewed places end. }
        closedAt: { type: [string, 'null'], format: date-time }
        legacy: { type: boolean, description: Bought before the 100 minimum; it keeps its count until paidThrough. }
        price:
          type: object
          properties:
            unitUsd: { type: string, example: '3.00' }
            periodDays: { type: integer, example: 30 }
            renewalUsd: { type: string }
            renewalSpots: { type: integer }
        sizing: { $ref: '#/components/schemas/Sizing' }
        master: { $ref: '#/components/schemas/MaxMasterBrief' }
        card:
          type: [object, 'null']
          description: The card a renewal would charge, as the processor last described it. null when none is saved.
          properties:
            brand: { type: [string, 'null'] }
            last4: { type: [string, 'null'] }
            expires: { type: [string, 'null'] }
        followers:
          type: object
          description: The counts behind the subscriber chips, so a caller need not add the lines up itself.
          properties:
            total: { type: integer }
            copying: { type: integer }
            connecting: { type: integer }
            attention: { type: integer }
            paused: { type: integer }
        links:
          type: object
          description: How many ways in are open right now.
          properties:
            open: { type: integer, description: Shared links that still take people. }
            invitesPending: { type: integer, description: Personal invites sent and not yet used. }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: [string, 'null'], format: date-time }
    MaxMasterBrief:
      type: [object, 'null']
      properties:
        id: { type: string }
        platform: { $ref: '#/components/schemas/PlatformId' }
        login: { type: [string, 'null'] }
        server: { type: [string, 'null'] }
        broker: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        online: { type: boolean }
        lastSeenAt: { type: [string, 'null'], format: date-time }
    MaxMasterChoice:
      type: object
      description: Every MetaTrader account the caller owns, usable or not, so the page can explain itself.
      properties:
        id: { type: string }
        platform: { $ref: '#/components/schemas/PlatformId' }
        login: { type: [string, 'null'] }
        server: { type: [string, 'null'] }
        broker: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        online: { type: boolean }
        usable: { type: boolean }
        why: { type: [string, 'null'], enum: [copies_someone_else, already_max, platform_unsupported, not_started, view_only, null] }
    MaxRoom:
      type: object
      description: What the hosting can really serve right now. Money is never taken for a seat that does not exist.
      properties:
        canServeNow: { type: integer }
        minimumSpots: { type: integer, example: 100 }
        unitUsd: { type: string, example: '3.00' }
        periodDays: { type: integer, example: 30 }
        maxSpots: { type: integer, example: 1000 }
        full: { type: boolean, description: canServeNow is below the minimum. }
        checkedAt: { type: string, format: date-time }
    MaxLink:
      type: object
      description: The one link a provider shares. Replacing it revokes the old one; the people already copying are untouched.
      properties:
        id: { type: string }
        code: { type: string }
        url: { type: [string, 'null'], description: 'null only while the link is being made — the console then says so rather than offering an empty box.' }
        status: { type: string, enum: [active, paused, full, revoked] }
        uses: { type: integer }
        remaining: { type: integer }
        createdAt: { type: string, format: date-time }
    MaxInvite:
      type: object
      description: A personal invite that has not been used or cancelled. The full address is never returned, not even to the provider who typed it.
      properties:
        id: { type: string }
        name: { type: [string, 'null'] }
        emailMasked: { type: string, example: 'ad•••@gmail.com' }
        prefilled: { type: boolean, description: 'The broker details travel with this invite, so the person finds everything filled in except the password. No password is ever stored with an invite: they type their own.' }
        sentAt: { type: [string, 'null'], format: date-time }
        expiresAt: { type: [string, 'null'], format: date-time }
        status: { type: string, enum: [sent, failed] }
        url: { type: string, description: 'Only on the answer that creates the invite, where it is shown once so the provider can send it by hand if the mail did not go. The lists never carry it.' }
    MaxSubscriber:
      type: object
      description: |
        One line on the provider's list, and the whole of what a provider may see about a person: never their
        email, never the full login, never a balance or an equity — the consent they gave does not cover those.
      properties:
        id: { type: string }
        name: { type: [string, 'null'] }
        account:
          type: object
          properties:
            platform: { $ref: '#/components/schemas/PlatformId' }
            server: { type: [string, 'null'] }
            broker: { type: [string, 'null'] }
            environment: { type: [string, 'null'], enum: [live, demo, null] }
            loginMasked: { type: string, example: '•••• 4599' }
        status: { $ref: '#/components/schemas/MaxPlaceStatus' }
        problem: { $ref: '#/components/schemas/MaxProblem' }
        pausedReason: { type: [string, 'null'], enum: [provider, lapsed, null] }
        endedReason: { type: [string, 'null'], enum: [left, removed, program_closed, not_renewed, null] }
        joinedAt: { type: string, format: date-time }
        endedAt: { type: [string, 'null'], format: date-time }
        lastCopied:
          type: [object, 'null']
          properties:
            at: { type: string, format: date-time }
            symbol: { type: string }
            side: { type: string, enum: [buy, sell] }
        spotUntil: { type: [string, 'null'], format: date-time }
        openCopies: { type: integer }
        actions:
          type: object
          properties:
            pause: { type: boolean }
            resume: { type: boolean }
            remove: { type: boolean }
    MaxProblem:
      type: [object, 'null']
      description: |
        What is wrong, in one code. A transient failure that is being retried is `starting`, not a problem.
        The same eight codes reach both sides of a place, but not the same sentence: `words` on a
        `MaxSubscriber` is written for the provider ("Waiting for your account to come online.") and on a
        `MaxMembership` for the person themselves ("Your account is ready. The account you copy is offline,
        so there is nothing to copy yet."), because the same fact is a different piece of news to each.
        `master_offline` in particular is answered on BOTH: the provider's subscriber line and the
        follower's own place read the copied account's heartbeat the same way, so the one thing a waiting
        follower most wants to know is not something only the provider can see.
      properties:
        code: { type: string, enum: [login_rejected, investor_password, server_not_found, waiting_for_room, master_offline, broker_down, retrying, other] }
        words: { type: string, description: 'One sentence, written for whichever side is reading it.' }
        fix: { type: string, enum: [password, server, wait, contact], description: On the follower's own view only. }
    MaxManage:
      type: object
      description: '"Manage this account yourself", 4.00 USD per 30 days. It replaces the older 2.00 USD access, which its holders keep to the end of their period.'
      properties:
        status: { type: string, enum: [none, active, grace, lapsed] }
        allowed: { type: boolean }
        priceUsd: { type: string, example: '4.00' }
        periodDays: { type: integer, example: 30 }
        paidThrough: { type: [string, 'null'], format: date-time }
        autoRenew: { type: boolean }
        card: { type: [object, 'null'], properties: { brand: { type: [string, 'null'] }, last4: { type: [string, 'null'] } } }
        legacy: { type: boolean }
        legacyPriceUsd: { type: [string, 'null'], example: '2.00' }
    MaxMembership:
      type: object
      description: The follower's own view of one place.
      properties:
        id: { type: string }
        status: { $ref: '#/components/schemas/MaxPlaceStatus' }
        program: { type: object, properties: { id: { type: string }, name: { type: string }, status: { type: string, enum: [draft, active, grace, lapsed, closed] } } }
        account:
          type: [object, 'null']
          properties:
            id: { type: string }
            platform: { $ref: '#/components/schemas/PlatformId' }
            login: { type: [string, 'null'] }
            server: { type: [string, 'null'] }
            broker: { type: [string, 'null'] }
            environment: { type: [string, 'null'], enum: [live, demo, null] }
            state: { type: string }
            connection: { type: string }
        viewOnly: { type: boolean }
        manage: { $ref: '#/components/schemas/MaxManage' }
        progress:
          type: object
          description: The four honest lines the joining page shows, and whether it is late.
          properties:
            step: { type: integer }
            of: { type: integer, example: 4 }
            words: { type: string }
            since: { type: [string, 'null'], format: date-time }
            late: { type: boolean }
        problem: { $ref: '#/components/schemas/MaxProblem' }
        release:
          type: object
          description: After leaving, the account stays running and covered until every copied trade is closed.
          properties:
            state: { type: string, enum: [none, waiting_for_trades, stopped] }
            openCopies: { type: integer }
        joinedAt: { type: string, format: date-time }
        endedAt: { type: [string, 'null'], format: date-time }
        endedReason: { type: [string, 'null'], enum: [left, removed, program_closed, not_renewed, null] }
        spotUntil: { type: [string, 'null'], format: date-time }
        consent: { type: object, properties: { version: { type: integer, example: 2 }, at: { type: [string, 'null'], format: date-time } } }
    MaxRenewal:
      type: object
      properties:
        state: { type: string, enum: [none, auto, due, grace, lapsed, needs_minimum, no_card] }
        at: { type: [string, 'null'], format: date-time }
        amountUsd: { type: [string, 'null'] }
        spots: { type: [integer, 'null'] }
        card: { type: [object, 'null'], properties: { brand: { type: [string, 'null'] }, last4: { type: [string, 'null'] } } }
        endsPlacesAt: { type: [string, 'null'], format: date-time }
    MaxPayment:
      type: object
      properties:
        id: { type: string }
        reference: { type: string }
        kind: { type: string, enum: [start, add, renew, manage, credit] }
        spots: { type: [integer, 'null'] }
        what: { type: [string, 'null'] }
        amountUsd: { type: string }
        charge: { type: object, properties: { currency: { type: string }, amount: { type: string } } }
        status: { type: string, enum: [pending, paid, failed, abandoned, refund_due] }
        auto: { type: boolean }
        provider: { type: string, enum: [paystack, development] }
        periodFrom: { type: [string, 'null'], format: date-time }
        periodTo: { type: [string, 'null'], format: date-time }
        failure: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
        paidAt: { type: [string, 'null'], format: date-time }
        holdSpots: { type: [integer, 'null'], description: The room a pending payment holds, for 24 hours. }
    MaxHome:
      type: object
      properties:
        provider:
          type: [object, 'null']
          description: null when this person runs no MAX copy trading of their own.
          properties:
            program: { $ref: '#/components/schemas/MaxProgram' }
            others: { type: array, items: { type: object, properties: { id: { type: string }, name: { type: string }, status: { type: string }, spots: { type: object, properties: { paid: { type: integer }, used: { type: integer } } } } } }
            link: { oneOf: [{ $ref: '#/components/schemas/MaxLink' }, { type: 'null' }] }
            subscribers:
              type: object
              properties:
                items: { type: array, items: { $ref: '#/components/schemas/MaxSubscriber' } }
                counts: { type: object, properties: { all: { type: integer }, copying: { type: integer }, starting: { type: integer }, waiting: { type: integer }, attention: { type: integer }, paused: { type: integer }, left: { type: integer } } }
            invites: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/MaxInvite' } } } }
            renewal: { $ref: '#/components/schemas/MaxRenewal' }
            payments: { type: object, properties: { items: { type: array, items: { $ref: '#/components/schemas/MaxPayment' } } } }
        masters: { type: array, items: { $ref: '#/components/schemas/MaxMasterChoice' } }
        room: { $ref: '#/components/schemas/MaxRoom' }
        memberships: { type: array, items: { $ref: '#/components/schemas/MaxMembership' } }
        serverTime: { type: string, format: date-time }
    MaxOnboardRequest:
      type: object
      required: [name, email, platform, server, login]
      properties:
        name: { type: string, maxLength: 80, description: The name on the subscriber line. }
        email: { type: string, format: email }
        mode: { type: string, enum: [invite, manage], default: invite }
        platform: { $ref: '#/components/schemas/PlatformId' }
        server: { type: string }
        login: { type: string }
        password: { type: string, minLength: 4, maxLength: 128, description: 'Mode `manage` only, and required there: sealed on arrival and never shown back to anybody, the provider included. Mode `invite` stores no password and REFUSES one (`400`, `details.field: password`) rather than dropping it: the person types their own when they open the link.' }
        environment: { type: string, enum: [live, demo] }
        attest: { type: boolean, description: Required and exactly true in `manage` mode - the provider states they have this person's permission. }
    MaxOnboardResult:
      type: object
      properties:
        subscriber: { oneOf: [{ $ref: '#/components/schemas/MaxSubscriber' }, { type: 'null' }] }
        invite: { oneOf: [{ $ref: '#/components/schemas/MaxInvite' }, { type: 'null' }] }
    MaxJoinInfo:
      type: object
      description: |
        What an invite link opens on. Everything here is safe to show somebody who is not signed in: the
        offer has one public name and one line about it, and nothing about the provider's own account, their
        money or the people already copying them.
      properties:
        state: { type: string, enum: [open, invalid, expired, revoked, used, paused, closed, full] }
        reason: { type: [string, 'null'], enum: [unpaid, joins_closed, null], description: 'Why a `paused` link is paused.' }
        kind: { type: [string, 'null'], enum: [open, personal, null], description: 'A personal invite is for one address; an open link is the one the provider shares.' }
        emailHint: { type: [string, 'null'], example: 'ad•••@gmail.com', description: 'The address a personal invite was sent to, masked.' }
        programId: { type: [string, 'null'] }
        program:
          type: [object, 'null']
          description: |
            null while the link cannot be resolved at all. These three fields are the whole of it: this
            answer is public, so it carries the offer's one public name, its one public line and the
            platforms this gateway really offers, and nothing about the provider's own account or the people
            already copying them. Whether practice accounts are taken is NOT answered here - the join
            answers it, with `422 MAX_DEMO_NOT_ALLOWED`.
          properties:
            name: { type: string }
            description: { type: [string, 'null'] }
            platforms: { type: array, items: { $ref: '#/components/schemas/PlatformId' } }
        prefill:
          type: [object, 'null']
          description: |
            What the provider filled in for this person on a personal invite. It is a head start for the
            page, not a substitute for the join request: `POST /max/join/{code}` reads the account fields out
            of the request body and never out of the link.
          properties:
            platform: { $ref: '#/components/schemas/PlatformId' }
            server: { type: [string, 'null'] }
            loginMasked: { type: string, example: '•••• 4599' }
            environment: { type: string, enum: [live, demo] }
            hasPassword: { type: boolean, const: false, description: 'Whether a sealed broker credential travels with the invite. Always false, and always present: no invite carries a password, and a page that reads this field asks for one rather than claiming it was saved.' }
        consent:
          type: [object, 'null']
          description: The words the tick box must show, recorded verbatim when the spot is taken.
          properties:
            version: { type: integer, example: 2 }
            wording: { type: string }
        membership:
          type: [object, 'null']
          description: 'With a bearer: the caller''s own place on this offer, when they already have one.'
          properties:
            id: { type: string }
            status: { $ref: '#/components/schemas/MaxPlaceStatus' }
        yours: { type: boolean, description: 'With a bearer: this is the caller''s own invite link.' }
        emailMatches: { type: [boolean, 'null'], description: 'With a bearer, on a personal invite: whether the signed-in address is the one it was sent to. null otherwise.' }
    MaxJoinRequest:
      type: object
      required: [consent]
      description: |
        Give EITHER `accountId`, for a MetaTrader account the caller already has here, OR all four of
        `platform`, `server`, `login` and `password`, for one to be started for them.
      properties:
        consent: { type: boolean, description: 'Must be exactly true: it records the wording shown on the page.' }
        consentVersion: { type: integer, example: 2, description: The version the page displayed. A stale one is refused with MAX_CONSENT_VERSION. }
        displayName: { type: string, maxLength: 80, description: The name the provider sees on their list. Their sign-up name is used when this is left out, never their email. }
        accountId: { type: string, description: An existing MetaTrader account of the caller. }
        platform: { $ref: '#/components/schemas/PlatformId' }
        server: { type: string, maxLength: 128 }
        login: { type: string, description: The account number. }
        password: { type: string, writeOnly: true, minLength: 4, maxLength: 128, description: The main trading password, sealed on arrival. Never the read-only one, which is refused as investor_password. }
        environment: { type: string, enum: [live, demo], default: live }
    HookPolicy:
      type: object
      description: What a hook does when a signal meets what is already on the books.
      properties:
        onOpposite:
          type: string
          enum: [reverse, hedge, refuse]
          default: reverse
          description: 'reverse closes the opposite side and then opens — what a strategy that flips long to short means. hedge leaves it. refuse sends nothing and logs why.'
        onSame:
          type: string
          enum: [add, skip, refuse]
          default: add
          description: 'skip is for a strategy that repeats its signal every bar.'
        scope:
          type: string
          enum: [own, account]
          default: own
          description: 'own means this hook only closes, reverses or protects positions IT opened — read from the ledger, not from a tag on the order, so a trader editing a comment in the terminal cannot change it. The default, because the account is not the robot''s: someone running a strategy alongside their own trading keeps their hand-placed positions.'
        pipMode:
          type: string
          enum: [auto, point, tenth]
          default: auto
          description: 'What one pip means. auto is ten points on 3- and 5-digit instruments and one point elsewhere. Never inferred from the symbol''s NAME — "US30" on one broker is "DJ30" on the next.'
        riskBase: { type: string, enum: [balance, equity], default: balance }
        closeOrder: { type: string, enum: [fifo, lifo], default: fifo }
        maxRiskPercent: { type: [number, 'null'], description: 'A ceiling on risk= — a fat-fingered risk=50 stops here.' }
        maxLots: { type: [number, 'null'] }
        partialClosePercent: { type: number, default: 50, exclusiveMinimum: 0, maximum: 100 }
        maxSpreadPips: { type: [number, 'null'], exclusiveMinimum: 0, description: 'Entry spread limit using this connection’s pip convention. Requires a fresh native broker quote.' }
    HookGuards:
      type: object
      description: 'The gate in front of the hook. The account''s own risk settings guard the ORDER; these guard the ALERT, and each one exists because of a way a webhook specifically goes wrong.'
      properties:
        duplicateWindowMs: { type: integer, default: 2000, maximum: 60000, description: 'TradingView can deliver the same alert twice. Add a changing field (its {{timenow}} as nonce=) when a repeat is deliberate. A ping is never a duplicate — it opens nothing.' }
        cooldownMs: { type: integer, default: 0, description: 'Per command and instrument. 0 is off.' }
        maxPerMinute: { type: integer, default: 60, description: 'A strategy alerting more often than this is usually a loop.' }
        allowSymbols: { type: [array, 'null'], items: { type: string } }
        allowCommands: { type: [array, 'null'], items: { type: string } }
        allowIps: { type: [array, 'null'], items: { type: string }, description: 'null (the default) accepts any source. TradingView''s published senders are on GET /hooks/{id}/templates — they are TradingView''s to change, and a stale list refuses every alert, which is why this is off unless you turn it on.' }
        requireBodySecret: { type: boolean, default: true, description: 'Leave it on. With it off the URL alone trades the account.' }
        schedule:
          type: [object, 'null']
          description: |
            When new trades may open. null (the default) means always. Only opening commands are held by the
            schedule: closes, cancels and protection changes always go through, because an exit signal at
            22:00 still means exit. An alert outside the hours is refused with `outside_the_hook_schedule`
            and the local time it arrived. A window whose `to` is earlier than `from` crosses midnight.
          properties:
            timezone: { type: string, description: 'IANA zone name, e.g. Europe/London. Daylight saving is honoured.', default: UTC }
            windows:
              type: array
              maxItems: 14
              items:
                type: object
                required: [from, to]
                properties:
                  days: { type: array, items: { type: integer, minimum: 1, maximum: 7 }, description: '1 = Monday … 7 = Sunday. Omitted means every day.' }
                  from: { type: string, pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$', example: '08:00' }
                  to: { type: string, pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$', example: '17:00' }
        closeSchedule:
          type: [object, 'null']
          description: 'Opt-in scheduled closing of this connection’s own open trades on its receiving accounts. Defaults off. One occurrence per local date; missed minutes are logged, never caught up late. Paused hooks do not run. Pending orders are unchanged; new alerts may open trades later. Existing broker checks and execution holds still apply.'
          additionalProperties: false
          required: [timezone, days, at]
          properties:
            timezone: { type: string, example: Africa/Lagos }
            days: { type: array, minItems: 1, maxItems: 7, items: { type: integer, minimum: 1, maximum: 7 } }
            at: { type: string, pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$', example: '17:00' }
    FirmBranding:
      type: object
      properties:
        logoUrl: { type: string, description: https URL }
        accent: { type: string, description: 'hex colour, e.g. #1FB6A6' }
        supportEmail: { type: string }
        tagline: { type: string, maxLength: 120 }
        domain: { type: string, description: 'the firm''s own hostname; MirroAPI attaches it (Enterprise)' }
    Firm:
      type: object
      properties:
        slug: { type: string }
        name: { type: string }
        status: { type: string, enum: [active, suspended] }
        package: { type: string, enum: [starter, growth, enterprise] }
        packageLabel: { type: string }
        seatLimit: { type: integer }
        features: { type: array, items: { type: string } }
        branding: { $ref: '#/components/schemas/FirmBranding' }
        notes: { type: [string, 'null'] }
        me: { type: object, properties: { role: { type: [string, 'null'], enum: [owner, risk, ops, trader, null] }, status: { type: [string, 'null'] }, can: { type: array, items: { type: string } } } }
        roles: { type: object, additionalProperties: { type: string } }
        packages: { type: object, additionalProperties: { type: object, properties: { label: { type: string }, seats: { type: integer }, features: { type: array, items: { type: string } } } } }
        createdAt: { type: [string, 'null'], format: date-time }
    FirmRules:
      type: object
      properties:
        maxLots: { type: [number, 'null'], description: 'Ceiling per order, in lots. null = none.' }
        maxRiskPercent: { type: [number, 'null'], description: 'Ceiling per trade for percentage and cash-risk sizing.' }
        requireStop: { type: boolean, description: 'An opening alert without a stop loss is refused.' }
        schedule: { type: [object, 'null'], description: 'Same shape as a connection''s guards.schedule; holds opens only, for every trader.' }
        allowSymbols: { type: [array, 'null'], items: { type: string }, description: 'The only instruments traders may open. A trader''s own list can only narrow it.' }
        forbidCopy: { type: boolean, description: 'Cross-person copy routes into the firm''s accounts stay refused.' }
        updatedAt: { type: [string, 'null'], format: date-time }
    ProgramPhase:
      type: object
      properties:
        name: { type: string, maxLength: 40 }
        targetPct: { type: [number, 'null'], description: 'Profit target from the stage''s starting balance; null only on the last (funded) stage.' }
        minTradingDays: { type: integer, minimum: 0, maximum: 365 }
    ProgramInput:
      type: object
      properties:
        name: { type: string, maxLength: 80 }
        model: { type: string, enum: [standard, pro], description: 'standard measures drawdown on balance (closed results); pro on equity (floating too).' }
        currency: { type: string, pattern: '^[A-Z]{3}$', default: NGN }
        startingBalance: { type: number }
        price: { type: [number, 'null'], minimum: 0, description: 'Challenge fee in priceCurrency, with up to two decimal places' }
        priceCurrency: { type: [string, 'null'], enum: [NGN, USD, EUR, GBP, ZAR, KES, GHS, null], description: 'Fee currency, independent of the trading account currency. Omitted or null defaults to the account currency. Checkout also requires the merchant to support this currency.' }
        drawdownBasis: { type: string, enum: [balance, equity] }
        drawdownMode: { type: string, enum: [static, trailing, eod_trailing], description: 'static from the starting balance (balance-based static = no trailing at all); trailing follows the high-water mark live; eod_trailing follows the high-water mark set at each day''s close.' }
        trailingLock: { type: string, enum: [none, at_start], description: 'at_start: a trailing floor stops rising once it reaches the starting balance.' }
        minHoldSeconds: { type: [integer, 'null'], description: 'A trade closed sooner is a warning (NairaProp''s 3-minute rule = 180). null = no rule.' }
        maxShortHolds: { type: [integer, 'null'], description: 'Warnings allowed; one more fails the challenge.' }
        maxDayProfitSharePct: { type: [number, 'null'], description: 'Consistency: the pass is held while one day is more than this share of the stage''s profit.' }
        maxDrawdownPct: { type: number }
        dailyLossPct: { type: [number, 'null'], description: 'Below the day''s opening equity, in the program''s time zone. null = none.' }
        phases: { type: array, minItems: 1, maxItems: 4, items: { $ref: '#/components/schemas/ProgramPhase' } }
        profitSplitPct: { type: number, minimum: 0, maximum: 100, default: 80 }
        maxLots: { type: [number, 'null'] }
        onBreach: { type: string, enum: [fail, flag, fail_close], description: 'fail ends the challenge the moment a rule is breached; fail_close also closes every open position and notes the outcome on the record; flag records a warning for a person.' }
        status: { type: string, enum: [draft, active, archived], description: "Drafts are staff-only and excluded from checkout and enrollment. New workspace forms save drafts; API creation defaults to active when omitted." }
        timezone: { type: string, default: UTC }
    MailSender:
      type: object
      properties:
        sender: { type: [string, 'null'] }
        verifiedAt: { type: [string, 'null'], format: date-time }
        inUse: { type: boolean, description: 'True only when verified: letters then carry the firm''s address; otherwise the default sender with the firm''s name and reply-to.' }
        next: { type: [string, 'null'] }
    FirmProgram:
      allOf:
        - $ref: '#/components/schemas/ProgramInput'
        - type: object
          properties:
            id: { type: string, format: uuid }
            live: { type: integer, description: 'Challenges currently active or funded on this program.' }
            createdAt: { type: string, format: date-time }
    Challenge:
      type: object
      description: 'One trader on one account under one program, with the live picture. The evaluator ticks every few seconds; a transition is recorded once with its arithmetic.'
      properties:
        id: { type: string, format: uuid }
        reference: { type: [string, 'null'] }
        program: { type: object, properties: { id: { type: string }, name: { type: string } } }
        state: { type: string, enum: [active, funded, failed, closed] }
        phaseIndex: { type: integer }
        poolManaged: { type: boolean }
        deliveryPending: { type: boolean }
        cycleIndex: { type: integer, minimum: 0 }
        phase: { type: [string, 'null'] }
        model: { type: string }
        currency: { type: string }
        startingBalance: { type: [number, 'null'] }
        balance: { type: [number, 'null'] }
        equity: { type: [number, 'null'] }
        drawdown: { type: object, properties: { basis: { type: string }, mode: { type: string }, anchor: { type: number }, floor: { type: number }, allowed: { type: number }, used: { type: [number, 'null'] }, usedPct: { type: [number, 'null'] }, current: { type: [number, 'null'] }, breached: { type: boolean } } }
        target: { type: object, properties: { phase: { type: [string, 'null'] }, target: { type: [number, 'null'] }, start: { type: number }, gained: { type: [number, 'null'] }, progressPct: { type: [number, 'null'] }, reached: { type: boolean }, minTradingDays: { type: integer } } }
        daily: { type: [object, 'null'], properties: { limitPct: { type: number }, dayStart: { type: [number, 'null'] } } }
        tradingDays: { type: integer }
        minTradingDays: { type: integer }
        profitSplitPct: { type: number }
        phases: { type: array, items: { allOf: [{ $ref: '#/components/schemas/ProgramPhase' }, { type: object, properties: { current: { type: boolean }, done: { type: boolean } } }] } }
        reporting: { type: boolean, description: 'The account reported within the last 3 minutes. Nothing is judged while false.' }
        lastSeenAt: { type: [string, 'null'], format: date-time }
        account: { type: object, properties: { id: { type: string }, login: { type: [string, 'null'] }, label: { type: [string, 'null'] }, platform: { type: [string, 'null'] }, broker: { type: [string, 'null'] }, state: { type: string } } }
        startedAt: { type: string, format: date-time }
        phaseStartedAt: { type: string, format: date-time }
        endedAt: { type: [string, 'null'], format: date-time }
        endReason: { type: [string, 'null'], enum: [max_drawdown, daily_loss, min_hold, override, null] }
        positionsClosedAt: { type: [string, 'null'], format: date-time, description: 'When fail_close flattened the account.' }
        hold: { type: [object, 'null'], properties: { minSeconds: { type: integer }, shortHolds: { type: integer }, allowed: { type: integer } } }
        consistency: { type: [object, 'null'], properties: { maxDayProfitSharePct: { type: number } } }
        trailingLock: { type: string }
        trader: { type: object, description: 'Staff only.', properties: { id: { type: string }, email: { type: string }, name: { type: [string, 'null'] }, label: { type: [string, 'null'] } } }
        events: { type: array, items: { type: object, properties: { id: { type: string }, at: { type: string, format: date-time }, kind: { type: string, enum: [started, phase_passed, funded, failed, warning, override, note, closed, stale] }, message: { type: string }, by: { type: string }, detail: { type: object } } } }
    NotificationSettings:
      type: object
      properties:
        emailEnabled: { type: boolean }
        email: { type: [string, 'null'], description: 'null means the sign-in address (accountEmail).' }
        accountEmail: { type: [string, 'null'] }
        emailConfigured: { type: boolean, description: 'Whether this gateway can send e-mail at all.' }
        telegram:
          type: object
          properties:
            available: { type: boolean }
            botUsername: { type: [string, 'null'] }
            linked: { type: boolean }
            name: { type: [string, 'null'] }
            linkedAt: { type: [string, 'null'], format: date-time }
        events:
          type: object
          description: 'Per kind: whether it sends (on), its label, and the default for a fresh account.'
          additionalProperties:
            type: object
            properties: { on: { type: boolean }, label: { type: string }, default: { type: boolean } }
    HookDefaults:
      type: object
      description: |
        What an alert may leave out, written in the SAME words an alert uses — not a second
        vocabulary. With `{"risk":1,"sl":20,"tp":40}` set here, the TradingView message is
        `SECRET,buy,{{ticker}}` instead of the whole line, on every alert. A default never
        overrides what a message says; it fills a slot the message left empty, and the alert row
        records which values came from where. Defaults apply to opening orders, never to closing or protection-only commands. Risk sizing needs a stop, here as in an alert.
      properties:
        risk: { type: number, description: 'Per cent of the account. Needs sl or slPrice.' }
        lots: { type: number }
        sl: { type: number, description: pips }
        slPrice: { type: number }
        tp: { type: number, description: pips }
        tpPrice: { type: number }
        slippage: { type: integer, description: points }
        cashRisk: { type: number, description: 'Planned loss in account currency. Requires a stop.' }
        balanceRisk: { type: number, description: 'Planned percentage loss using balance explicitly. Requires a stop.' }
        equityRisk: { type: number, description: 'Planned percentage loss using equity explicitly. Requires a stop.' }
        lotsPer1000: { type: number, maximum: 100, description: 'Fixed lots for every 1,000 of the account balance (alert field lots_per_1000=). 0.01 on a 10,000 balance is 0.10 lots. Needs no stop.' }
        slPercent: { type: number, description: 'Stop distance as percentage of the entry reference. Market orders use a fresh broker quote.' }
        tpPercent: { type: number, description: 'Target distance as percentage of the entry reference.' }
    HookManage:
      type: object
      description: |
        Looking after a position after it is open, checked every few seconds against the live
        price — on positions this hook opened, and only those. Empty means the position is left
        exactly as it was placed.

        Every decision is taken from what is true right now, never from a memory of the last
        pass, so a skipped pass, a doubled pass or a restart all reach the same answer. A stop
        only ever tightens: it never follows the price back down, and is never set within a tick
        of the market.
      properties:
        breakEvenAt: { type: number, description: 'Pips of profit before the stop moves to the entry.' }
        breakEvenOffset: { type: number, description: 'Pips beyond the entry, without assuming fees are covered.' }
        trailAfter: { type: number, description: 'Pips of profit before trailing starts. Needs trailBy.' }
        trailBy: { type: number, description: 'How far behind the price the stop is kept.' }
        trailStep: { type: number, description: 'Minimum pip improvement before another trailing update. Requires trailing distance and trigger.' }
        takeProfits:
          type: array
          maxItems: 8
          description: 'Partial exits. The size still open IS the progress counter, so a level whose share is already gone asks for nothing.'
          items:
            type: object
            properties: { atPips: { type: number }, closePercent: { type: number } }
    AlertLink:
      type: object
      required: [id, name, command, symbol, createdAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        command: { type: string }
        symbol: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
    Hook:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        accountIds: { type: array, items: { type: string, format: uuid } }
        url: { type: string, description: 'Masked on ordinary reads; full URL on creation, rotation and authorized reveal=true reads when stored encrypted. A masked URL cannot receive an alert.' }
        keyPrefix: { type: string }
        secret: { type: string, description: 'The message secret. Returned on create, credential rotation, and authorized GET with reveal=true.' }
        template: { type: string, description: 'A ready-to-paste market-order message, secret included.' }
        policy: { $ref: '#/components/schemas/HookPolicy' }
        guards: { $ref: '#/components/schemas/HookGuards' }
        symbolMap: { type: object, additionalProperties: { type: string } }
        defaults: { $ref: '#/components/schemas/HookDefaults' }
        manage: { $ref: '#/components/schemas/HookManage' }
        source: { $ref: '#/components/schemas/HookSource' }
        style: { $ref: '#/components/schemas/HookStyle' }
        reading: { $ref: '#/components/schemas/HookReading' }
        paused: { type: boolean }
        createdAt: { type: string, format: date-time }
        lastAlertAt: { type: [string, 'null'], format: date-time }
    HookSource:
      type: object
      description: 'Where a hook''s alerts come from. `webhook` is the URL a charting platform POSTs to. `telegram` is a channel read through the owner''s own Telegram; `accountId` names the connected Telegram, and `disconnectedAt` is set when that Telegram was disconnected, which pauses the hook without deleting it.'
      required: [kind]
      properties:
        kind: { type: string, enum: [webhook, telegram] }
        accountId: { type: string, format: uuid, description: 'telegram: the connected Telegram this reads through.' }
        channelId: { type: string, description: 'telegram: the channel, as GET /telegram/channels gives it.' }
        channelTitle: { type: string }
        channelUsername: { type: [string, 'null'] }
        disconnectedAt: { type: string, format: date-time, description: 'telegram: when the Telegram behind this source was disconnected. The hook is paused, and connecting again resumes it.' }
    HookStyle:
      type: object
      description: |
        How THIS channel writes. It names words and aliases; it never carries a price, a size or a direction,
        so a style can be shared between people without carrying anyone's trade into it. Empty is valid: the
        reader knows the ordinary ways of writing a signal already, and a style is what to add for a channel
        that writes its own way.
      properties:
        aliases:
          type: object
          additionalProperties: { type: string }
          maxProperties: 100
          description: 'A word the channel uses, to the instrument it means, e.g. {"GOLD":"XAUUSDm"}. The strongest evidence there is: an alias is taken over anything the text is guessed to mean. Up to 24 characters a key.'
        words:
          type: object
          description: 'Extra words this channel uses for a field, e.g. {"target":["objective","tp zone"]}. Up to 40 words each, matched case-insensitively.'
          additionalProperties: { type: array, items: { type: string, maxLength: 40 }, maxItems: 40 }
          propertyNames: { type: string, enum: [buy, sell, entry, stop, target, close, partial, breakeven, cancel, hit, market, limit, noise] }
        numberOrder:
          type: array
          maxItems: 8
          items: { type: string, enum: [entry, stop, target] }
          description: 'What unlabelled numbers mean, in the order they appear, for a channel that writes `2340 2330 2345` and nothing else.'
        examples:
          type: array
          maxItems: 20
          description: 'Real messages from the channel, each optionally with what it meant. Read by the optional second opinion; never a source of numbers.'
          items: { type: object, required: [text], properties: { text: { type: string, maxLength: 2000 }, means: { type: string, maxLength: 200 } } }
        language: { type: string, maxLength: 16, description: 'A short language name, when the channel does not write in English.' }
    HookReading:
      type: object
      description: |
        What a reading MEANS on this connection. Every field has a default, and the one that matters most is
        `mode`: every new Telegram connection starts in `watch`, where it reads every message and writes down
        what it would have sent, and sends nothing. Nothing trades until somebody sets `trade` on purpose.
      properties:
        mode: { type: string, enum: [watch, trade], default: watch, description: '`watch` reads and records; it never sends. Every new connection starts here.' }
        minConfidence: { type: number, minimum: 0, maximum: 1, default: 0.75, description: 'Below this the message is held and logged, never traded.' }
        targets: { type: string, enum: [first, last, split], default: split, description: 'A signal naming several targets: take the first, the last, or split the size across them.' }
        splitWeights: { type: [array, 'null'], items: { type: number }, description: 'Per-target percentages for `split`, e.g. [50, 30, 20]; they must total 100. null splits equally.' }
        missingStop: { type: string, enum: [skip, default, none], default: skip, description: 'A signal with no stop: skip it, use the connection''s saved stop, or open without one.' }
        missingTarget: { type: string, enum: [none, skip, default], default: none }
        entry: { type: string, enum: [market, as_written], default: as_written, description: '`as_written` places a pending order when the message says limit or stop; `market` always enters now.' }
        onClose: { type: boolean, default: true }
        onPartial: { type: boolean, default: true }
        onBreakeven: { type: boolean, default: true }
        onUpdate: { type: boolean, default: true, description: 'Follow stop and target changes the channel posts later.' }
        onCancel: { type: boolean, default: true }
        onTargetHit: { type: string, enum: [nothing, breakeven], default: breakeven, description: 'What a "target reached" post does. The broker has already closed that part; this is about the rest.' }
        onEdit: { type: string, enum: [nothing, stop], default: stop, description: 'An edited signal whose stop changed moves the stop.' }
        onDelete: { type: string, enum: [nothing, cancel], default: cancel, description: 'A deleted signal cancels the pending order it placed. A filled position is never closed by a deletion.' }
        allowSymbols: { type: [array, 'null'], items: { type: string }, description: 'Trade only these; null for any.' }
        blockSymbols: { type: [array, 'null'], items: { type: string } }
        maxPerDay: { type: [integer, 'null'], description: 'Entries a calendar day (UTC); null for no cap. Management instructions are not counted.' }
        assist: { type: boolean, default: true, description: 'Ask the gateway''s language model when the rules are unsure and one is configured. Every number it answers must appear in the message or the whole answer is dropped, and an assisted reading is capped at 0.9 confidence and marked.' }
    TelegramAccount:
      type: object
      description: 'The Telegram connected to this sign-in. The session behind it is sealed under the tenant''s key and never leaves the gateway; the phone is only ever given back masked.'
      required: [id, phone, state, connectedAt]
      properties:
        id: { type: string, format: uuid }
        phone: { type: string, description: 'Masked, e.g. +234••••••5678.' }
        username: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        state: { type: string, enum: [connected, signed_out, expired, error], description: '`expired` is a session ended from Telegram''s own devices screen. The gateway notices, records it, and never tries to sign in again by itself.' }
        error: { type: [string, 'null'] }
        connectedAt: { type: string, format: date-time }
        lastSeenAt: { type: [string, 'null'], format: date-time }
        lastEventAt: { type: [string, 'null'], format: date-time, description: 'When a message last arrived down this Telegram.' }
    TelegramChannel:
      type: object
      required: [id, title, kind]
      properties:
        id: { type: string, description: 'Telegram''s own id, as a string: it does not fit a 32-bit integer and is negative for a channel.' }
        title: { type: string }
        username: { type: [string, 'null'] }
        kind: { type: string, enum: [channel, group] }
        members: { type: [integer, 'null'] }
    SignalPrice:
      type: object
      description: 'A number read out of a message, with how it was read. `range` is a zone (`2340-2337`), and the entry taken from it is named in the reading''s evidence.'
      required: [kind, how]
      properties:
        kind: { type: string, enum: [price, pips, percent, range] }
        value: { type: number, description: 'price, pips, percent.' }
        low: { type: number, description: 'range.' }
        high: { type: number, description: 'range.' }
        how: { type: string, description: 'Why it was read that way, in words, e.g. `the zone 2337–2340 after "@"`.' }
    SignalReading:
      type: object
      description: |
        What the reader made of one message. Every field carries the EVIDENCE for why it was read that way,
        and no number is ever invented: a price in the reading appears in the message. `confidence` is 0 to 1
        and the connection's `minConfidence` decides what is enough; `warnings` names each doubt.
      required: [kind, confidence]
      properties:
        kind: { type: string, enum: [open, manage, noise] }
        confidence: { type: number, minimum: 0, maximum: 1 }
        symbol: { type: [string, 'null'], description: 'The instrument, as this account''s broker spells it.' }
        symbolText: { type: [string, 'null'], description: 'What the message actually wrote, e.g. `GOLD`.' }
        side: { type: [string, 'null'], enum: [buy, sell, 'null'] }
        order: { type: [string, 'null'], enum: [market, limit, stop, 'null'] }
        entry: { anyOf: [{ $ref: '#/components/schemas/SignalPrice' }, { type: 'null' }] }
        stop: { anyOf: [{ $ref: '#/components/schemas/SignalPrice' }, { type: 'null' }] }
        targets: { type: array, items: { $ref: '#/components/schemas/SignalPrice' } }
        manage:
          description: 'kind `manage`: the instruction, e.g. close half, move the stop to breakeven, cancel.'
          type: [object, 'null']
          additionalProperties: true
          properties:
            action: { type: string, enum: [close, close_partial, breakeven, move_stop, move_target, cancel, target_hit, stop_hit, update] }
            all: { type: boolean, description: 'The instruction is about every open position, not one instrument.' }
        refersTo: { type: [string, 'null'], enum: [reply, 'null'], description: '`reply`: the instrument or the signal came from the message this one replies to, which is how channels write "close half" under the original.' }
        warnings: { type: array, items: { type: string }, description: 'Doubts by name: symbol_missing, symbol_ambiguous, several_symbols, no_stop, …' }
        evidence: { type: array, items: { type: object, additionalProperties: true }, description: 'Field by field, the span of text each reading came from.' }
        reads: { type: string, description: 'The whole reading in one plain sentence, as the console shows it.' }
        assist:
          type: object
          description: 'Present only when the optional second reader was asked. An assisted reading is capped at 0.9 confidence.'
          properties:
            asked: { type: boolean }
            accepted: { type: boolean, description: 'false when its answer named a number or an instrument the message does not contain — the whole answer is then dropped.' }
            agreed: { type: boolean }
            why: { type: [string, 'null'] }
            error: { type: string }
    RehearsedMessage:
      type: object
      description: 'One message in a rehearsal: what it says, what this connection makes of it, and what it WOULD send. Nothing is sent.'
      required: [messageId, text, reading, held]
      properties:
        messageId: { type: integer, format: int64 }
        postedAt: { type: [string, 'null'], format: date-time }
        text: { type: string, maxLength: 4000 }
        replyTo: { type: [integer, 'null'], format: int64 }
        reading: { anyOf: [{ $ref: '#/components/schemas/SignalReading' }, { type: 'null' }] }
        reads: { type: [string, 'null'], description: 'The reading in one sentence.' }
        would: { type: [array, 'null'], items: { type: string }, description: 'The exact alert lines this would have sent, in the same grammar a TradingView alert uses. null when it is held.' }
        held: { $ref: '#/components/schemas/SignalHeld' }
        why: { type: string, description: 'The same thing in plain words.' }
    SignalHeld:
      type: [string, 'null']
      description: |
        Why nothing was sent, by name — null when it was. `watch_mode` (the connection reads only),
        `low_confidence`, `noise` (not a signal), `symbol_missing`, `symbol_blocked`, `side_missing`,
        `no_size` (the connection has no saved trade size), `no_stop`, `no_target`, `max_per_day`,
        `action_off` (that instruction is switched off here), `nothing_changed`, `informational`
        (a target was reached and the broker closed that part itself).
      enum: [watch_mode, low_confidence, noise, symbol_missing, symbol_blocked, side_missing, no_size, no_stop, no_target, max_per_day, action_off, nothing_changed, informational, 'null']
    SourceMessage:
      type: object
      description: 'One message the listener received for a connection, and what it did about it.'
      required: [messageId, event, outcome]
      properties:
        messageId: { type: integer, format: int64 }
        event: { type: string, enum: [new, edit, delete], description: 'A channel edits and deletes its own posts; both are read, and what each does is the connection''s `onEdit` and `onDelete`.' }
        replyTo: { type: [integer, 'null'], format: int64 }
        postedAt: { type: [string, 'null'], format: date-time }
        receivedAt: { type: string, format: date-time }
        text: { type: [string, 'null'], maxLength: 4000 }
        reading: { anyOf: [{ $ref: '#/components/schemas/SignalReading' }, { type: 'null' }] }
        reads: { type: [string, 'null'] }
        outcome:
          type: object
          description: What came of it.
          required: [state]
          properties:
            state: { type: string, enum: [traded, held, refused, noise], description: '`refused` is a line the pipeline itself turned down — a guard, the schedule, the duplicate window — and the alert log says which.' }
            held: { $ref: '#/components/schemas/SignalHeld' }
            why: { type: string }
            notes: { type: array, items: { type: string } }
            alerts: { type: array, items: { type: string }, description: 'The alert ids the lines became. Each is in GET /hooks/{hookId}/alerts with its commands, like any TradingView alert.' }
    HookCreated:
      allOf:
        - $ref: '#/components/schemas/Hook'
        - type: object
          properties:
            key: { type: string, description: 'The whole URL key. Returned on creation or rotation; store privately.' }
    HookRun:
      type: object
      description: What an alert did. The same body whatever the status.
      properties:
        id: { type: string, description: The alert's id; every command it produced carries it as clientId. }
        verdict: { type: string, enum: [processing, acted, ignored, refused] }
        reason: { type: string, description: 'A stable code: duplicate_alert, nothing_to_close, below_broker_minimum, symbol_not_on_this_account, alert_field_not_carried, hook_paused, book_unreadable …' }
        message: { type: string, description: The same thing in plain words, as the alert log shows it. }
        commands: { type: array, items: { type: string }, description: Command ids, in the order they were sent, across every account. }
        accounts:
          type: array
          description: 'Present when the hook trades more than one account: each one''s own verdict.'
          items:
            type: object
            properties:
              accountId: { type: string, format: uuid }
              verdict: { type: string, enum: [processing, acted, ignored, refused] }
              reason: { type: string }
              message: { type: string }
              commands: { type: array, items: { type: string } }
        notes: { type: array, items: { type: string } }
        ms: { type: integer }
    HookAlert:
      type: object
      description: 'One row of the log. Every alert that reached the hook is here, acted on or not — "why did nothing happen?" is the only question this product gets, and the row is the answer.'
      properties:
        id: { type: string }
        receivedAt: { type: string, format: date-time }
        verdict: { type: string, enum: [processing, acted, ignored, refused] }
        reason: { type: [string, 'null'] }
        message: { type: [string, 'null'] }
        command: { type: [string, 'null'] }
        symbol: { type: [string, 'null'] }
        raw: { type: string, description: Message with the credential removed. Malformed JSON is omitted. }
        commands: { type: array, items: { type: string } }
        executions:
          type: array
          description: Current command ledger outcomes. An acted alert means submission, not broker completion. Processing outcomes require reconciliation before replay.
          items:
            type: object
            properties:
              id: { type: string }
              accountId: { type: string, format: uuid }
              state: { type: string, enum: [queued, claimed, sent, filled, placed, closed, modified, cancelled, refused, rejected, expired] }
              result: { type: object, additionalProperties: true }
        sourceIp: { type: [string, 'null'] }
        ms: { type: [integer, 'null'] }
        detail:
          type: object
          description: |
            `accounts` holds one entry per account the alert reached, each with its own verdict,
            the instrument as THAT broker spells it, and `plan` — what the alert meant there, step
            by step, with the arithmetic that produced it: the risk budget, what one lot costs
            over the stop distance, the division, the lot grid, and what the trade will actually
            risk if the stop is hit. That is the answer to "why that size?", per account.
            `fromDefaults` names the values the message did not carry.
    Refusal:
      type: object
      description: Every error the gateway returns has this envelope; refusals add `details.arithmetic`.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum: [API_BUSY, MIRA_BUSY, MIRA_CREDIT_REQUIRED, MIRA_VERIFY_EMAIL, MIRA_PAYMENTS_UNAVAILABLE, MIRA_BROWSING_UNAVAILABLE, MIRA_NOT_ENABLED, MIRA_ALLOWANCE, MIRA_UNAVAILABLE, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, METHOD_NOT_ALLOWED, CONFLICT, PAYLOAD_TOO_LARGE, RATE_LIMITED, NOT_IMPLEMENTED, INTERNAL, IDEMPOTENCY_MISMATCH, UPGRADE_REQUIRED, TEST_KEY_LIVE_ACCOUNT, TEST_KEY_LIVE_KEY, PLATFORM_UNSUPPORTED, USE_OAUTH, USE_PLATFORM_CONNECT, FIRM_PERMISSION_REQUIRED, RELIABILITY_UNAVAILABLE, ACCOUNT_REFUSED, TERMINAL_LOGIN_ON_OTHER_PLATFORM, TERMINAL_SERVER_IS_OTHER_PLATFORM, TERMINAL_ACCOUNT_ALREADY_OWNED, TERMINAL_ACCOUNT_CHANGED, TERMINAL_BROKER_IDENTITY_AMBIGUOUS, TERMINAL_TENANT_CAPACITY_REACHED, TERMINAL_USER_CAPACITY_REACHED, TERMINAL_ONBOARDING_INVALID, LIVE_CONFIRMATION_REQUIRED, EA_UPGRADE_REQUIRED, INVALID_REQUEST, NO_CONSENT, EXECUTION_ROLE_REQUIRED, ACCOUNT_DISCONNECTED, BROKER_DOWN, FIRM_UNREACHABLE, RISK_MAX_LOTS, RISK_MAX_OPEN_LOTS, RISK_MAX_POSITIONS, RISK_DAILY_LOSS, RISK_DAILY_LOSS_UNKNOWN, RISK_OPEN_LOTS_UNKNOWN, RISK_TRADING_HOURS, RISK_RULE_UNSUPPORTED, TICKET_SUPERSEDED, TICKET_READDRESS_FAILED, MARGIN_PRICE_UNSUPPORTED, GATEWAY_UNAVAILABLE, OPS_UNAVAILABLE, CTRADER_UNAVAILABLE, TRADELOCKER_UNAVAILABLE, CTRADER_AUTHORIZATION_FAILED, CTRADER_FLOW_EXPIRED, CTRADER_TRADING_SCOPE_REQUIRED, CTRADER_GRANT_NOT_FOUND, CTRADER_ACCOUNT_NOT_GRANTED, TELEGRAM_UNAVAILABLE, TELEGRAM_SOURCES_UNAVAILABLE, TELEGRAM_NOT_CONNECTED, TELEGRAM_PHONE_INVALID, TELEGRAM_CODE_INVALID, TELEGRAM_CODE_EXPIRED, TELEGRAM_PASSWORD_NEEDED, TELEGRAM_PASSWORD_INVALID, TELEGRAM_LOGIN_EXPIRED, TELEGRAM_LOGIN_FAILED, TELEGRAM_CHANNEL_UNAVAILABLE, FIRM_TRADER_REQUIRED, PROGRAM_INVALID, EXECUTION_REFUSED, BROKER_REJECTED, EXECUTION_NOT_CLAIMED, EXECUTION_TRANSIENT_EXHAUSTED, EXECUTION_NETTED, BOOK_UNAVAILABLE, HISTORY_UNAVAILABLE, SYMBOL_CATALOG_UNAVAILABLE, TERMINAL_UNAVAILABLE, TERMINAL_READ_TIMEOUT, TERMINAL_READ_INVALID, TERMINAL_READ_UNAVAILABLE, TERMINAL_READ_CANCELLED, TERMINAL_CLOCK_UNCERTAIN, QUOTE_STALE, EXECUTION_UNKNOWN, COPY_REFUSED, COPY_SAME_ACCOUNT, FOLLOWER_ALREADY_LINKED, FOLLOWER_UNAVAILABLE, MASTER_UNAVAILABLE, MAX_PROGRAM_UNAVAILABLE, SIZING_INVALID, LINK_NOT_FOUND, PATCH_EMPTY, ACCOUNT_PLAN_REQUIRED, BILLING_ENROLLMENT_REQUIRED, FIRM_HOSTING_REQUIRED, INSUFFICIENT_BALANCE, BILLING_UNAVAILABLE, BILLING_SESSION_REQUIRED, BILLING_OFFER_UNAVAILABLE, PAYMENTS_DISABLED, PAYMENT_UNAVAILABLE, PAYMENT_CHECKOUT_UNAVAILABLE, PAYMENT_MINIMUM_TOPUP, PAYMENT_PENDING_LIMIT, PRICING_UNAVAILABLE, CARD_PAYMENTS_DISABLED, CARD_CURRENCY_UNAVAILABLE, CARD_AMOUNT_NOT_SUPPORTED, CARD_PROVIDER_UNAVAILABLE, PASSWORD_INCORRECT, ACCOUNT_LOCKED, PASSWORD_CHANGE_REQUIRED, ACCOUNT_DISABLED, TOKEN_INVALID, OTP_INVALID, CHALLENGE_INVALID, EMAIL_REQUIRED, MAX_PLATFORM_UNSUPPORTED, MAX_OWN_PROGRAM, MAX_INVITE_NOT_YOURS, MAX_CONSENT_REQUIRED, MAX_ALREADY_MEMBER, MAX_ACCOUNT_ALREADY_COPYING, MAX_API_UNAVAILABLE, PROGRAM_INACTIVE, LOGIN_IMMUTABLE, ACCOUNT_NOT_HOSTED, MAX_ACCOUNT_VIEW_ONLY, MAX_MASTER_IN_USE, MAX_MASTER_OFFLINE, MAX_RESUME_REFUSED, MAX_MASTER_IS_COPYING, MAX_ACCOUNT_IS_COPIED, MAX_CONSENT_VERSION, MAX_EMAIL_NOT_VERIFIED, MAX_REMOVED_BY_PROVIDER, MAX_DEMO_NOT_ALLOWED, MAX_ONBOARD_ATTEST_REQUIRED, MAX_ONBOARD_EMAIL_IN_PROGRAM, SPOTS_BELOW_MINIMUM, SPOTS_NO_ROOM, SPOTS_NONE_FREE, MAX_GUARD_UNAVAILABLE, MAIL_UNAVAILABLE, MAX_INVALID, MAX_EXPIRED, MAX_REVOKED, MAX_USED, MAX_PAUSED, MAX_CLOSED, MAX_FULL, MAX_REFUSED]
              description: |
                `INVALID_REQUEST` (422): a trade request failed validation; `details.field` names
                the field (null when the request as a whole is wrong). `BAD_REQUEST` (400): any
                other malformed request; some carry `details.field`.
                `API_BUSY` (503, with `Retry-After`): the gateway's bound on unfinished work is
                reached (reads and trade submissions have separate budgets). Nothing was started;
                retry after the header's delay and keep a trade's idempotency key unchanged.
                `RELIABILITY_UNAVAILABLE` (422, `details.field: reliability`): `reliability: high`
                on POST or PATCH /accounts; no standby terminal is in service on any platform.
                `TICKET_SUPERSEDED` (422): a MetaTrader 4 `close-partial` named a ticket that a
                partial close ended. `details.liveTicket` is the position now; nothing was sent, so
                that the remainder is never cut twice by a retry.
                `TICKET_READDRESS_FAILED` (422): a MetaTrader 4 command named a superseded ticket
                and its ledger row could not be re-addressed to the live one; nothing was sent
                (`details.ticket`, `details.liveTicket`).
                `PLATFORM_UNSUPPORTED` (422): `mt4` on a gateway started with `MIRRO_API_MT4=0`
                (`POST /accounts`, `POST /configuration-links`); `GET /platforms` shows the same
                gateway's `mt4` row as `status: unsupported` with `selfOnboard.reason` naming this code.
                A gateway that offers MetaTrader 4 never answers it. `TERMINAL_LOGIN_ON_OTHER_PLATFORM`: this MetaTrader login
                already runs on the other MetaTrader version (`details.platform`).
                `TERMINAL_SERVER_IS_OTHER_PLATFORM`: the broker directory knows this server only
                for the other MetaTrader version. A provisioning or copy refusal whose reason has no
                named code lands on `ACCOUNT_REFUSED` / `COPY_REFUSED` with the reason as sent in
                `details.reason`; the code set is closed.
                `BOOK_UNAVAILABLE` (503, `details.read: positions | orders`): the account's book
                could not be read. It is NOT an empty book and NOT "no such position": nothing
                about the positions or orders is known from that answer. Retry.
                `EXECUTION_UNKNOWN` (503 on `POST /trades`, `details.command`,
                `details.intendedRefusal`): the gateway meant to refuse the command but could not
                record the refusal, so it may still execute; read the command before re-sending.
                `BROKER_REJECTED`: the terminal's final refusal; `result.broker` is its reason
                (including the permanent MetaTrader 4 words `ticket_superseded` — with the live
                ticket in `result.ticket` — `remainder_unproven` and `remainder_ambiguous`, after
                which nothing was traded).
                `EXECUTION_REFUSED`: MIRRO refused the command before any terminal saw it;
                `result.reason` is the word (`position_book_unknown`, `entry_price_required`,
                `stop_limit_unsupported`, `invalid_request`, `unsupported_action`,
                `opens_suppressed`) and there is no `result.broker` — the broker was never asked.
                `EXECUTION_NOT_CLAIMED`: nothing claimed the command before it expired.
                `EXECUTION_TRANSIENT_EXHAUSTED`: the terminal answered a transient reason on every
                attempt until the command expired; `result.broker` carries the last reason.
                `EXECUTION_UNKNOWN` as a `Command.result.code`: a terminal took the command and no
                answer ever came back — the outcome is not known, and it is never called rejected.
                `EXECUTION_NETTED`: the order WAS executed — on a MetaTrader 5 netting account it
                reduced a position already open on the symbol instead of opening one of its own, so
                there is nothing of MIRRO's to name (`result.executed: true`, no `ticket`,
                `result.broker: netted_against_existing`). Never resend on this code: a resend nets
                the position again. Read `GET /positions` before deciding anything.
                The last six arrive in `Command.result.code`, not as an HTTP error.
                Native terminal reads (`/symbols/{symbol}/price`, `/tick`, `/server-time?source=terminal`,
                `POST /margin`) answer `EA_UPGRADE_REQUIRED` (422, the terminal's EA is older than
                the read needs; on `POST /trades` a partial `modify-order` below EA 1.7.3),
                `MARGIN_PRICE_UNSUPPORTED` (422, a `price` on MetaTrader 4), `RATE_LIMITED` with
                `details.scope: terminal-read` (429, one read at a time per account) and the 503s
                `TERMINAL_UNAVAILABLE`, `QUOTE_STALE`, `TERMINAL_READ_TIMEOUT`,
                `TERMINAL_READ_INVALID`, `TERMINAL_READ_UNAVAILABLE`, `TERMINAL_CLOCK_UNCERTAIN`
                and `TERMINAL_READ_CANCELLED`: no reading is invented. `SYMBOL_CATALOG_UNAVAILABLE`
                and `HISTORY_UNAVAILABLE` (503) are the catalogue and history twins of
                `BOOK_UNAVAILABLE`. `BILLING_ENROLLMENT_REQUIRED` and `INSUFFICIENT_BALANCE` (402
                on provisioning and deploy, 422 on a risk-opening trade) and `BILLING_UNAVAILABLE`
                (503, or 422 on a trade) apply in live payment mode only. `UPGRADE_REQUIRED` (426):
                a plain HTTP request to `/stream`.
                `RISK_RULE_UNSUPPORTED` (422 on `PUT /risk`, `details.rule`): the rule has no enforcer,
                so it is refused rather than stored. `RISK_OPEN_LOTS_UNKNOWN` (422): a `maxOpenLots`
                rule is set and the account's current exposure cannot be counted — `details.arithmetic.reason`
                says why (`book_unreadable`, `terminal_count_unknown`, `book_behind_terminal`, `book_stale`,
                `lots_missing`; the RiskSettings schema explains each); usually the next book, about 5 s,
                clears it, and exits are never refused. `LIVE_CONFIRMATION_REQUIRED` (422 on `POST /configure/{token}`):
                a live account through a configuration link needs the trader's phrase.
                The sandbox boundary is named: `TEST_KEY_LIVE_ACCOUNT` (403, a test key on a live account)
                and `TEST_KEY_LIVE_KEY` (403, a test key asking for a live key).
                Provisioning refusals are named codes now, with the data layer's word in `details.reason`:
                `TERMINAL_ACCOUNT_ALREADY_OWNED`, `TERMINAL_ACCOUNT_CHANGED`, `TERMINAL_BROKER_IDENTITY_AMBIGUOUS`,
                `TERMINAL_TENANT_CAPACITY_REACHED`, `TERMINAL_USER_CAPACITY_REACHED`, `TERMINAL_ONBOARDING_INVALID`
                and the fallback `ACCOUNT_REFUSED`; copy refusals the same: `COPY_SAME_ACCOUNT`,
                `FOLLOWER_ALREADY_LINKED`, `FOLLOWER_UNAVAILABLE`, `MASTER_UNAVAILABLE`, `MAX_PROGRAM_UNAVAILABLE`,
                `SIZING_INVALID`, `LINK_NOT_FOUND`, `PATCH_EMPTY` and the fallback `COPY_REFUSED` (all 422).
                `FIRM_PERMISSION_REQUIRED` (422): DXtrade is switched on per firm, so a trader cannot connect it alone.
                Console sign-in codes: `TOKEN_INVALID` (422: a verification or reset link that expired or
                was used), `OTP_INVALID` (401/422: a one-time code, `details.attemptsRemaining`),
                `CHALLENGE_INVALID` (401: start the sign-in again), `PASSWORD_INCORRECT` (401: the current
                password on a change), `ACCOUNT_LOCKED` (423: too many failed sign-ins; wait a few minutes).
                Card payments (`POST /billing/card-checkouts`, and a plan paid by card at `POST /billing/plans/checkout`): `EMAIL_REQUIRED` (422),
                `CARD_PAYMENTS_DISABLED`, `PAYMENTS_DISABLED`, `PAYMENT_PENDING_LIMIT`,
                `PAYMENT_CHECKOUT_UNAVAILABLE`, `CARD_CURRENCY_UNAVAILABLE`, `BILLING_OFFER_UNAVAILABLE` (503,
                nothing charged), `PAYMENT_MINIMUM_TOPUP`, `CARD_AMOUNT_NOT_SUPPORTED` (400),
                `BILLING_SESSION_REQUIRED` (403: accept billing from a signed-in session). MAX copy trading
                (console product; its routes are not in this file): `PAYMENT_UNAVAILABLE`, `PRICING_UNAVAILABLE`,
                `PROGRAM_INACTIVE`, `ACCOUNT_NOT_HOSTED`,
                `LOGIN_IMMUTABLE`, `MAX_OWN_PROGRAM`, `MAX_INVITE_NOT_YOURS`, `MAX_ALREADY_MEMBER`,
                `MAX_CONSENT_REQUIRED`, `MAX_PLATFORM_UNSUPPORTED`, `MAX_ACCOUNT_ALREADY_COPYING`,
                `MAX_API_UNAVAILABLE` (all 422). Signing in: `PASSWORD_CHANGE_REQUIRED` (403: the
                password was one an operator issued, so until the person sets their own only
                `GET /auth/me`, `POST /auth/password` and `POST /auth/logout` answer) and
                `ACCOUNT_DISABLED` (403: an operator switched the sign-in off; sessions already held
                end at their next call). Telegram as a signal source (session-only routes, see the
                Telegram tag): `TELEGRAM_SOURCES_UNAVAILABLE` (503: this gateway has no Telegram
                application id and hash, so every one of those routes answers it),
                `TELEGRAM_NOT_CONNECTED` (409/503), `TELEGRAM_LOGIN_EXPIRED` (404: the ten-minute
                attempt is gone), `TELEGRAM_PHONE_INVALID`, `TELEGRAM_CODE_INVALID`,
                `TELEGRAM_CODE_EXPIRED`, `TELEGRAM_PASSWORD_INVALID` (400),
                `TELEGRAM_PASSWORD_NEEDED` (401: two-step verification, finish at
                `POST /telegram/login/password`), `TELEGRAM_CHANNEL_UNAVAILABLE` (404) and
                `TELEGRAM_LOGIN_FAILED` (400/403/429/503: what Telegram itself refused — the message
                says which). Every code is described, with what to do, at
                https://mirroapi.com/docs/errors/.
            message: { type: string }
            docs: { type: string, format: uri, description: 'The code''s entry in the error catalogue, `https://mirroapi.com/docs/errors#<code>`: what it means and what to do. The catalogue is the gateway''s own table (packages/api/errors.mjs CATALOGUE), which this enum equals plus the four `Command.result` codes.' }
            details:
              type: object
              additionalProperties: true
              properties:
                rule: { type: string, description: '`RISK_RULE_UNSUPPORTED`: the setting that has no enforcer (maxDrawdownPercent, flattenOnBreach, refuseToSend).' }
                liveConfirmation: { type: string, description: '`LIVE_CONFIRMATION_REQUIRED`: the exact phrase `POST /configure/{token}` needs for a live account.' }
                arithmetic:
                  type: object
                  additionalProperties: true
                  description: 'The numbers that produced the refusal, e.g. `{ "requestedLots": "2.00", "maxLots": "1.00" }`'
                field: { type: [string, 'null'], description: The request field a validation refusal is about. }
                read: { type: string, enum: [positions, orders], description: 'Which book could not be read (`BOOK_UNAVAILABLE`).' }
                reason: { type: string, description: 'The terminal''s own word on `TERMINAL_READ_UNAVAILABLE`; a provisioning or role refusal''s reason as sent.' }
                ageSeconds: { type: [number, 'null'], description: '`QUOTE_STALE`: how old the quote was, or null when unknowable.' }
                timeBasis: { type: string, enum: [utc, broker], description: '`QUOTE_STALE`: whether the quote''s age could be measured.' }
                maxAgeSeconds: { type: integer, description: '`QUOTE_STALE`: the limit the request set.' }
                requiredVersion: { type: string, description: '`EA_UPGRADE_REQUIRED`: the EA version the read or patch needs.' }
                actualVersion: { type: [string, 'null'], description: '`EA_UPGRADE_REQUIRED`: the EA the terminal reported.' }
                scope: { type: string, description: '`RATE_LIMITED`: which bucket (read, trade, terminal-read, …).' }
                retryAfterSec: { type: integer, description: '`RATE_LIMITED`: when to try again.' }
                command: { type: string, description: 'The command whose outcome is not known (`503 EXECUTION_UNKNOWN`); read it at GET /accounts/{id}/trades/{command}.' }
                intendedRefusal: { type: string, description: 'The refusal the gateway meant to record when it answered `503 EXECUTION_UNKNOWN`.' }
                applied: { type: array, items: { type: string }, description: 'PATCH /accounts: what had already changed before the refusal.' }
                request: { $ref: '#/components/schemas/TradeRequest' }
    NativeExecutionResult:
      type: object
      description: 'Original terminal fields. Optional on older EAs; never inferred when absent. MT4 code is GetLastError after the native trade call. MT5 code is MqlTradeResult.retcode; runtimeCode is GetLastError, externalCode is retcode_external, message is the original broker comment capped at 512 characters. A zero external/runtime code is preserved without inventing a meaning.'
      properties:
        code: { type: integer, minimum: 0, maximum: 4294967295 }
        name: { type: string, description: Published native enum name when known }
        runtimeCode: { type: integer, minimum: 0, maximum: 4294967295 }
        externalCode: { type: integer, minimum: 0, maximum: 4294967295 }
        message: { type: string, maxLength: 512 }
    ExecutionDiagnostic:
      type: object
      required: [version, code, known, source, reason, raw, outcome, retry, nextAction]
      properties:
        version: { type: string }
        code: { type: string, description: Specific stable diagnostic code; result.code remains the existing broad API code }
        known: { type: boolean, description: False for unclassified vendor or future errors; raw values remain available }
        source: { type: string, enum: [mirro, mt4, mt5, terminal, unknown] }
        reason: { type: string }
        raw: { type: [string, 'null'] }
        outcome: { type: string, enum: [unknown, rejected] }
        native: { $ref: '#/components/schemas/NativeExecutionResult' }
        retry: { type: string, enum: [wait_for_resolution, reconcile_first] }
        nextAction: { type: string }
    Command:
      type: object
      description: 'One accepted trade request and its current outcome. Diagnostic fields are identical across REST, account streams and command.resolved webhooks. Native timeouts, connection failures and unclassified send outcomes can become EXECUTION_UNKNOWN immediately: they stop automatic dispatch rather than risk sending a second trade. A later definitive acknowledgement can correct that outcome.'
      properties:
        id: { type: string, example: cmd_lx3k9a2fq8b1 }
        accountId: { type: string }
        action: { type: string, enum: [market, pending, modify-position, modify-order, close, close-partial, cancel] }
        state:
          type: string
          enum: [queued, claimed, sent, filled, placed, modified, closed, cancelled, rejected, refused, expired]
          description: '`queued` → `claimed` (by the gateway for direct delivery, or by a worker) → a terminal state. `refused` is a refusal the gateway made itself before any terminal saw the command: a structural refusal (`NO_CONSENT`, `ACCOUNT_DISCONNECTED`, …) is written onto the ledger born `refused`, a risk or ticket refusal closes out the row it had; `result.code` is the code the 422 carried and nothing was sent. `rejected` is a final refusal — the broker''s (`result.broker`) or MIRRO''s own (`result.code: EXECUTION_REFUSED`, which the broker never saw). `expired` has two meanings, told apart by `result.code`: `EXECUTION_NOT_CLAIMED` (nothing claimed it in time — nothing was sent) and `EXECUTION_UNKNOWN` (a terminal took it and never answered — the outcome is not known, it is never called rejected, and it requires reconciliation, not a new trade). A transient broker outcome (requote, price changed, trade context busy, off-quotes) is not final: the command stays `claimed`/`sent` and is handed to the terminal again until `expiresAt`, then `rejected` with `result.code: EXECUTION_TRANSIENT_EXHAUSTED` and the last broker reason. A late answer corrects either ending and names what it corrected in `result.correctedFrom`. This holds for MetaTrader 4 and 5 alike.'
        request: { $ref: '#/components/schemas/TradeRequest', description: 'What was accepted, normalised. On MetaTrader 4 a re-addressed command carries the live `ticket` and `ticketResolvedFrom`.' }
        clientId: { type: [string, 'null'] }
        executionId: { type: [string, 'null'], description: The adapter execution ledger id once claimed }
        result:
          type: object
          additionalProperties: true
          description: |
            Empty while queued. While `claimed` after a transient answer:
            `{ lastBroker, transientAttempts, lastTransientAt }`.

            - Resolved ok: `{ ticket, filledLots, price, latencyMs }`, plus `note` (a sentence) and
              `broker` (the terminal's word: `already_closed`, `recovered_after_unknown`,
              `already_reduced` or `recovered_closed`) when the terminal resolved it that way.
              `ticket` after `close-partial` on MetaTrader 4 is the REMAINDER ticket (the broker
              re-tickets the rest).
            - Rejected by the terminal: `{ code: BROKER_REJECTED, message, broker, latencyMs }`,
              and `ticket` as well when the terminal named the live one (`ticket_superseded`).
              `remainder_unproven` and `remainder_ambiguous` arrive the same way; nothing traded.
            - Refused by MIRRO before any terminal saw it:
              `{ code: EXECUTION_REFUSED, reason, message, latencyMs }` — no `broker` key, because
              the broker was never asked.
            - Rejected after transient answers:
              `{ code: EXECUTION_TRANSIENT_EXHAUSTED, message, broker, lastBroker, transientAttempts, lastTransientAt }`.
            - Refused at the gateway (risk, re-addressing): `{ code, message, ... }` with the
              refusal details.
            - Expired: `{ code: EXECUTION_NOT_CLAIMED, message }` (nothing was sent) or
              `{ code: EXECUTION_UNKNOWN, message }` (handed out, never answered: outcome unknown).
            - Executed but nothing to name (MetaTrader 5 netting): `{ code: EXECUTION_NETTED, executed: true,
              message, broker: netted_against_existing }` — no `ticket`; never resend.

            A late answer replaces an `EXECUTION_TRANSIENT_EXHAUSTED` or `EXECUTION_UNKNOWN`
            result, keeps the retry trace and adds `correctedFrom` naming the code it corrected.

            Timing. `latencyMs` measures ledger creation to the acknowledgement arriving at the
            engine; it excludes client network time and the final result commit. Optional
            `terminalExecutionMs` measures the final attempt inside the EA including validation,
            dedup and broker calls; it excludes transport, earlier attempts and backoff. Neither
            is an SLA.
          properties:
            diagnostic: { $ref: '#/components/schemas/ExecutionDiagnostic' }
            native: { $ref: '#/components/schemas/NativeExecutionResult' }
            lastNative: { $ref: '#/components/schemas/NativeExecutionResult' }
            latencyMs: { type: number, minimum: 0, description: Ledger creation to acknowledgement arrival in milliseconds }
            terminalExecutionMs: { type: number, minimum: 0, maximum: 3600000, description: 'Final-attempt EA service time in milliseconds, supplied by MT4 1.9.5 / MT5 1.9.2 or newer. Absent on older terminals.' }
            brokerRequestMs: { type: number, minimum: 0, maximum: 3600000, description: 'MT5 1.9.4+: sum of synchronous native OrderSend durations in the final attempt, including terminal queueing, network and broker handling. A subset of terminalExecutionMs, not pure broker execution or network RTT. Zero for a dedup answer without a native request; absent on older EAs and MT4.' }
            brokerRequestCount: { type: integer, minimum: 0, maximum: 100, description: Number of native requests included in brokerRequestMs; retries within this attempt are counted }
            terminalOverheadMs: { type: number, minimum: 0, maximum: 3600000, description: 'Final-attempt EA processing outside native requests: terminalExecutionMs minus brokerRequestMs. Includes validation, dedup and result reconciliation.' }
            lastBrokerPingMs: { type: number, exclusiveMinimum: 0, maximum: 3600000, description: 'MT5 1.9.4+: last known terminal-to-trade-server ping in milliseconds, sampled independently of this trade. Its age is unavailable. Absent when unknown; never a measurement of current trade completion.' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        resolvedAt: { type: [string, 'null'], format: date-time }
        expiresAt: { type: string, format: date-time }
    CopyCreate:
      type: object
      required: [masterAccountId, followerAccountId, sizing]
      properties:
        masterAccountId: { type: string }
        followerAccountId: { type: string }
        sizing: { $ref: '#/components/schemas/Sizing' }
        symbolFilter: { type: array, items: { type: string }, description: Normalized instrument ids to copy; empty = all }
        copyStopLoss: { type: boolean, default: true }
        copyTakeProfit: { type: boolean, default: true }
        active: { type: boolean, default: true }
    CopyUpdate:
      type: object
      properties:
        sizing: { $ref: '#/components/schemas/Sizing' }
        symbolFilter: { type: array, items: { type: string } }
        copyStopLoss: { type: boolean }
        copyTakeProfit: { type: boolean }
        active: { type: boolean }
    Sizing:
      type: object
      required: [mode]
      properties:
        mode: { type: string, enum: [proportional, fixed-risk, fixed-lot] }
        ratio: { type: string, description: proportional multiplier }
        riskPercent: { type: string, description: fixed-risk % of follower equity per trade }
        lots: { type: string, description: fixed-lot }
        maxLots: { type: string }
    Copy:
      type: object
      properties:
        id: { type: string, example: cp_91ab }
        masterAccountId: { type: string }
        followerAccountId: { type: string }
        sizing: { $ref: '#/components/schemas/Sizing' }
        symbolFilter: { type: array, items: { type: string } }
        status:
          type: string
          enum: [mirroring, master-not-reporting, follower-not-reporting, safety-check-needed, execution-off, firm-refused]
          description: The exact precondition a link is missing, never a bare "active".
        active: { type: boolean }
        createdAt: { type: string, format: date-time }
        max: { $ref: '#/components/schemas/MaxRole' }
    CopyIntent:
      type: object
      properties:
        masterTicket: { type: string }
        symbol: { type: string }
        desired: { type: string, enum: [open, closed] }
        actual: { type: string, enum: [open, closed, pending, refused] }
        masterLots: { type: string }
        followerLots: { type: string }
        arithmetic: { type: object, additionalProperties: true }
    Stats:
      x-not-served: true
      type: object
      properties:
        verified: { type: boolean }
        since: { type: string, format: date-time }
        trades: { type: integer }
        winRate: { type: string }
        profitFactor: { type: string }
        netProfit: { $ref: '#/components/schemas/Money' }
        maxDrawdownPercent: { type: string }
        averageWin: { $ref: '#/components/schemas/Money' }
        averageLoss: { $ref: '#/components/schemas/Money' }
        expectancy: { $ref: '#/components/schemas/Money' }
        bestTrade: { $ref: '#/components/schemas/Money' }
        worstTrade: { $ref: '#/components/schemas/Money' }
        averageHoldMinutes: { type: number }
    RiskSettings:
      type: object
      description: |
        Only rules the gateway enforces before sending are accepted. `maxOpenLots` counts the account's
        current exposure: every position in its stored book (one row per position, as `GET …/positions`
        lists it, API-opened and manual alike), plus opening commands still in flight on the ledger
        (`market`/`pending` in `queued`, `claimed` or `sent`, at their requested size, so a burst of
        opens inside one heartbeat cannot pass the ceiling one order at a time), plus market fills of the
        last 60 s whose ticket the book has not shown yet; then the request. The refusal's
        `details.arithmetic` shows `openLots`, `requestedLots`, `maxOpenLots` and the breakdown
        `bookLots`, `inFlightLots`, `unseenFilledLots`.
        `maxDrawdownPercent` and `flattenOnBreach` have no enforcer yet (drawdown enforcement is a
        later release): a value for either on PUT is `422 RISK_RULE_UNSUPPORTED`, and both read back
        `null` whatever an earlier release stored. `refuseToSend: false` is refused the same way:
        sending and flattening afterwards is not offered. A setting name the gateway does not know
        is `400 BAD_REQUEST` with `details.unknown`, never dropped. `maxOpenLots` needs the account's
        current book: whenever the count is not that, an opening command is `422 RISK_OPEN_LOTS_UNKNOWN`
        (never passed, never counted as 0) with `details.arithmetic.reason` a stable word —
        `book_unreadable` (the read failed), `terminal_count_unknown` (no heartbeat has said how many
        positions the terminal holds), `book_behind_terminal` (the stored rows plus the unseen fills do
        not add up to the terminal's own count: a book of 64 or more positions is never written, a
        hand-opened or hand-closed position the next book has not reported yet; clears with the next
        book, about 5 s), `book_stale` (rows exist but the newest is over 60 s old while the terminal is
        connected), `lots_missing` (a counted row has no size) — with `bookPositions`,
        `terminalPositions` and `bookAgeSec`. Exits are never refused by any of this.
      properties:
        maxLotsPerOrder: { type: [string, 'null'], description: A positive decimal string. }
        maxOpenLots: { type: [string, 'null'], description: 'A positive decimal string; every open position counts, in-flight opens and unseen fills too (see above).' }
        maxOpenPositions: { type: [integer, 'null'], minimum: 1, maximum: 1000 }
        maxDailyLossPercent: { type: [string, 'null'], description: 'Below 100. Until the account has reported its equity today an opening command is `RISK_DAILY_LOSS_UNKNOWN`, not silently passed.' }
        maxDrawdownPercent: { type: [string, 'null'], readOnly: true, description: 'Always null: a value on PUT is `422 RISK_RULE_UNSUPPORTED`.' }
        tradingHours: { type: [string, 'null'], example: 'Mon-Fri 07:00-21:00 Europe/London' }
        refuseToSend: { type: boolean, default: true, description: 'Always true: a command that would breach is refused with the numbers, never sent and flattened after. `false` on PUT is `422 RISK_RULE_UNSUPPORTED`.' }
        flattenOnBreach: { type: [boolean, 'null'], readOnly: true, description: 'Always null: no enforcer exists; `true` on PUT is `422 RISK_RULE_UNSUPPORTED`.' }
        updatedAt: { type: [string, 'null'], format: date-time }
    AuditType:
      type: string
      enum: [order.sent, order.filled, order.rejected, order.refused, position.closed, copy.decision, copy.refused, copy.created, copy.removed, account.created, account.removed, account.connected, account.disconnected, account.firm-unreachable, consent.recorded, risk.updated, key.created, key.revoked, webhook.created, webhook.delivered, webhook.failed, risk.breach]
    AuditEvent:
      type: object
      properties:
        id: { type: string }
        type: { $ref: '#/components/schemas/AuditType' }
        at: { type: string, format: date-time }
        accountId: { type: [string, 'null'] }
        copyId: { type: [string, 'null'] }
        keyId: { type: [string, 'null'] }
        requestId: { type: [string, 'null'] }
        summary: { type: string }
        evidence:
          type: object
          additionalProperties: true
          description: The snapshot and arithmetic behind the event. Sufficient to settle a dispute without us.
    WebhookEvent:
      type: string
      description: |
        `command.resolved` is sent for every ending, the honest ones included: `EXECUTION_NOT_CLAIMED`,
        `EXECUTION_TRANSIENT_EXHAUSTED` and `EXECUTION_UNKNOWN`. A command can resolve TWICE: when a late
        answer corrects an `EXECUTION_TRANSIENT_EXHAUSTED` or `EXECUTION_UNKNOWN` row, a second
        `command.resolved` goes out for the same command id, carrying the corrected result and
        `result.correctedFrom`. Deliveries are at-least-once anyway, so key on the command id and take the
        newest `updatedAt`. `order.refused` goes out for every command that ends `refused` on the ledger,
        which is every refusal the gateway makes itself: the structural gate (`EXECUTION_ROLE_REQUIRED`,
        `NO_CONSENT`, `ACCOUNT_DISCONNECTED`, `BROKER_DOWN`, `FIRM_UNREACHABLE`, the funding gate in live
        payment mode) — such a command is written onto the ledger born `refused`, so `GET …/trades` lists
        it too — the risk arithmetic (`RISK_*`), MetaTrader 4 ticket re-addressing (`TICKET_SUPERSEDED`,
        `TICKET_READDRESS_FAILED`), a command the engine refused before any terminal saw it
        (`EXECUTION_REFUSED`) and an expiry nothing ever claimed (`EXECUTION_NOT_CLAIMED`): exactly the rows
        `GET …/risk/breaches` lists (the gateway's own refusals at once, the engine's when the event poller
        sees the resolved command, within seconds). Not events: a request refused before the account was consulted
        (`INVALID_REQUEST`, `EA_UPGRADE_REQUIRED`: not an order, no ledger row), `403 TEST_KEY_LIVE_ACCOUNT`,
        and an expiry whose outcome is `EXECUTION_UNKNOWN` (it may have executed; `command.resolved` carries
        it). Its `data` is `{commandId, accountId, action, clientId, code, message, details}` with the
        422's details (the arithmetic, the rule, the ticket); the event id is `evt_<commandId>.refused`,
        written once. `risk.breach` goes out beside `order.refused` only for a rule that was BREACHED —
        `RISK_MAX_LOTS`, `RISK_MAX_OPEN_LOTS`, `RISK_MAX_POSITIONS`, `RISK_DAILY_LOSS`, `RISK_TRADING_HOURS` —
        with `data.rule` (the code) and `data.arithmetic` once; id `evt_<commandId>.risk`.
        `RISK_DAILY_LOSS_UNKNOWN` and `RISK_OPEN_LOTS_UNKNOWN` mean the rule could not be measured, not that
        it was broken, and are `order.refused` only. Neither names an event the gateway does not produce.
      enum: [position.opened, position.modified, position.closed, order.filled, order.cancelled, order.refused, copy.decision, copy.refused, account.connected, account.disconnected, risk.breach, command.resolved, challenge.phase_passed, challenge.funded, challenge.failed, challenge.warning]
    Webhook:
      type: object
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events: { type: array, items: { $ref: '#/components/schemas/WebhookEvent' } }
        accountIds: { type: array, items: { type: string } }
        createdAt: { type: string, format: date-time }
        lastDeliveryAt: { type: [string, 'null'], format: date-time }
        failing: { type: boolean }
