openapi: 3.1.0
info:
  title: KellyIQ API
  version: "1.0"
  description: |
    Source of truth for the public KellyIQ API (Capability Set 1.0). Docs, the Python SDK, the MCP
    server and spreadsheet add-ins are generated from or checked against this file.

    ## Authentication
    Every request sends the account's API key: `Authorization: Bearer kiqa_...`. Never put the key
    in a URL. One key per account; regenerating it in the KellyIQ app invalidates the old key
    immediately.

    ## Versioning rule (permanent)
    Within v1, changes are **additive only**: new endpoints, new optional request fields, new
    response fields, new error codes. Clients must ignore response fields they do not know. The
    server ignores request fields it does not know, so a newer client works against an older server.
    Changing the meaning of a field, removing one, or making an optional field required needs a new
    version (`/v2`), and v1 then keeps working for a published sunset window.

    ## Conventions
    - Odds are American integers: at most -100 or at least +100. Send JSON integers; a decimal
      (`150.7`) is truncated and a numeric string (`"150"`) is accepted, but neither is guaranteed.
    - Probabilities and fractions are decimals in (0, 1), rounded to 6 places.
    - Money is USD, a number rounded to cents (half-up). There is no currency field.
    - Times are ISO-8601 UTC with a trailing `Z`; dates are `YYYY-MM-DD` (UTC).
    - Daily allowances run midnight to midnight US Eastern; `GET /v1/me` gives the exact `resetsAt`.
    - Fields that a tier is not entitled to are present with value `null`, never omitted.
    - Batch endpoints take at most 100 items.
    - Every response has an `X-Request-Id` header; error bodies repeat it as `requestId`. Quote it
      when reporting a problem.
    - Every response also says what produced it: `X-KellyIQ-Api-Version` (this capability set,
      "1.0") and `X-KellyIQ-Engine-Version` (the optimizer build). Optimize results repeat both
      in the body; keep them with any result you store.
    - Calculators have no daily allowance, only a per-minute cap (60). `GET /v1/me` and
      `GET /v1/sports` are free and never counted or capped.
servers:
  - url: https://api.kellyiq.com
security:
  - apiKey: []

