> ## Documentation Index
> Fetch the complete documentation index at: https://docs.seesaw.fun/llms.txt
> Use this file to discover all available pages before exploring further.

# Preview an order at the user's stake (step 1 of 2)

> Computes the summary the user must confirm and, when the order is
currently possible, mints a `confirm_token` for `POST /orders`.
**Charges nothing.** Requires the `write` scope; counts against the
read window only.

**The stake is the user's choice.** If the user stated how many coins
to stake, send exactly that as `amount_coins`; if they did not, ask
them before calling — never choose, infer or round an amount on their
behalf. The allowed range is the topic's `stake_rules`
(`min_coins`..`max_coins`, whole coins, from `GET /topics/{id}`).

When `can_place` is `false` the `reason` says why and no token is
issued:
- `STAKE_OUT_OF_RANGE` — `amount_coins` is outside the range.
  `TOPIC_CLOSED` and `ALREADY_STAKED` take precedence over it (the
  reason is then one of those and `details` is absent, while the
  summary keeps the shape described here); it takes precedence over
  `INSUFFICIENT_BALANCE`. `details` carries `min_coins` /
  `max_coins`, `summary.stake_coins` echoes the requested amount (it
  is **not** clamped), `balance_after` equals `balance_before`,
  `price_now` is the current display price, and `price_after`,
  `payout_if_correct_rubies`, `ruby_multiplier` and `avg_price` are
  empty strings.
- `MECHANISM_UNSUPPORTED` (a `v2_share` topic) — the summary is
  partial: only `topic_title`, `option_label`, `stake_coins`, the
  balances, `price_now`, `close_time` and `mechanism` are meaningful.
- `ALREADY_STAKED` — the owner already placed this topic's one order,
  including an order they have since sold in the app.
- `INSUFFICIENT_BALANCE` — the balance is below `stake_coins`.

The stake is bound into the token: a different amount needs a new
preview, and `POST /orders` spends exactly the previewed
`stake_coins`.




## OpenAPI

