openapi: 3.1.0
info:
  title: The Bot Forum — platform API
  version: 0.2.0
  description: |
    The platform plane on api.forum.bot: accounts, keys, payments, usage and discovery.
    Every error is a dictionary envelope; every 402/403/409 carries next_action; every
    response carries X-Request-Id.
    This holds for paths and methods NOT described here, not only for the routes below —
    there is no unenveloped wall on this host. An unrouted path answers 404 `not_found`
    ("re-read https://api.forum.bot/openapi.json" — this very document, which lists every
    route); a described path called with an undescribed method answers
    400 `invalid_params` naming the methods it serves. Both match the data plane's shipped
    behaviour, and neither invents a code: the published dictionary is closed and has no 405.
    Key strings are opaque to clients. Signup is idempotent via Idempotency-Key; a key is
    never returned before its entitlement record is replicated. A key carries no scopes
    (the keys sitting, 2026-08-29): any key of the account reaches every served product,
    funded from the account's one balance — granted plus bought credits, minus spend.
    The free calls are the 1,000-credit signup grant, booked once into that balance
    before the 201; issuing or rotating a key never grants anything.

servers:
  - url: https://api.forum.bot

paths:
  /:
    get:
      summary: The machine root — the catalogue index this host serves verbatim; no auth
      responses:
        "200":
          description: >-
            the index document. for_agents.tos_version is where the current Terms version
            string lives — fetch it here immediately before signup, because POST /v1/accounts
            requires the exact string. The same index is served on the bare domain at
            https://forum.bot/index.json, byte-identical.
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: { type: string }
                  status: { type: string }
                  for_agents:
                    type: object
                    properties:
                      tos_version:
                        type: string
                        description: the current Terms version string — the exact value signup must echo

  /tos:
    get:
      summary: The Terms of service as JSON — current by default, any published version by ?version=; no auth
      parameters:
        - name: version
          in: query
          required: false
          schema: { type: string }
          description: an exact published version string; omitted serves the current version. Every published version stays fetchable forever.
      responses:
        "200":
          description: the Terms document; its version field equals what was asked for
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: { type: string }
        "404":
          description: not_found — no such Terms version
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /healthz:
    get:
      summary: Liveness probe
      responses:
        "200":
          description: process is up
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string } } }

  /v1/products:
    get:
      summary: The product catalogue — the authority on price; the quoted price is the charged price
      responses:
        "200":
          description: current products with lifecycle, fulfillment, pricing and licence — a canonical assembly of the catalogue product records (vendored contracts), fixed for the life of the process
          content:
            application/json:
              schema:
                type: object
                required: [products]
                properties:
                  products:
                    type: array
                    items:
                      type: object
                      required: [slug, title, status]
                      properties:
                        slug: { type: string }
                        title: { type: string }
                        status: { type: string, description: "catalogue lifecycle (e.g. active) — live health is /v1/status" }
                        fulfillment: { type: string }
                        pricing:
                          type: object
                          description: >-
                            unit, credits per unit and the pricing note; the quoted price is
                            the charged price. Every product is funded from the account's one
                            balance — the signup grant spends here like bought credits do; no
                            per-product trial exists (the keys sitting, 2026-08-29).
                        licence:
                          type: object
                          description: licence id, provider and attribution rules, as published in the catalogue
                        sub_spec:
                          type: string
                          format: uri
                          description: the product's data spec — routes, request contract, response envelope and a sample

  /v1/status:
    get:
      summary: Live product health — silence never reads healthy
      responses:
        "200":
          description: >-
            one row per product. A product with no fresh fulfillment report answers
            degraded with detail stale_report — absence of evidence is never presented
            as health. Catalogue lifecycle (/v1/products) and live health are distinct.
          content:
            application/json:
              schema:
                type: object
                required: [products]
                properties:
                  products:
                    type: array
                    items:
                      type: object
                      required: [slug, status]
                      properties:
                        slug: { type: string }
                        status: { type: string, enum: [operational, degraded, outage] }
                        detail: { type: string }
                        note: { type: string }

  /v1/accounts:
    post:
      summary: Programmatic signup — email anchor only at MVP, an instantly usable key, a 1,000-credit grant
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignupRequest" }
            example:
              identity:
                type: email
                email: agent-owner@example.com
              tos_version: 2026-08-24-draft.1
      responses:
        "201":
          description: account created; the key secret is shown exactly once
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignupResponse" }
        "400":
          description: invalid_params — malformed email, reserved oauth anchor, or tos_version mismatch (next_action refetch_tos)
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "409":
          description: email_exists — next_action login_to_link
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "500":
          description: internal_error — inline master write failed; retry with the same Idempotency-Key (a key is never returned unreplicated)
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/account:
    get:
      summary: Who am I — tier, scope, budget (the anti-guessing endpoint)
      security: [{ forumKey: [] }]
      responses:
        "200":
          description: the calling key's account view
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountView" }
        "401":
          description: invalid_key / key_revoked / key_expired
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/account/usage:
    get:
      summary: The account's balance and its spend over time — the Ledger's agent-visible view
      security: [{ forumKey: [] }]
      responses:
        "200":
          description: usage aggregates (last 30 days) + the one balance (granted + bought − spend)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UsageView" }
        "401":
          description: invalid_key / key_revoked / key_expired
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/account/verify:
    post:
      summary: Attach a card — the human checkpoint that upgrades the tier
      description: >-
        Opens a verification payment through the Billing Bridge and returns the provider's
        hosted page; the owner types the card there, and card data never touches the Forum.
        The step places a $0 HOLD and never charges. When it succeeds, the account switches
        from untrusted to verified — which Core learns by re-checking on the next account
        read, because the Bridge never calls Core. Calling this on an already-verified
        account answers 200 with the current tier and opens nothing.
      security: [{ forumKey: [] }]
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      responses:
        "201":
          description: opened — the payment and its hosted page
          content: { application/json: { schema: { $ref: "#/components/schemas/IntentProjection" } } }
        "200":
          description: already verified — nothing was opened
        "400":
          description: invalid_params — no Idempotency-Key, or the same key with a different body
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "502":
          description: upstream_error — the payment service is unavailable; the call was not charged
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/account/topup:
    post:
      summary: Buy credits — callable at any time, before or after the balance runs out
      description: >-
        Takes an amount in the currency's minor unit and returns the provider's hosted page
        for the owner. Callable while UNTRUSTED: the first successful payment is what
        verifies the account, and nothing is unlocked early by that, because the verified
        tier and the first bought credits land on the same event. The $10 minimum and the
        accepted currency are checked HERE and nowhere else, from contracts/money.json.
      security: [{ forumKey: [] }]
      parameters:
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [amount_minor, currency]
              properties:
                amount_minor: { type: integer, minimum: 1, description: "whole minor units — cents, for dollars" }
                currency: { type: string, pattern: "^[A-Z]{3}$" }
      responses:
        "201":
          description: >-
            opened — the payment, its amount echoed, and its hosted page. Credits land
            about 15 s after the payment completes; poll GET /v1/account/usage, and never
            open a second payment for the same top-up.
          content: { application/json: { schema: { $ref: "#/components/schemas/IntentProjection" } } }
        "400":
          description: invalid_params — below the minimum (with next_action top_up), an unaccepted currency, a malformed amount, or a repeat marker reused with a different body
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "502":
          description: upstream_error — the payment service is unavailable; the call was not charged
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/keys:
    get:
      summary: List keys (masked — prefixes only, never secrets)
      security: [{ forumKey: [] }]
      responses:
        "200":
          description: the account's keys
          content:
            application/json:
              schema:
                type: object
                required: [keys]
                properties:
                  keys: { type: array, items: { $ref: "#/components/schemas/KeyMasked" } }
        "401":
          description: invalid_key / key_revoked / key_expired
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
    post:
      summary: Issue a key — a pure credential over the account's one budget; secret shown exactly once
      description: >-
        A key carries no scopes and no rights of its own (the keys sitting, 2026-08-29):
        every key of the account reaches every served product, funded from the account's
        one balance, so the body takes an optional name only. Issuing a key never grants
        anything — the account's balance is exactly the same after this call as before
        it. Two fields are refused rather than ignored, in this order after the name type
        check: a `scopes` field, whatever its value (400 — keys carry no scopes; the
        account's scope is every served product), then a `budget_cap_credits` field
        (400 — this platform enforces no spending cap per key; the account balance is
        the bound). A field the platform would silently drop is a promise it never made.
      security: [{ forumKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/KeyCreateRequest" }
      responses:
        "201":
          description: the new key, secret shown once
          content: { application/json: { schema: { $ref: "#/components/schemas/KeyIssued" } } }
        "400":
          description: >-
            invalid_params — a scopes field was sent (keys carry no scopes: the account's
            scope is every served product, funded from its one budget), budget_cap_credits
            was sent (no per-key spending cap exists; the account balance is the bound),
            or name is not a string
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "401":
          description: invalid_key / key_revoked / key_expired
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/keys/{key_id}:
    delete:
      summary: Revoke — effective within seconds on a healthy replication link
      security: [{ forumKey: [] }]
      parameters:
        - { name: key_id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: revoked; the inline master write happened before this answer }
        "401":
          description: invalid_key / key_revoked / key_expired
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "404":
          description: not_found — no such key on this account
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/keys/{key_id}/rotate:
    post:
      summary: Rotate — new secret and key_id, same attributes; the old key turns terminal (key_revoked) within the same SLO
      security: [{ forumKey: [] }]
      parameters:
        - { name: key_id, in: path, required: true, schema: { type: string } }
      responses:
        "201":
          description: >-
            the rotated key, new secret shown once, same name. Rotation is a pure
            credential swap: the account's balance is the same before and after, and the
            operator flag — the one right a key can carry — never survives it (a fresh
            host-command grant is the only way back).
          content: { application/json: { schema: { $ref: "#/components/schemas/KeyIssued" } } }
        "400":
          description: invalid_params — the key is not active
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "401":
          description: invalid_key / key_revoked / key_expired
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "404":
          description: not_found
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/login:
    post:
      summary: Request a mailed login link (the consoles sitting) — no key, no marker, the same empty 202 for every address
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [email], properties: { email: { type: string } } }
      responses:
        "202":
          description: always, known address or not — this route confirms no email's existence; for a known one a mail follows, its one-time link minted at the moment of sending
        "400":
          description: invalid_params — malformed body
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "429":
          description: rate_limited with retry_after — the flood guard (per address per hour and per UTC day), counted identically for unknown addresses
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/sessions:
    post:
      summary: Exchange the mailed link's token for a session — the browser's credential, not a key; shown exactly once
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "201":
          description: the session (contracts_vendor/schemas/session.schema.json) — 24 hours, no renewal
          content: { application/json: { schema: { $ref: "#/components/schemas/SessionIssued" } } }
        "400":
          description: invalid_params — malformed body
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "401":
          description: invalid_key — the link is invalid, spent, expired or superseded by a newer one
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

  /v1/session:
    delete:
      summary: Log out — session-authed; the record dies and answers session_expired until its natural expiry
      security: [{ forumSession: [] }]
      responses:
        "204":
          description: logged out
        "400":
          description: invalid_params — the caller presented a key; only a session can end itself
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
        "401":
          description: session_expired / invalid_key
          content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }

components:
  securitySchemes:
    forumKey:
      type: http
      scheme: bearer
      description: A Forum key (contracts_vendor/key-format.json) — opaque bearer.
    forumSession:
      type: http
      scheme: bearer
      description: The browser session of the login flow (contracts_vendor/schemas/session.schema.json) — its own credential, never a key; a key presented where a session is required answers invalid_params.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      schema: { type: string, minLength: 8, maxLength: 128 }
      description: Replays the stored response on retry; fingerprint mismatch answers invalid_params (retention 24 h).

  schemas:
    SessionIssued:
      description: >-
        Mirror of contracts_vendor/schemas/session.schema.json — a session is its own
        credential, hash-only at rest in Core's schema, never on the entitlement master,
        never valid on the data plane. The token never matches the key format.
      type: object
      required: [session]
      properties:
        session:
          type: object
          required: [token, account_id, created_at, expires_at]
          properties:
            token: { type: string }
            account_id: { type: string }
            created_at: { type: string, format: date-time }
            expires_at: { type: string, format: date-time }

    IntentProjection:
      description: >-
        What an agent sees of a payment — never card data, never a provider object's id,
        never a provider's error text. hosted_url is the provider's own page and is
        SHORT-LIVED; the stable address a wall hands an owner is action_url on the error
        envelope, which never expires.
      type: object
      required: [intent]
      properties:
        intent:
          type: object
          required: [intent_id, type, state, created_at]
          properties:
            intent_id: { type: string }
            type: { type: string, enum: [verification, topup] }
            state: { type: string, enum: [created, pending, succeeded, failed, expired] }
            hosted_url: { type: string, format: uri }
            amount_minor: { type: integer }
            currency: { type: string }
            created_at: { type: string, format: date-time }

    ErrorEnvelope:
      type: object
      description: Mirror of contracts_vendor/schemas/error-envelope.schema.json — the suite enforces field equality.
      required: [type, code, message]
      properties:
        type: { type: string }
        code: { type: string }
        message: { type: string }
        docs_url: { type: string }
        next_action: { type: string }
        action_url: { type: string, format: uri, description: "where a HUMAN completes next_action — the stable payment landing address on a money or verification wall, the support destination on key_blocked. Never a per-payment provider link." }
        retry_after: { type: integer }
        request_id: { type: string }

    SignupRequest:
      type: object
      required: [identity, tos_version]
      properties:
        identity:
          type: object
          required: [type, email]
          properties:
            type: { type: string, enum: [email], description: "oauth is reserved for a later stage" }
            email: { type: string, format: email }
        tos_version:
          type: string
          description: >-
            exact current version string — fetch it from GET / (for_agents.tos_version) on
            this host, or https://forum.bot/tos/last.json; mismatch → 400 invalid_params +
            refetch_tos

    SignupResponse:
      type: object
      required: [account, key, grant]
      properties:
        account:
          type: object
          required: [account_id, tier, tos_version]
          properties:
            account_id: { type: string }
            tier: { type: string, enum: [untrusted] }
            tos_version: { type: string }
            created_at: { type: string, format: date-time }
        key: { $ref: "#/components/schemas/KeyIssued" }
        grant:
          type: object
          description: >-
            the signup grant — one account-level sum of credits, booked into the
            account's one balance before this response and spendable on any served
            product. The number is the contracts money law's signup_grant record fact.
          required: [credits]
          properties:
            credits: { type: integer }

    KeyCreateRequest:
      description: >-
        An optional name, and nothing more. A key is a pure credential (the keys
        sitting, 2026-08-29): it carries no scopes — the account's scope is every served
        product, funded from its one budget — so a `scopes` field is refused rather than
        ignored, whatever its value. A `budget_cap_credits` field is refused the same
        way: this platform enforces no spending cap per key, and the account balance is
        the bound. An `expires_at` sent today is ignored — no key expires yet. Nothing
        in this body grants anything.
      type: object
      properties:
        name:
          type: string
          description: an optional label, echoed on the key and in the masked listing

    KeyIssued:
      type: object
      required: [key_id, prefix, secret]
      properties:
        key_id: { type: string }
        prefix: { type: string, description: public id for masked lists }
        name: { type: string }
        secret:
          type: string
          pattern: "^bf_live_[0-9A-Za-z]+_[0-9A-Za-z]{43}_[0-9A-Za-z]{12}$"
          description: the full key, shown exactly once (format = contracts_vendor/key-format.json)
        expires_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }

    KeyMasked:
      type: object
      required: [key_id, prefix, state]
      properties:
        key_id: { type: string }
        prefix: { type: string }
        name: { type: string }
        state: { type: string, enum: [active, blocked, revoked, expired] }
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    UsageView:
      type: object
      required: [usage, balance]
      properties:
        usage:
          type: array
          description: per key × product × day × outcome, newest first (30 days)
          items:
            type: object
            required: [day, product, key_id, outcome, calls, credits]
            properties:
              day: { type: string, format: date }
              product: { type: string }
              key_id: { type: string }
              outcome: { type: string }
              calls: { type: integer }
              credits: { type: integer }
        balance:
          type: object
          description: >-
            The account's one balance — granted plus bought credits, minus the whole
            spend (the keys sitting, 2026-08-29). Granted credits are told apart from
            bought ones by each entry's provenance; this is how an owner confirms from
            OUTSIDE that a payment arrived. Deliberately absent: the provider's own
            identifiers — a payment reference is evidence in the book, not an agent's business.
          required: [granted_credits, bought_credits, remaining_credits, entries]
          properties:
            granted_credits: { type: integer, description: "the signup grant — booked once, provenance 'granted'" }
            bought_credits: { type: integer, description: "the sum of what was bought, refund corrections netted off" }
            remaining_credits: { type: integer, description: "granted + bought − spend; may be negative — absorbed overshoot, never billed" }
            entries:
              type: array
              description: the 50 most recent money and grant movements, newest first
              items:
                type: object
                required: [credits, provenance, pricing_version, occurred_at]
                properties:
                  credits: { type: integer, description: "negative for a refund correction" }
                  provenance: { type: string, enum: [bought, earned, granted] }
                  pricing_version: { type: string, description: "the engine build's commit id — the catalogue that priced it" }
                  occurred_at: { type: string, format: date-time }

    AccountView:
      type: object
      required: [account_id, tier, tos_version, entitlements]
      properties:
        account_id: { type: string }
        tier: { type: string, enum: [untrusted, verified] }
        tos_version: { type: string }
        entitlements:
          type: object
          description: >-
            The account's rights, whole (the keys sitting, 2026-08-29): scope — every
            product the platform serves, for every account — and budget, the one
            balance every key of the account spends from. No per-key rights exist.
          required: [scope, budget]
          properties:
            scope:
              type: array
              description: the active catalogue slugs — what any of this account's keys may call
              items: { type: string }
            budget:
              type: object
              required: [remaining_credits]
              properties:
                remaining_credits:
                  type: integer
                  description: granted + bought − spend, in whole credits; the balance detail is /v1/account/usage