paths:
  /v1/me:
    get:
      summary: "Profile: the account behind this key, its tier and its limits. Free."
      operationId: getMe
      responses:
        "200":
          description: Profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/InvalidApiKey" }
        "403": { $ref: "#/components/responses/ApiNotEnabled" }
        default: { $ref: "#/components/responses/OtherError" }

  /v1/sports:
    get:
      summary: The sport keys /v1/optimize accepts. Free.
      description: |
        Sports are added and removed over time, so read this list rather than hard-coding it.
        Never counted or capped.
      operationId: getSports
      responses:
        "200":
          description: Every sport optimize accepts, by name.
          content:
            application/json:
              schema:
                type: object
                required: [sports]
                properties:
                  sports:
                    type: array
                    items:
                      type: object
                      required: [key, name]
                      properties:
                        key: { type: string, example: baseball_mlb, description: "Send as picks[].sport." }
                        name: { type: string, example: MLB }
        "401": { $ref: "#/components/responses/InvalidApiKey" }
        "403": { $ref: "#/components/responses/ApiNotEnabled" }
        default: { $ref: "#/components/responses/OtherError" }

  /v1/implied-probability:
    post:
      summary: Odds → the probability the price implies (bookmaker margin included).
      operationId: impliedProbability
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [odds]
              properties:
                odds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items: { $ref: "#/components/schemas/AmericanOdds" }
            example: { odds: [-145, 220] }
      responses:
        "200":
          description: One result per input, same order.
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      required: [odds, decimalOdds, impliedProbability]
                      properties:
                        odds: { $ref: "#/components/schemas/AmericanOdds" }
                        decimalOdds: { type: number, description: "Total return per 1 staked." }
                        impliedProbability: { $ref: "#/components/schemas/Probability" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidApiKey" }
        "403": { $ref: "#/components/responses/ApiNotEnabled" }
        "429":
          description: "BURST_LIMIT_EXCEEDED: more than the calculators' per-minute budget; retry after Retry-After seconds."
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        default: { $ref: "#/components/responses/OtherError" }

  /v1/devig:
    post:
      summary: Remove the bookmaker's margin from a market (proportional method).
      description: |
        Each outcome's implied probability divided by their sum, the same method /v1/optimize uses
        before sizing. Two-way markets list 2 odds; three-way (win/draw/loss) list 3.
      operationId: devig
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [markets]
              properties:
                markets:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [odds]
                    properties:
                      id: { $ref: "#/components/schemas/ClientId" }
                      odds:
                        type: array
                        minItems: 2
                        maxItems: 3
                        items: { $ref: "#/components/schemas/AmericanOdds" }
            example: { markets: [{ id: "nyy-bos", odds: [-145, 125] }] }
      responses:
        "200":
          description: One result per market, same order; probabilities in the order of `odds`.
          content:
            application/json:
              schema:
                type: object
                required: [results]
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      required: [id, odds, impliedProbabilities, fairProbabilities, overround]
                      properties:
                        id: { type: [string, "null"] }
                        odds: { type: array, items: { $ref: "#/components/schemas/AmericanOdds" } }
                        impliedProbabilities: { type: array, items: { $ref: "#/components/schemas/Probability" } }
                        fairProbabilities: { type: array, items: { $ref: "#/components/schemas/Probability" } }
                        overround: { type: number, description: "Sum of implied probabilities minus 1 (the margin)." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidApiKey" }
        "403": { $ref: "#/components/responses/ApiNotEnabled" }
        "429":
          description: "BURST_LIMIT_EXCEEDED: more than the calculators' per-minute budget; retry after Retry-After seconds."
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        default: { $ref: "#/components/responses/OtherError" }

  /v1/kelly:
    post:
      summary: Single-bet Kelly stake for each bet on its own.
      description: |
        Each bet is sized **alone**: no per-bet cap, no total-exposure limit, no correlation between
        bets. Inside a slate, /v1/optimize usually allocates less because it applies all three.
        `kellyFraction` is the raw full-Kelly fraction (negative = no edge);
        `stakeFraction` = max(0, kellyFraction) × `fraction`.
      operationId: kelly
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [bets]
              properties:
                bets:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required: [odds, probability]
                    properties:
                      id: { $ref: "#/components/schemas/ClientId" }
                      odds: { $ref: "#/components/schemas/AmericanOdds" }
                      probability:
                        type: number
                        minimum: 0.000001
                        maximum: 0.999999
                        description: Your win probability for this bet.
                bankroll: { $ref: "#/components/schemas/Bankroll" }
                fraction:
                  type: number
                  exclusiveMinimum: 0
                  maximum: 1
                  default: 1
                  description: "Kelly multiplier: 0.5 = half Kelly."
            example: { bets: [{ id: "a", odds: 100, probability: 0.55 }], bankroll: 1000, fraction: 0.5 }
      responses:
        "200":
          description: One result per bet, same order.
          content:
            application/json:
              schema:
                type: object
                required: [fraction, bankroll, results]
                properties:
                  fraction: { type: number }
                  bankroll: { type: [number, "null"] }
                  results:
                    type: array
                    items:
                      type: object
                      required: [id, odds, probability, decimalOdds, breakEvenProbability, expectedValue,
                                 kellyFraction, stakeFraction, stake]
                      properties:
                        id: { type: [string, "null"] }
                        odds: { $ref: "#/components/schemas/AmericanOdds" }
                        probability: { $ref: "#/components/schemas/Probability" }
                        decimalOdds: { type: number }
                        breakEvenProbability: { $ref: "#/components/schemas/Probability" }
                        expectedValue: { type: number, description: "Expected profit per 1 staked." }
                        kellyFraction: { type: number }
                        stakeFraction: { type: number, minimum: 0, description: "Fraction of the bankroll to stake." }
                        stake: { type: [number, "null"], description: "USD; null when no bankroll was sent." }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidApiKey" }
        "403": { $ref: "#/components/responses/ApiNotEnabled" }
        "429":
          description: "BURST_LIMIT_EXCEEDED: more than the calculators' per-minute budget; retry after Retry-After seconds."
          headers:
            Retry-After: { schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        default: { $ref: "#/components/responses/OtherError" }

  /v1/optimize:
    post:
      summary: "Size a slate: how to split the bankroll across its bets (the allocation), and the risk."
      description: |
        How it works: each pick's price is de-vigged to a fair (market) probability; your
        `confidence` shifts it, or your `probability` replaces it; that probability drives a
        Kelly-shaped mean-variance allocation across the whole slate (not plain Kelly), with each
        bet capped at min(perBetCap, 1.5 × its single-bet Kelly). A Monte Carlo simulation
        (100,000 trials) measures each sizing's risk and picks the Kelly multiplier for the allocation.

        **Settings** (`maxTotalExposure`, `perBetCap`, `maxBets`): each comes from the request,
        else the account's saved KellyIQ app settings, else the product defaults (100%, 20%, no
        maximum). From the account, max exposure is the smaller of the saved slate fraction and
        max exposure, as in the app; a saved fixed-stake mode does not apply.
        `useAccountDefaults: false` skips the account step. perBetCap never exceeds
        maxTotalExposure (nor falls below 1% of it); an adjusted value is reported with a warning. `settingsApplied` reports
        every value and its source; `GET /v1/me` shows what an omitted setting resolves to.
        Free accounts cannot set perBetCap (fixed at 25% of the at-risk amount).

        **Results vary slightly between identical calls** (simulation noise), which can move the
        allocation's multiplier. Every response returns the `seed` it used; resend that seed with the
        same input to reproduce a result exactly, or send your own.

        **Limits.** Daily optimize allowance: Free 10, Bettor 50, Sharp 250, Pro 500; resets at
        midnight US Eastern. Every optimize that passes the per-minute and one-at-a-time checks
        counts, including ones that fail validation or need an upgrade. Not counted: 429
        BURST_LIMIT_EXCEEDED, 503 SERVICE_BUSY, malformed JSON and key errors. At most 10 optimizes
        per minute and one at a time per account. `GET /v1/me` and the X-RateLimit-* headers
        (on successful responses and DAILY_LIMIT_EXCEEDED) show the current state.
      operationId: optimize
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OptimizeRequest" }
            example:
              bankroll: 1000
              maxTotalExposure: 0.5
              picks:
                - { id: "nyy", sport: baseball_mlb, team: Yankees, opponent: Red Sox, odds: -130, opponentOdds: 110, confidence: MODERATE_EDGE }
                - { id: "lad", sport: baseball_mlb, team: Dodgers, opponent: Padres, odds: -145, opponentOdds: 125, confidence: STRONG_EDGE }
      responses:
        "200":
          description: Sized slate.
          headers:
            X-RateLimit-Limit: { description: "Today's optimize allowance. The X-RateLimit-* headers are omitted on the rare response where usage could not be counted.", schema: { type: integer } }
            X-RateLimit-Remaining: { description: Optimizes left today after this one., schema: { type: integer } }
            X-RateLimit-Reset: { description: When the allowance resets (Unix seconds; midnight US Eastern)., schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OptimizeResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/InvalidApiKey" }
        "403":
          description: API_NOT_ENABLED, or UPGRADE_REQUIRED (slate larger than the tier allows, Free 3 and Bettor 12 picks, or probability input on Free); requiredTier says which.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "429":
          description: |
            DAILY_LIMIT_EXCEEDED: today's allowance is used up; retry after resetsAt (midnight US
            Eastern). BURST_LIMIT_EXCEEDED: too many requests per minute, or another optimize for this
            account is still running; retry after retryAfterSeconds. Always sends Retry-After.
          headers:
            Retry-After: { schema: { type: integer } }
            X-RateLimit-Limit: { description: DAILY_LIMIT_EXCEEDED only., schema: { type: integer } }
            X-RateLimit-Remaining: { description: "DAILY_LIMIT_EXCEEDED only: 0.", schema: { type: integer } }
            X-RateLimit-Reset: { description: DAILY_LIMIT_EXCEEDED only (Unix seconds)., schema: { type: integer } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: "SLATE_NOT_OPTIMIZABLE: valid request, but nothing could be sized; reasons in violations."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: "SERVICE_BUSY: server capacity, not your allowance. Retry after the Retry-After seconds."
          headers:
            Retry-After: { schema: { type: integer, example: 5 } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        default: { $ref: "#/components/responses/OtherError" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "The account API key, kiqa_ followed by 43 characters."

  schemas:
    AmericanOdds:
      type: integer
      description: "American odds: at most -100 or at least +100 (magnitude ≤ 100000)."
      example: -145
    Probability:
      type: number
      exclusiveMinimum: 0
      exclusiveMaximum: 1
    Bankroll:
      type: number
      exclusiveMinimum: 0
      maximum: 100000000
      description: USD.
    ClientId:
      type: string
      maxLength: 64
      description: Your own label for the item; echoed back unchanged.

    OptimizePick:
      type: object
      required: [sport, team, opponent, odds, opponentOdds]
      description: Send exactly one of confidence or probability; every pick in a request uses the same one.
      properties:
        id: { $ref: "#/components/schemas/ClientId" }
        sport: { type: string, example: baseball_mlb, description: "A key from GET /v1/sports." }
        team: { type: string, maxLength: 100, description: "Moneyline and spread: the team you are betting. Totals: either team in the game (betType sets Over or Under)." }
        opponent: { type: string, maxLength: 100, description: The other team in the game. }
        odds: { $ref: "#/components/schemas/AmericanOdds", description: "Your side: the team, or the Over or Under for totals." }
        opponentOdds: { $ref: "#/components/schemas/AmericanOdds", description: "The other side of the same market, so the margin can be removed." }
        drawOdds: { $ref: "#/components/schemas/AmericanOdds" }
        betType: { type: string, enum: [MONEYLINE, SPREAD, TOTAL_OVER, TOTAL_UNDER], default: MONEYLINE }
        line: { type: number, description: "Required for SPREAD and totals. Spread lines are from the picked team's side (-1.5 = your team must win by 2+); for totals, the total." }
        eventId: { type: string, maxLength: 128, description: "Optional; picks sharing an eventId are treated as the same game." }
        gameDate: { type: string, format: date }
        confidence:
          type: string
          enum: [STRONG_FADE, MODERATE_FADE, SLIGHT_FADE, MARKET, SMALL_EDGE, MODERATE_EDGE, STRONG_EDGE]
          description: How far your view is from the fair price.
        probability: { type: number, minimum: 0.000001, maximum: 0.999999, description: Your own win probability (Bettor and up). }
    OptimizeRequest:
      type: object
      required: [bankroll, picks]
      properties:
        bankroll: { $ref: "#/components/schemas/Bankroll" }
        picks: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OptimizePick" } }
        maxTotalExposure: { type: number, exclusiveMinimum: 0, maximum: 1, description: Most of the bankroll at risk across the slate. }
        perBetCap: { type: number, minimum: 0.01, maximum: 1, description: Most of the bankroll on any one bet. }
        maxBets: { type: integer, minimum: 1, maximum: 50, description: Omit for no maximum. }
        useAccountDefaults: { type: boolean, default: true }
        seed: { type: integer, format: int64, description: "Same seed + same input = identical result. Omit it and the server picks one (returned in the response)." }
    Setting:
      type: object
      required: [value, source]
      properties:
        value: { type: [number, "null"] }
        source: { type: string, description: "request | account | default | tier (open set)" }
    Sized:
      type: [object, "null"]
      required: [fraction, stake]
      properties:
        fraction: { type: number, description: Fraction of the bankroll sent. }
        stake: { type: number, description: USD. }
    RiskStats:
      type: [object, "null"]
      required: [trials, meanProfit, stdDev, probLoss, var5, cvar5]
      properties:
        trials: { type: integer }
        meanProfit: { type: number, description: USD. }
        stdDev: { type: number, description: USD. }
        probLoss: { type: number }
        var5: { type: number, description: "USD; 5th-percentile result." }
        cvar5: { type: number, description: "USD; average of the worst 5%." }
    RiskPair:
      type: object
      required: [independent, correlated]
      properties:
        independent: { $ref: "#/components/schemas/RiskStats" }
        correlated: { $ref: "#/components/schemas/RiskStats", description: "Bets that tend to win or lose together; null below Sharp." }
    OptimizeResponse:
      type: object
      required: [allocation, bankroll, atRisk, settingsApplied, warnings, picks, risk, explanation, seed, apiVersion, engineVersion, requestId]
      properties:
        allocation:
          type: object
          description: "The result: how the optimizer splits the bankroll across the slate. A calculation, not advice."
          required: [kellyMultiplier, riskConstraintMet, totalStake, stakes]
          properties:
            kellyMultiplier: { type: [number, "null"], description: "The fraction of full Kelly chosen (e.g. 0.35)." }
            riskConstraintMet: { type: [boolean, "null"] }
            totalStake: { type: number, description: "USD; the sum of the stakes." }
            stakes:
              type: array
              description: One row per pick with a stake; picks without one are left out (see picks[].reason).
              items:
                type: object
                required: [pickIndex, id, team, betType, line, odds, fraction, stake]
                properties:
                  pickIndex: { type: integer, description: Position of the pick in the request. }
                  id: { type: [string, "null"] }
                  team: { type: string }
                  betType: { type: string }
                  line: { type: [number, "null"] }
                  odds: { $ref: "#/components/schemas/AmericanOdds" }
                  fraction: { type: number, description: Fraction of the bankroll sent. }
                  stake: { type: number, description: USD. }
        requestId: { type: string }
        apiVersion: { type: string, example: "1.0", description: The capability set that produced this result. }
        engineVersion: { type: string, example: "a5af4c6", description: "The optimizer build that produced this result; with the seed and the request, enough to explain it later." }
        bankroll: { type: number }
        atRisk: { type: number, description: "bankroll × maxTotalExposure: the amount sized." }
        settingsApplied:
          type: object
          required: [maxTotalExposure, perBetCap, maxBets]
          properties:
            maxTotalExposure: { $ref: "#/components/schemas/Setting" }
            perBetCap: { $ref: "#/components/schemas/Setting" }
            maxBets: { $ref: "#/components/schemas/Setting" }
        warnings: { type: array, items: { type: string } }
        seed: { type: integer, format: int64, description: "The seed this run used: yours, or one the server generated (0 to 2^53-1, safe in JavaScript). Resend it with the same input to reproduce this exact result." }
        picks:
          type: array
          description: One per request pick, same order.
          items:
            type: object
            required: [index, id, team, opponent, betType, line, odds, eventId, included, reason, probability, expectedValue, sizing]
            properties:
              index: { type: integer }
              id: { type: [string, "null"] }
              team: { type: string }
              opponent: { type: string }
              betType: { type: string }
              line: { type: [number, "null"] }
              odds: { $ref: "#/components/schemas/AmericanOdds" }
              eventId: { type: [string, "null"] }
              included: { type: boolean, description: Has a stake in the allocation. }
              reason: { type: [string, "null"], description: "When not included: NO_EDGE, MAX_BETS, NOT_SELECTED or NOT_PRICEABLE (open set)." }
              probability:
                type: [object, "null"]
                required: [market, yours]
                properties:
                  market: { type: [number, "null"], description: Fair (de-vigged) probability. }
                  yours: { type: [number, "null"], description: The probability that drove sizing. }
              expectedValue: { type: [number, "null"], description: Expected profit per 1 staked at your probability. }
              sizing:
                type: [object, "null"]
                description: Null when the pick never reached the allocation.
                properties:
                  full: { $ref: "#/components/schemas/Sized" }
                  half: { $ref: "#/components/schemas/Sized" }
                  quarter: { $ref: "#/components/schemas/Sized" }
                  fixed: { $ref: "#/components/schemas/Sized", description: "Full-Kelly proportions scaled to stake the whole at-risk amount." }
                  riskAdjusted: { $ref: "#/components/schemas/Sized", description: The sizing the allocation uses. }
        risk:
          type: object
          required: [full, half, quarter, fixed, riskAdjusted]
          properties:
            full: { $ref: "#/components/schemas/RiskPair" }
            half: { $ref: "#/components/schemas/RiskPair" }
            quarter: { $ref: "#/components/schemas/RiskPair" }
            fixed: { $ref: "#/components/schemas/RiskPair" }
            riskAdjusted: { $ref: "#/components/schemas/RiskPair" }
        explanation:
          type: [object, "null"]
          description: Plain-English summary for display only. Do not parse; wording changes. Its percentages are of the at-risk amount, not the bankroll sent.
          properties:
            summary: { type: string }
            details: { type: array, items: { type: string } }
            closing: { type: [string, "null"] }

    Me:
      type: object
      required: [apiVersion, tier, trialEndsAt, limits, key, defaults]
      properties:
        apiVersion: { type: string, example: "1.0", description: "Capability-set version this account is served." }
        tier: { type: string, enum: [free, bettor, sharp, pro] }
        trialEndsAt: { type: [string, "null"], format: date, description: "Set only while a trial is active." }
        limits:
          type: object
          required: [optimize, calculators]
          properties:
            optimize:
              type: object
              required: [perDay, usedToday, remainingToday, resetsAt, maxConcurrentPerAccount, perMinute]
              properties:
                perDay: { type: integer }
                usedToday: { type: integer, description: "Attempts charged today; can exceed perDay, because refused over-limit attempts still count." }
                remainingToday: { type: integer }
                resetsAt: { type: string, format: date-time, description: "Next midnight US Eastern (America/New_York, follows daylight time), as a UTC instant." }
                maxConcurrentPerAccount: { type: integer, example: 1 }
                perMinute: { type: integer, example: 10 }
            calculators:
              type: object
              required: [metered, perMinute]
              properties:
                metered: { type: boolean, example: false }
                perMinute: { type: integer, example: 60 }
        key:
          type: object
          required: [prefix, createdAt, lastUsedAt]
          properties:
            prefix: { type: string, example: "kiqa_5ePA" }
            createdAt: { type: string, format: date-time }
            lastUsedAt: { type: [string, "null"], format: date-time }
        defaults:
          type: object
          description: What /v1/optimize uses for each setting a request leaves out, and where it comes from.
          required: [maxTotalExposure, perBetCap, maxBets, warnings]
          properties:
            maxTotalExposure: { $ref: "#/components/schemas/Setting" }
            perBetCap: { $ref: "#/components/schemas/Setting" }
            maxBets: { $ref: "#/components/schemas/Setting" }
            warnings: { type: array, items: { type: string } }

    Error:
      type: object
      required: [error, message, requestId]
      properties:
        error:
          type: string
          description: |
            Machine-readable code. Branch on this, never on `message`. **An open set**: new codes
            may be added within v1, so treat an unknown code by its HTTP status. Current codes:
            INVALID_API_KEY, API_NOT_ENABLED, VALIDATION_ERROR, INVALID_JSON, INVALID_REQUEST,
            NOT_FOUND, METHOD_NOT_ALLOWED, UNSUPPORTED_MEDIA_TYPE, FORBIDDEN, INTERNAL_ERROR,
            UPGRADE_REQUIRED (403), SLATE_NOT_OPTIMIZABLE (422), SERVICE_BUSY (503),
            DAILY_LIMIT_EXCEEDED and BURST_LIMIT_EXCEEDED (429). Retry a DAILY_LIMIT_EXCEEDED only
            after resetsAt, never in a loop.
          x-known-values: [INVALID_API_KEY, API_NOT_ENABLED, VALIDATION_ERROR, INVALID_JSON, INVALID_REQUEST,
                           NOT_FOUND, METHOD_NOT_ALLOWED, UNSUPPORTED_MEDIA_TYPE, FORBIDDEN, INTERNAL_ERROR,
                           UPGRADE_REQUIRED, SLATE_NOT_OPTIMIZABLE, SERVICE_BUSY,
                           DAILY_LIMIT_EXCEEDED, BURST_LIMIT_EXCEEDED]
        message: { type: string, description: "Human-readable; wording may change." }
        violations:
          type: array
          items: { type: string }
          description: 'VALIDATION_ERROR: every problem found, each as "field: problem". SLATE_NOT_OPTIMIZABLE: the reasons.'
        requiredTier: { type: string, description: "UPGRADE_REQUIRED only: the tier needed." }
        retryAfterSeconds: { type: integer, description: "DAILY_LIMIT_EXCEEDED and BURST_LIMIT_EXCEEDED: seconds to wait (same as Retry-After)." }
        resetsAt: { type: string, format: date-time, description: "DAILY_LIMIT_EXCEEDED only: when the allowance resets." }
        requestId: { type: string, example: "req_3f1c..." }

  responses:
    BadRequest:
      description: VALIDATION_ERROR or INVALID_JSON.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    InvalidApiKey:
      description: Missing, malformed, unknown or revoked key (all answered the same).
      headers:
        WWW-Authenticate: { schema: { type: string, example: Bearer } }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ApiNotEnabled:
      description: The API is switched off.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    OtherError:
      description: Any other error (404, 405, 415, 500 …), always in the Error shape.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