````yaml /api-reference/openapi.yaml post /topics/{id}/order-preview
openapi: 3.1.0
info:
  title: SeeSaw Open API
  version: 2.1.0
  description: |
    The **open API** of SeeSaw — a dedicated surface for AI agents and
    third-party integrations, exposed both as REST (`/open/v1/*`) and as an
    **MCP server** (`/mcp`, Streamable HTTP, stateless) in the same process.

    Every key can **read**; keys created with the `write` scope can also act
    on the owner's behalf: place a position, publish an opinion, back an
    opinion. Writes always run in two phases — a `preview_*` call returns the
    summary the user is meant to confirm plus a short-lived `confirm_token`,
    and the execute call accepts only that token.

    This contract is independent from the app-facing API:
    it does not inherit app DTO shapes and follows its own conventions.

    ## Conventions
    - Field names are `snake_case`; enums are lowercase strings.
    - Decimals (coins, gems, prices) are **strings with 4 decimal places**
      (`"100.0000"`) — never floats. Rounding for display is the client's call.
      One deliberate exception: `avg_price` (coins per ruby, a ratio rather
      than a price) carries **6 decimal places**, and says so at the field.
    - Timestamps are RFC 3339 UTC (`2026-09-09T12:00:00Z`).
    - Every resource carries a human-visitable `url`.
    - Lists use **opaque cursor pagination**: `{data, next_cursor, has_more}`.
      `limit` defaults to 20, max 100. Reusing a cursor with different query
      params does not guarantee continuity.
    - Single resources are returned bare (no wrapper); errors use
      `{"error": {"code", "message", "details"}}`.
    - Vocabulary follows the 2.0 glossary: **opinion** (not comment), **back**,
      **position**, **stake**, **payout**.

    ## Authentication and scopes
    Every request (public data included) requires a **personal API key** (PAT):
    `Authorization: Bearer sspat_…`. Keys are issued per user (max 5 active)
    and can be revoked anytime. Key management lives on the app API
    (`/v1/users/me/api-keys`, JWT auth), NOT on this surface.

    Two scope tiers, chosen when the key is created and **fixed for the life of
    the key** (there is no upgrade path — create a new key instead):

    | Scopes | Grants |
    | --- | --- |
    | `["read"]` | The whole read surface. The default. |
    | `["read","write"]` | The read surface **plus** the six write operations. |

    A read-only key hitting a write endpoint gets `403 FORBIDDEN_SCOPE`; over
    MCP it never sees the write tools at all.

    ## The write flow
    1. Call `POST …/order-preview`, `…/opinion-preview` or `…/back-preview`.
       Nothing is charged; the response carries a `summary` plus, when the
       action is currently possible, a `confirm_token` and its `expires_at`.
    2. **Show the summary to the user and get explicit confirmation.**
    3. Call `POST /orders`, `/opinions` or `/backs` with only that token.

    Tokens are valid **5 minutes**, are bound to the issuing user, key, action
    and parameters, and are single-use. `place_order` and `back_opinion` are
    idempotent on retry with the same token (`replayed: true`);
    `publish_opinion` is strictly single-use and answers a retry with
    `409 CONFIRM_TOKEN_USED`.

    ## Rate limits (per key)
    - **Read window**: 120 requests/min and 10,000 requests/day across the
      surface. The three `preview_*` operations count only against this window.
    - **Search**: additionally capped at 30 requests/min — a dedicated per-key
      budget shared by REST `/search` and the MCP `search_topics` tool.
    - **Write window**: the three executing operations (`/orders`, `/opinions`,
      `/backs`) are additionally capped at **10/min and 200/day**; they also
      consume the read window.
    - `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`
      response headers reflect the binding minute window; 429 responses
      carry `Retry-After`. Over MCP, exceeding a quota surfaces as a tool
      error naming the retry delay.

    ## MCP server
    `POST https://api.seesaw.fun/mcp` (Streamable HTTP, stateless, same
    `Authorization` header). The server reports the contract version as its
    `serverInfo.version` (`2.1.0`). A read key sees **12 tools**; a
    read+write key sees **18**:

    - Read: `search_topics`, `list_topics`, `list_topic_categories`,
      `get_topic`, `list_topic_opinions`, `get_my_profile`,
      `list_my_positions`, `list_my_opinions`, `list_my_backed_opinions`,
      `list_my_watchlist`, `list_my_coin_ledger`, `list_my_ruby_ledger`.
    - Write: `preview_order` / `place_order`, `preview_opinion` /
      `publish_opinion`, `preview_back` / `back_opinion`.

    Read and preview tools are annotated `readOnlyHint: true`; the three
    executing tools carry `destructiveHint: true` and
    `_meta["anthropic/requiresUserInteraction"] = true`, which compatible
    clients turn into a mandatory confirmation prompt. Tool results carry the
    corresponding REST response JSON.

    Tools that return titles, options or opinions take an optional `language`
    argument; REST reads the `Accept-Language` header instead (unknown values
    fall back to `en`). Supported: `en` (default), `zh-TW`, `zh-CN`, `vi`,
    `id`, `th`, `ja`, `ko`, `ms`. Over MCP, aliases such as `zh` or `en-US`
    fold onto the closest supported code and any other value is rejected —
    which is why the tool schemas carry no JSON-schema `enum` for it.

    ## Trading mechanisms
    2.0 topics run one of two mechanisms. `v2_share` topics are legacy
    share-pool markets — readable, but not tradable through this surface
    (`preview_order` answers `MECHANISM_UNSUPPORTED`). `v21_lmsr` is the live
    one; list and search endpoints default to it.

    - **Stake rules are fixed per topic.** Each `v21_lmsr` topic freezes its
      own stake range, market depth and rules version when it is created;
      they never change afterwards, and a later platform change only affects
      new topics. The range is published as the topic's `stake_rules`
      (`min_coins`..`max_coins`, whole coins; older topics are 100..100).
    - **The user chooses the stake.** `preview_order` takes `amount_coins`
      within that range. If the user stated an amount, pass exactly that;
      if they did not, ask them — an agent must never choose or infer an
      amount. An amount outside the range is refused with
      `STAKE_OUT_OF_RANGE`, never adjusted. Omitting `amount_coins` stakes
      `stake_rules.default_coins`, a fallback rather than a suggestion.
    - **One order per topic, no fees.** A user places at most one order on a
      topic; the whole stake buys units.
    - **Opening prices.** A topic can open at initial probabilities set when
      it is published, rather than at an even split across its options;
      read the current `implied_price` instead of assuming 1/n.
    - **Payout.** A correct option pays `units × ruby multiplier` **rubies**;
      the stake itself is not returned. The multiplier is 1 unless a ruby
      boost (`ruby_boost` on the topic) is live at one of three moments: when
      the order is placed, when the topic settles, or (in the app) when the
      position is sold — whichever gives the highest multiplier applies. The
      order records the multiplier live when it is placed; a boost live at
      settlement raises it. So every `payout_if_correct_rubies` figure is a
      **guaranteed minimum** (units × the order's current multiplier), not
      the final amount.
    - **Selling is app-only.** Users can sell a whole position for rubies in
      the SeeSaw app; this surface offers no sell operation. A sold position
      shows `sold: true` with `sold_rubies`, and is never settled or refunded
      afterwards — `won`, `payout_rubies` and `payout_if_correct_rubies`
      stay null. Selling still counts as the topic's one order.

    ## Rubies and the retired sapphire
    Sapphires were merged 1:1 into rubies; opinion paybacks now pay rubies.
    `list_my_ruby_ledger` shows every ruby movement — settlement payouts,
    positions sold in the app, opinion paybacks and the one-off conversion
    of the old sapphire balance. The sapphire-named fields remain on the
    wire with the same values as their `ruby_*` replacements and are marked
    `deprecated`; they will be removed in contract **3.0.0**:

    | Deprecated | Use instead |
    | --- | --- |
    | `MyProfile.sapphires` (always `"0.0000"`) | `rubies` |
    | `sapphire_payback` | `ruby_payback` |
    | `sapphire_total` | `ruby_total` |
    | `max_relay_payback_sapphires` | `max_relay_payback_rubies` |
servers:
  - url: https://api.seesaw.fun/open/v1
    description: Production
security:
  - apiKey: []
tags:
  - name: Topics
    description: 2.0 prediction topics — browse, search, detail, opinions
  - name: Me
    description: Data of the authenticated key owner
  - name: Trading
    description: Place a position (preview then execute, `write` scope)
  - name: Opinions
    description: Publish and back opinions (preview then execute, `write` scope)
paths:
  /topics/{id}/order-preview:
    post:
      tags:
        - Trading
      summary: Preview an order at the user's stake (step 1 of 2)
      description: |
        Computes the summary the user must confirm and, when the order is
        currently possible, mints a `confirm_token` for `POST /orders`.
        **Charges nothing.** Requires the `write` scope; counts against the
        read window only.

        **The stake is the user's choice.** If the user stated how many coins
        to stake, send exactly that as `amount_coins`; if they did not, ask
        them before calling — never choose, infer or round an amount on their
        behalf. The allowed range is the topic's `stake_rules`
        (`min_coins`..`max_coins`, whole coins, from `GET /topics/{id}`).

        When `can_place` is `false` the `reason` says why and no token is
        issued:
        - `STAKE_OUT_OF_RANGE` — `amount_coins` is outside the range.
          `TOPIC_CLOSED` and `ALREADY_STAKED` take precedence over it (the
          reason is then one of those and `details` is absent, while the
          summary keeps the shape described here); it takes precedence over
          `INSUFFICIENT_BALANCE`. `details` carries `min_coins` /
          `max_coins`, `summary.stake_coins` echoes the requested amount (it
          is **not** clamped), `balance_after` equals `balance_before`,
          `price_now` is the current display price, and `price_after`,
          `payout_if_correct_rubies`, `ruby_multiplier` and `avg_price` are
          empty strings.
        - `MECHANISM_UNSUPPORTED` (a `v2_share` topic) — the summary is
          partial: only `topic_title`, `option_label`, `stake_coins`, the
          balances, `price_now`, `close_time` and `mechanism` are meaningful.
        - `ALREADY_STAKED` — the owner already placed this topic's one order,
          including an order they have since sold in the app.
        - `INSUFFICIENT_BALANCE` — the balance is below `stake_coins`.

        The stake is bound into the token: a different amount needs a new
        preview, and `POST /orders` spends exactly the previewed
        `stake_coins`.
      operationId: previewOrder
      parameters:
        - $ref: '#/components/parameters/TopicID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - option_id
              properties:
                option_id:
                  type: string
                  format: uuid
                  description: The option to stake on, from the topic's `options`.
                amount_coins:
                  type: integer
                  minimum: 1
                  description: |
                    Coins to stake — a whole number within the topic's
                    `stake_rules` (`min_coins`..`max_coins`). Pass the amount
                    the user stated; if they did not state one, ask them
                    first. Outside the range: `200` with `can_place: false`
                    and reason `STAKE_OUT_OF_RANGE` (never adjusted). Must be
                    a JSON integer: a quoted string such as `"100"`, a
                    fraction, zero or a negative number is
                    `400 INVALID_ARGUMENT` with
                    `details.field = amount_coins`. Omitted: the order stakes
                    `stake_rules.default_coins` — a fallback, not a
                    recommendation.
                  example: 300
      responses:
        '200':
          description: The preview (check `can_place` before confirming)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderPreview'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  parameters:
    TopicID:
      name: id
      in: path
      required: true
      description: Topic UUID.
      schema:
        type: string
        format: uuid
  schemas:
    OrderPreview:
      type: object
      required:
        - can_place
        - summary
      properties:
        can_place:
          type: boolean
        reason:
          type: string
          enum:
            - TOPIC_CLOSED
            - ALREADY_STAKED
            - INSUFFICIENT_BALANCE
            - MECHANISM_UNSUPPORTED
            - STAKE_OUT_OF_RANGE
          description: Present only when `can_place` is false.
        details:
          type: object
          description: Present only with `STAKE_OUT_OF_RANGE` — the topic's stake range.
          required:
            - min_coins
            - max_coins
          properties:
            min_coins:
              type: string
              example: '500.0000'
            max_coins:
              type: string
              example: '1000.0000'
        summary:
          $ref: '#/components/schemas/OrderSummary'
        confirm_token:
          type: string
          description: Present only when `can_place` is true.
        expires_at:
          type: string
          format: date-time
          description: Present only when `can_place` is true. 5 minutes out.
    OrderSummary:
      type: object
      description: >
        What the user confirms before `POST /orders`. `stake_coins` is what the

        order spends; `payout_if_correct_rubies` is the rubies received if the

        option wins — `units × ruby_multiplier`, so it differs from the unit

        count while a ruby boost runs. It is a guaranteed minimum: if the topic
        settles while a ruby boost is live, it pays at the higher multiplier.
        The stake is not

        returned.
      required:
        - topic_title
        - option_label
        - stake_coins
        - balance_before
        - balance_after
        - price_now
        - price_after
        - payout_if_correct_rubies
        - ruby_multiplier
        - avg_price
        - close_time
        - mechanism
      properties:
        topic_title:
          type: string
        option_label:
          type: string
        stake_coins:
          type: string
          description: |
            The coins this order spends: the requested `amount_coins`, or
            `stake_rules.default_coins` when it was omitted. With
            `STAKE_OUT_OF_RANGE` it echoes the requested amount, unclamped.
        balance_before:
          type: string
        balance_after:
          type: string
          description: Equals `balance_before` when the order cannot be placed.
        price_now:
          type: string
        price_after:
          type: string
          description: Marginal price once this order joins the book.
        payout_if_correct_rubies:
          type: string
          description: >
            Rubies received if the option wins, already multiplied by

            `ruby_multiplier` — a guaranteed minimum: if the topic settles while
            a ruby boost is live, it pays at the higher multiplier. Empty with

            `STAKE_OUT_OF_RANGE`.
        ruby_multiplier:
          type: string
          description: >
            The ruby boost multiplier live on the topic now — `"1.0000"`

            without a boost. The order records the multiplier live when it

            fills; a boost live at settlement can raise it. Empty with
            `STAKE_OUT_OF_RANGE` and `MECHANISM_UNSUPPORTED`.
          example: '1.0000'
        avg_price:
          type: string
          description: |
            Coins paid per ruby for this fill (`stake / units`) — always
            higher than `price_now`, since buying moves the price up as the
            order fills (the average lands between `price_now` and
            `price_after`).
            **6 decimal places**, not the 4 used by every other decimal here:
            it is a ratio, and two extra digits keep it from rounding away.
        close_time:
          type: string
          format: date-time
        mechanism:
          type: string
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              enum:
                - UNAUTHORIZED
                - FORBIDDEN
                - FORBIDDEN_SCOPE
                - NOT_FOUND
                - OPINION_NOT_FOUND
                - INVALID_ARGUMENT
                - RATE_LIMITED
                - SERVICE_DISABLED
                - INTERNAL
                - CONFIRM_TOKEN_INVALID
                - CONFIRM_TOKEN_EXPIRED
                - CONFIRM_TOKEN_USED
                - CONFIRM_UNAVAILABLE
                - MECHANISM_UNSUPPORTED
                - PREVIEW_UNAVAILABLE
                - TOPIC_ALREADY_STAKED
                - TOPIC_CLOSED
                - TOPIC_QUOTE_EXPIRED
                - TOPIC_QUOTE_INVALID
                - TOPIC_MECHANISM_MISMATCH
                - STAKE_OUT_OF_RANGE
                - RETRY_IDEMPOTENCY_KEY
                - OPINION_ALREADY_BACKED
                - OPINION_BACK_PRICE_MOVED
                - OPINION_BACK_RETRY
                - OPINION_BACK_FULL
                - OPINION_BACK_CHAIN_BROKEN
                - OPINION_SIDE_REQUIRED
                - OPINION_SIDE_INVALID
                - OPINION_TOPIC_CLOSED
                - OPINION_TOPIC_HIDDEN
                - OPINIONS_UNAVAILABLE
                - INSUFFICIENT_BALANCE
                - CONTENT_INVALID
                - CONTENT_MODERATED
                - USER_PUNISHED
              description: |
                Stable contract values. `SERVICE_DISABLED` (503) is the
                emergency kill switch; `FORBIDDEN_SCOPE` (403) means the key
                lacks the `write` scope. The `CONFIRM_TOKEN_*` and rule codes
                only occur on the write surface. `OPINION_SIDE_REQUIRED` /
                `OPINION_SIDE_INVALID` are rare but reachable: `preview_opinion`
                already answers a missing or invalid side with
                `can_publish: false` (reason `SIDE_REQUIRED`), so they surface
                as a 400 from `publish_opinion` only when the owner's position
                changed between preview and publish (e.g. no position at
                preview, then an order on the other side before publishing) and
                the side in the token no longer fits.
            message:
              type: string
            details:
              type: object
              description: >-
                Optional context, e.g. `{"field": "limit"}`, `{"retry_after":
                12}` or `{"required_scope": "write"}`.
              additionalProperties: true
  responses:
    BadRequest:
      description: Invalid argument (`details.field` names the offending parameter)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: >-
        Missing, invalid, revoked, or expired API key (carries
        `WWW-Authenticate`)
      headers:
        WWW-Authenticate:
          schema:
            type: string
          description: Bearer realm="seesaw-open-api"
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ForbiddenScope:
      description: |
        The key lacks the scope this operation needs — `FORBIDDEN_SCOPE`, with
        `details.required_scope`. Create a key with the `write` scope.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: >-
        Resource missing, or hidden by privacy/moderation (existence does not
        leak)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: >-
        Rate limit exceeded (see `Retry-After` and `details.retry_after`
        seconds)
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalError:
      description: >-
        Internal error (`INTERNAL`, or `CONFIRM_UNAVAILABLE` when token signing
        is unavailable)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: sspat_ personal API key
      description: |
        Personal API key issued per SeeSaw user account:
        `Authorization: Bearer sspat_…`. Manage keys on the app API
        (`/v1/users/me/api-keys`). Revocation propagates within ~60 seconds
        (validation cache window).

        The key's **scopes** decide what it may do: every key has `read`; a key
        created with `["read","write"]` may also use the six write operations
        (marked `x-required-scope: write` in this document). Scopes are fixed at
        creation — to get write access, create a new key. A read-only key
        calling a write operation gets `403 FORBIDDEN_SCOPE`.

````