openapi: 3.0.3
info:
  title: Cervana Voice API
  version: "1.0.0"
  description: >
    The single public contract for the Cervana voice-AI platform, powered
    end-to-end by Cervana's own models:


    * **Cervana Percept 1.0** — speech recognition (the `/transcribe` engine).

    * **Cervana Noema 1.0** — the language model (the `/chat` engine).

    * **Cervana Melo 1.0** — speech synthesis and voice cloning (the
    `/synthesize` and `/voices` engine).


    Every model is served on Cervana's own stack — no third-party AI APIs.
    The gateway routes each request to the right model automatically; callers
    never pick an engine. Write/update this file BEFORE changing service
    endpoints (Constitution Principle 5).
servers:
  - url: https://api.cervanaai.com/v1

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key

  responses:
    RateLimited:
      description: >
        Rate limit exceeded for this API key (or, on the console auth
        endpoints, this client IP). Carries Retry-After plus the
        X-RateLimit-Limit / -Remaining / -Reset headers.
    PaymentRequired:
      description: >
        Billing enforcement (v3.1 §4): the tenant's credits are exhausted
        (`balance_exhausted`), the trial hard-stopped at zero
        (`trial_exhausted`, guardrail G-6), the subscription is inactive
        (`subscription_inactive`), or no plan was ever subscribed
        (`no_subscription`). Before blocking ever starts, low-balance
        responses carry an X-Credits-Warning header while still serving
        (the warning window); the console remains fully accessible in every
        state. Deployments without billing (on-prem licence) never return
        this.
      headers:
        X-Credits-Balance:
          schema: { type: integer }
          description: The tenant's current credit balance.
      content:
        application/json:
          schema:
            type: object
            properties:
              detail:
                type: object
                properties:
                  error: { type: string, enum: [payment_required] }
                  reason:
                    type: string
                    enum: [balance_exhausted, trial_exhausted,
                           subscription_inactive, no_subscription]
                  credit_balance: { type: integer, nullable: true }

  schemas:
    # Layer 1 (deploy/PLATFORM_FOUNDATION.md): a tenant's agent configuration,
    # resolved from the DID a call arrived on. Every override field is OPTIONAL —
    # only the ones the tenant actually set are present; the agent merges them
    # over its env defaults, so a partial config means "override just these".
    AgentConfig:
      type: object
      required: [agent_config_id, tenant_id, name]
      properties:
        agent_config_id: { type: integer, description: agent_configs row id }
        tenant_id:       { type: integer, description: owning tenant }
        name:            { type: string,  description: config name (unique per tenant) }
        language:        { type: string, description: STT/TTS language (any of the 23 supported codes, e.g. en/de/ar/ur) }
        voice_id:        { type: string, description: preset or cloned TTS voice }
        llm_model:       { type: string, description: LLM model id (omit -> service default) }
        system_prompt:   { type: string, description: full instructions override }
        tools:
          type: array
          items: { type: string }
          description: >
            Which built-in @function_tools this tenant enables
            (transfer/lookup/booking); absent -> the agent's default tool set.
        transfer_target_number: { type: string, description: SIP transfer target }
        lookup_webhook_url:     { type: string, description: lookup_info backend URL }
        website_url:        { type: string, description: "public https page the agent reads at call start and answers from (its own words about the business)" }
        speech_rate:        { type: number, minimum: 0.75, maximum: 1.35, description: "how fast this agent speaks; 1.0 = the deployment's tuned default, lower is slower" }
        greeting_override:      { type: string, description: fixed spoken greeting override }
        mcp_servers:
          type: array
          description: >
            MCP (Model Context Protocol) servers this agent connects to at
            call start, giving the model the tenant's own tools mid-call. Each
            url is validated as a public https URL (SSRF guard).
          items:
            type: object
            required: [url]
            properties:
              url:     { type: string, description: public https MCP server endpoint }
              name:    { type: string }
              headers: { type: object, description: auth headers (string->string) }

    # Console (Phase 1): the signed-in user's context — every console screen
    # scopes by this tenant (the L0 isolation boundary, per logged-in user).
    Me:
      type: object
      required: [email, tenant]
      properties:
        email: { type: string, format: email }
        tenant:
          type: object
          required: [id, name, concurrency_limit, status]
          properties:
            id:                { type: integer }
            name:              { type: string }
            concurrency_limit: { type: integer, description: max simultaneous calls (RL) }
            status:            { type: string, enum: [active, suspended] }

    # RL (deploy/RL_DESIGN.md): the tenant + concurrency cap a DID resolves to.
    Tenant:
      type: object
      required: [tenant_id, concurrency_limit]
      properties:
        tenant_id:         { type: integer, description: owning tenant id }
        tenant_name:       { type: string,  description: tenant name }
        concurrency_limit:
          type: integer
          description: >
            Max simultaneous calls (RL cap). ZERO when billing enforcement
            blocks the tenant (v3.1 §4 — exhausted credits, trial hard-stop,
            or dead subscription): the agent's limiter then rejects every
            call into the busy-message flow, which is the balance-aware busy
            behaviour.
        agent_config_id:   { type: integer, nullable: true, description: routed agent config, if any }
        billing_blocked:
          type: string
          nullable: true
          enum: [balance_exhausted, trial_exhausted, subscription_inactive,
                 no_subscription, null]
          description: >
            Why the limit was zeroed, when billing enforcement blocked the
            tenant; null when service is normal.

    # A single transcript segment. The SAME shape carries batch results and
    # (Wave-3) streaming partials — partials set is_final=false, the terminal
    # segment sets is_final=true — so streaming is an additive, non-breaking
    # extension of the batch response below.
    Segment:
      type: object
      required: [text]
      properties:
        text:     { type: string }
        start:    { type: number, description: Seconds from utterance start }
        end:      { type: number }
        is_final: { type: boolean, default: true }

    Transcript:
      type: object
      required: [text, language]
      properties:
        text:     { type: string, description: Finalized transcript }
        language: { type: string, description: Detected or requested language }
        duration: { type: number, description: Audio duration in seconds }
        segments:
          type: array
          items: { $ref: '#/components/schemas/Segment' }

    Voice:
      type: object
      required: [voice_id]
      properties:
        voice_id:   { type: string }
        name:       { type: string }
        language:   { type: string, enum: [ar, da, de, el, en, es, fi, fr, he, hi, it, ja, ko, ms, nl, "no", pl, pt, ru, sv, sw, sl, tr, ur, zh] }
        type:       { type: string, enum: [cloned, preset] }
        created_at: { type: string, format: date-time }

    # A function the model may call (PILOT_READY Track F / F0). OpenAI-compatible:
    # `parameters` is a JSON Schema object describing the arguments. On /chat,
    # the model returns a `tool_calls` array (each {id, type:function,
    # function:{name, arguments}} where arguments is a JSON string) instead of
    # text; the caller executes and returns each result as a role="tool"
    # message carrying the matching tool_call_id.
    Tool:
      type: object
      required: [type, function]
      properties:
        type:
          type: string
          enum: [function]
        function:
          type: object
          required: [name]
          properties:
            name:        { type: string }
            description: { type: string }
            parameters:
              type: object
              description: JSON Schema for the function's arguments.

security:
  - ApiKeyAuth: []

paths:
  /transcribe:
    post:
      summary: Speech to text
      description: >
        Transcribe caller audio to text with **Cervana Percept 1.0**
        (VAD-gated), emitting a finalized transcript. `stream` is reserved for
        Wave-3 incremental
        partials delivered as Server-Sent Events: those events reuse the exact
        Segment/Transcript shapes below (partials carry is_final=false, the
        terminal event is_final=true), so turning streaming on is additive and
        never breaks a stream=false caller.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Caller audio (WAV/PCM or a common encoding).
                language:
                  type: string
                  enum: [ar, da, de, el, en, es, fi, fr, he, hi, it, ja, ko, ms, nl, "no", pl, pt, ru, sv, sw, sl, tr, ur, zh, auto]
                  default: auto
                stream:
                  type: boolean
                  default: false
                  description: Reserved for Wave-3 SSE partial transcripts.
      responses:
        "200":
          description: >
            Finalized transcript as JSON. When stream=true (Wave 3), the same
            segments arrive incrementally as text/event-stream — same schema,
            no breaking change.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Transcript'
            text/event-stream:
              schema:
                type: string
                description: >
                  SSE stream of Segment-shaped JSON events (Wave 3); terminal
                  event carries the full text with is_final=true.

        "402": { $ref: "#/components/responses/PaymentRequired" }
  /synthesize:
    post:
      summary: Text to speech
      description: >
        Synthesize speech from text with **Cervana Melo 1.0**. The gateway
        routes by `language` to the right voice automatically; `voice_id`
        selects a cloned or preset voice registered via /voices. `voice_id`
        is resolved within
        the CALLER's namespace (per API key) — one caller's voices are never
        visible or usable by another. With stream=false the full WAV is
        returned; stream=true (Wave 3) returns the same audio/wav as a chunked
        stream — additive, non-breaking.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text, language]
              properties:
                text:
                  type: string
                language:
                  type: string
                  enum: [ar, da, de, el, en, es, fi, fr, he, hi, it, ja, ko, ms, nl, "no", pl, pt, ru, sv, sw, sl, tr, ur, zh]
                voice_id:
                  type: string
                stream:
                  type: boolean
                  default: false
                speed:
                  type: number
                  minimum: 0.75
                  maximum: 1.35
                  default: 1.0
                  description: >
                    Speaking pace relative to this deployment's tuned default:
                    1.0 = normal, below = slower, above = brisker. Clamped
                    server-side; a per-agent setting customers control from the
                    console.
      responses:
        "200":
          description: WAV audio
          content:
            audio/wav:
              schema:
                type: string
                format: binary

        "402": { $ref: "#/components/responses/PaymentRequired" }
  /voices:
    get:
      summary: List caller's voices
      description: >
        Lists voices in the CALLER's namespace only (per API key) — cloned
        voices plus any presets available for their languages.
      responses:
        "200":
          description: The caller's voices.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Voice' }
    post:
      summary: Register / clone a voice
      description: >
        Upload a reference clip to create a cloned voice with **Cervana Melo
        1.0**. Stored per API key in the object store keyed by the caller's
        key_id (never on local disk, never shared across callers). `ref_text`
        is the transcript of the clip, used when the target language's voice
        model is transcript-conditioned. Every cloned voice requires
        documented consent.
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, name]
              properties:
                file:
                  type: string
                  format: binary
                name:
                  type: string
                language:
                  type: string
                  enum: [ar, da, de, el, en, es, fi, fr, he, hi, it, ja, ko, ms, nl, "no", pl, pt, ru, sv, sw, sl, tr, ur, zh]
                ref_text:
                  type: string
      responses:
        "200":
          description: The newly created voice (scoped to the caller).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Voice'

        "402": { $ref: "#/components/responses/PaymentRequired" }
  /voices/{id}:
    delete:
      summary: Delete a voice
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "204":
          description: Deleted

  /chat:
    post:
      summary: Chat completions
      description: >
        Chat completion with **Cervana Noema 1.0**. The gateway selects and
        warms the model; omit `model` (or pass `cervana-noema-1.0`) to use the
        default.
        With `stream=false` (default) the full reply is returned as JSON.
        With `stream=true` (Wave 3) the gateway relays the engine's token
        stream as Server-Sent Events (`text/event-stream`) — the same tokens,
        emitted incrementally as they are generated and never buffered, so
        first-token latency is not gated on the full completion. Turning
        streaming on is additive and never breaks a `stream=false` caller.
        `tools` (PILOT_READY Track F, contract-first — gate F0) lets the model
        request a FUNCTION CALL instead of (or before) a text reply, so the
        agent can act — look something up, book, transfer a call. The schema is
        the industry-standard OpenAI-compatible shape, so external `/v1`
        callers get tool use for free.
        Declaring `tools` is additive: a caller that sends none is unaffected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [messages]
              properties:
                model:
                  type: string
                  description: >
                    Optional. Omit to use the default, or pass
                    `cervana-noema-1.0`. Any other value is rejected (400).
                  example: cervana-noema-1.0
                messages:
                  type: array
                  items:
                    # A chat message. Assistant turns may carry `tool_calls`;
                    # a caller returns a tool's result as a message with
                    # role="tool" + matching `tool_call_id` (OpenAI shape).
                    type: object
                stream:
                  type: boolean
                  default: false
                  description: >
                    Wave 3 — when true, the reply is streamed as SSE token
                    deltas instead of a single JSON body.
                tools:
                  type: array
                  description: >
                    Functions the model MAY call (F0). OpenAI-compatible; the
                    model returns `tool_calls` instead of `content` when it
                    chooses one. Omit for a plain chat request.
                  items:
                    $ref: '#/components/schemas/Tool'
                tool_choice:
                  description: >
                    "auto" (default — model decides), "none" (never call a
                    tool), or {type:function, function:{name}} to force one.
                  oneOf:
                    - type: string
                      enum: [auto, none, required]
                    - type: object
      responses:
        "200":
          description: >
            Chat reply. With stream=false, a single JSON body — either a text
            reply OR (if the model chose to act) an assistant message carrying
            `tool_calls` (the caller runs them and posts results back as
            role="tool" messages to continue). With stream=true, an SSE stream
            of OpenAI-shaped token/tool-call delta events, terminated by a
            `data: [DONE]` sentinel — relayed incrementally, not buffered.
          content:
            application/json: {}
            text/event-stream:
              schema:
                type: string
                description: >
                  SSE stream of chat token / tool-call deltas (Wave 3);
                  terminal event is `data: [DONE]`.
        "402": { $ref: "#/components/responses/PaymentRequired" }

  /agent-config:
    get:
      summary: Resolve the agent configuration a DID answers with (Layer 1)
      description: >
        Multi-tenant control-plane read (deploy/PLATFORM_FOUNDATION.md Layer 1):
        given the DID a call arrived on, return that tenant's agent
        configuration — system prompt, voice, language, LLM model, enabled tools,
        transfer target, lookup webhook. The agent worker calls this at call
        start and builds itself from the record instead of env vars, so ONE
        deployment serves every tenant. Only fields explicitly overridden by the
        tenant's config are present; anything absent means "use the agent's env
        default". A 204 (unmapped DID / tenant has no config / config store down)
        is a first-class answer telling the agent to fall back to env defaults —
        it never fails a call. Additive and non-breaking: a single-tenant
        deployment that never calls this behaves exactly as before.
      parameters:
        - name: did
          in: query
          required: false
          schema:
            type: string
          description: >
            The E.164 number the call was dialed to (the phone routing key).
            Exactly one of did/room.
        - name: room
          in: query
          required: false
          schema:
            type: string
          description: >
            A web-call room name (`web-…`, minted by /web-call) — the routing
            key for a BROWSER session, resolved through web_sessions instead
            of did_routing. Same response shape either way.
      responses:
        "200":
          description: The resolved agent configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentConfig'
        "204":
          description: >
            No config resolves — the agent uses its env defaults (unmapped DID,
            tenant without a config, or config store unavailable).
        "403":
          description: Missing or invalid API key.

  /tenant:
    get:
      summary: Resolve the tenant + concurrency limit a DID belongs to (RL)
      description: >
        Rate-limiting attribution (deploy/RL_DESIGN.md): given the DID a call
        arrived on, return its owning tenant and that tenant's concurrent-call
        limit — what the agent's per-tenant concurrency limiter admits against.
        Distinct from /agent-config: this is the attribution + cap, not the agent
        configuration record. A 204 (unmapped DID / suspended tenant / store
        down) tells the limiter to no-op — a call with no tenant is not
        rate-limited (the single-tenant pilot is unaffected), and RL fails open.
      parameters:
        - name: did
          in: query
          required: false
          schema:
            type: string
          description: >
            The E.164 number the call was dialed to (the phone routing key).
            Exactly one of did/room.
        - name: room
          in: query
          required: false
          schema:
            type: string
          description: >
            A web-call room name (`web-…`, minted by /web-call) — the routing
            key for a BROWSER session, resolved through web_sessions instead
            of did_routing. Same response shape either way.
      responses:
        "200":
          description: The resolved tenant + limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tenant'
        "204":
          description: No tenant resolves — the limiter no-ops (fail-open).
        "403":
          description: Missing or invalid API key.

  /web-call:
    post:
      summary: Start a web call with one of the tenant's agents (W2)
      description: >
        Mints a single-room LiveKit join ticket for a BROWSER voice session
        with the given agent — the console's test environment and the
        embeddable website widget ride this seam. The returned room resolves
        through /tenant?room= and /agent-config?room= exactly as a dialed
        number resolves through ?did=, so per-tenant concurrency (the line
        cap covers phone + web together), the busy flow, call records, and
        voice metering (12 credits/min at call end) apply to web calls
        unchanged. Billing enforcement is applied at minting (402 when
        blocked) and again at call admission. Session-cookie authed
        (console plane).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_config_id]
              properties:
                agent_config_id: { type: integer }
      responses:
        "200":
          description: The join ticket.
          content:
            application/json:
              schema:
                type: object
                required: [room, token, url]
                properties:
                  room:  { type: string, description: LiveKit room (web-…) }
                  token: { type: string, description: single-room join JWT }
                  url:   { type: string, description: public LiveKit wss endpoint }
                  expires_in_s: { type: integer }
        "400": { description: agent_config_id missing/invalid. }
        "401": { description: Not signed in. }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "404": { description: Agent not found (or not this tenant's). }
        "503": { description: Web calling not enabled on this deployment. }

  /web-call/embed:
    post:
      summary: Start a web call from the embedded website widget (W5)
      description: >
        The widget's public mint endpoint — no session, no API key. Auth is
        the pair (embed key, browser Origin): the key is a public identifier
        that ships in customer page source; requests are honored only when
        the Origin exactly matches the agent's allow-list (deny by default —
        no origins configured means the widget is off). Additional bounds: a
        per-IP mint throttle, billing enforcement (402), and the tenant's
        line cap at call admission. Returns the same join ticket as
        /web-call; sessions are metered identically (voice minutes).
        CORS: preflight is answered permissively; the real gate is here.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [embed_key]
              properties:
                embed_key: { type: string }
      responses:
        "200":
          description: The join ticket ({room, token, url, expires_in_s}).
        "400": { description: embed_key missing. }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "403": { description: Unknown/disabled key or Origin not allowed (one answer — no oracle). }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: Web calling not enabled on this deployment. }

  /console/agents/{agent_id}/embed:
    get:
      summary: Console — the agent's website-widget embed (key + origins)
      security: []
      parameters:
        - name: agent_id
          in: path
          required: true
          schema: { type: integer }
      responses:
        "200": { description: "{embed: {embed_key, allowed_origins, enabled} | null}" }
        "401": { description: Not signed in. }
        "503": { description: Web calling unavailable. }
    post:
      summary: Console — create/update the widget embed (origins, enable, rotate)
      description: >
        allowed_origins is an exact scheme+host[:port] allow-list (no paths,
        max 20). rotate=true mints a fresh key — every pasted snippet with
        the old key dies immediately.
      security: []
      parameters:
        - name: agent_id
          in: path
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [allowed_origins]
              properties:
                allowed_origins:
                  type: array
                  items: { type: string }
                enabled: { type: boolean, default: true }
                rotate:  { type: boolean, default: false }
      responses:
        "200": { description: "{embed: {embed_key, allowed_origins, enabled}}" }
        "400": { description: Invalid origins. }
        "401": { description: Not signed in. }
        "404": { description: Agent not found (or not this tenant's). }
        "503": { description: Web calling unavailable. }

  /auth/signup:
    post:
      summary: Console self-serve signup (creates a tenant + first user + session)
      description: >
        The web console's signup (Phase 1). Creates a NEW TENANT through the
        Layer-0 no-oversubscription guard (capacity-honest — a full deployed
        ceiling returns 409 with an "at capacity" message, never a silent
        overcommit), its first user (PBKDF2-hashed password), and a session
        delivered as an httpOnly cookie. Cookie-session auth is the CONSOLE
        plane only; the machine API's X-API-Key auth is unchanged.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [company, email, password]
              properties:
                company:  { type: string, description: becomes the tenant name }
                email:    { type: string, format: email }
                password: { type: string, minLength: 8 }
      responses:
        "201": { description: Tenant + user created; session cookie set. }
        "400": { description: Missing/invalid fields. }
        "409": { description: Duplicate email/company, or deployed capacity full. }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: Console auth unavailable (tables not migrated). }

  /auth/login:
    post:
      summary: Console login (session cookie)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:    { type: string, format: email }
                password: { type: string }
      responses:
        "200": { description: Session cookie set. }
        "401": { description: Invalid email or password. }
        "429": { $ref: "#/components/responses/RateLimited" }
        "503": { description: Console auth unavailable. }

  /auth/logout:
    post:
      summary: Console logout (revokes the session server-side)
      security: []
      responses:
        "200": { description: Session revoked; cookie cleared. }

  /auth/methods:
    get:
      summary: Which sign-in methods this deployment offers
      description: >
        `{password, google}`. The console shows the "Continue with Google"
        button only when `google` is true (it is dark unless Google OAuth is
        configured — on-prem/air-gapped deployments never reach Google).
      security: []
      responses:
        "200": { description: "{password: bool, google: bool}" }

  /auth/google/start:
    get:
      summary: Begin "Continue with Google" (redirects to Google)
      description: >
        302 to Google's consent screen with a CSRF `state` also set as a
        short-lived cookie. 404 when Google sign-in isn't configured.
      security: []
      responses:
        "302": { description: Redirect to Google's consent screen. }
        "404": { description: Google sign-in not enabled on this deployment. }

  /auth/google/callback:
    get:
      summary: Google OAuth redirect target (find-or-create + session)
      description: >
        Google redirects here with `code` + `state`. The gateway verifies the
        state, exchanges the code, reads the verified email, finds or creates
        the account, mints a console session cookie, and 302s to the console.
        Any failure redirects to /login?error=google (never a 500 page).
      security: []
      parameters:
        - { name: code, in: query, required: false, schema: { type: string } }
        - { name: state, in: query, required: false, schema: { type: string } }
      responses:
        "302": { description: Redirect to the console (signed in) or /login on failure. }

  /auth/verify:
    post:
      summary: Confirm an email address (from the emailed link's token)
      description: >
        One-shot: consumes the verification token, marks the account verified,
        and triggers the welcome email. Tokens are single-use and expire in
        24 hours.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [token], properties: { token: { type: string } } }
      responses:
        "200": { description: Email confirmed. }
        "400": { description: Token invalid, expired, or already used. }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/verify/resend:
    post:
      summary: Resend the verification email (signed-in user)
      security: []
      responses:
        "200": { description: Always 200 (no-op if already verified). }
        "401": { description: Not signed in. }

  /auth/password/forgot:
    post:
      summary: Start a password reset (emails a one-hour, one-shot link)
      description: >
        ALWAYS returns 200 and never reveals whether an email has an account
        (no user enumeration). A reset link is emailed only if the account
        exists.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, required: [email], properties: { email: { type: string, format: email } } }
      responses:
        "200": { description: If the account exists, a reset link was sent. }
        "429": { $ref: "#/components/responses/RateLimited" }

  /auth/password/reset:
    post:
      summary: Complete a password reset (consumes the token, revokes sessions)
      description: >
        Sets the new password and revokes EVERY live session for that user (a
        reset is a security event). Token is single-use, one-hour.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token: { type: string }
                password: { type: string, minLength: 8 }
      responses:
        "200": { description: Password changed; all sessions revoked. }
        "400": { description: Token invalid/expired, or password too short. }
        "429": { $ref: "#/components/responses/RateLimited" }

  /me:
    get:
      summary: Console session context — user + tenant + limits
      description: >
        The console shell's context read. Every screen scopes its data by THIS
        tenant — the Layer-0 isolation boundary enforced per logged-in user.
        Authenticated by the session cookie, not X-API-Key.
      security: []
      responses:
        "200":
          description: The signed-in user's context.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Me'
        "401": { description: Not signed in. }
        "403": { description: Tenant suspended. }

  /console/agents:
    get:
      summary: Console — list this workspace's agents (session-authed)
      description: >
        Phase 2 Agent Builder data plane. Session cookie auth; every row is
        scoped to the session's tenant — the SAME agent_configs records a live
        call resolves (L1), no console-only shadow model.
      security: []
      responses:
        "200":
          description: The workspace's agent configs.
          content:
            application/json:
              schema:
                type: object
                properties:
                  agents:
                    type: array
                    items: { $ref: '#/components/schemas/AgentConfig' }
        "401": { description: Not signed in. }
    post:
      summary: Console — create an agent in this workspace
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:                   { type: string }
                language:               { type: string, enum: [ar, da, de, el, en, es, fi, fr, he, hi, it, ja, ko, ms, nl, "no", pl, pt, ru, sv, sw, sl, tr, ur, zh] }
                voice_id:               { type: string }
                system_prompt:          { type: string }
                greeting_override:      { type: string }
                tools:
                  type: array
                  items: { type: string, enum: [transfer, lookup, booking] }
                transfer_target_number: { type: string }
                lookup_webhook_url:     { type: string }
      responses:
        "201":
          description: Created (the stored record).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentConfig' }
        "400": { description: Invalid fields. }
        "401": { description: Not signed in. }

  /console/agents/{agent_id}:
    get:
      summary: Console — read one agent (tenant-scoped; foreign ids are 404)
      security: []
      parameters:
        - { name: agent_id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The agent config.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentConfig' }
        "404": { description: Not found in this workspace. }
    put:
      summary: Console — update one agent (empty string clears an override)
      security: []
      parameters:
        - { name: agent_id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          description: The updated record.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AgentConfig' }
        "400": { description: Invalid fields. }
        "404": { description: Not found in this workspace. }

  /console/voices:
    get:
      summary: Console — voice-picker catalog (presets + languages + cloning state)
      description: >
        A test-locked mirror of the tts preset catalog, served without waking
        the GPU pool. `cloning.available` is honestly false until per-workspace
        voice-storage isolation ships (deploy/S3_TENANT_ISOLATION.md).
      security: []
      responses:
        "200": { description: Voices, languages, and the cloning gate state. }
        "401": { description: Not signed in. }

  /console/voices/preview:
    post:
      summary: Console — synthesize a short sample of a voice (play in the builder)
      description: >
        Session-authed voice SAMPLE. Synthesizes a short greeting in the given
        `voice_id` + `language` so the agent builder can PLAY it before saving.
        Returns raw `audio/wav`. Presets are global; a cloned voice resolves in
        the caller's workspace namespace. Unmetered — a tiny product-surface
        sample, not billable usage.
      security: []
      responses:
        "200": { description: A short WAV sample of the voice. }
        "401": { description: Not signed in. }
        "502": { description: Voice preview temporarily unavailable. }

  /console/studio/noema:
    post:
      summary: Noema Studio — one named language task over your text
      description: >
        TASK BUTTONS, never an open chat box: a fixed menu (summarize,
        translate, extract, rewrite) is useful, priced FLAT per task, and
        un-jailbreakable by construction. Charged only on success.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task, text]
              properties:
                task:            { type: string, enum: [summarize, translate, extract, rewrite, show_notes, chapters, titles, social] }
                text:            { type: string, maxLength: 40000 }
                target_language: { type: string }
      responses:
        "200": { description: task + result + credits charged. }
        "400": { description: Unknown task, empty text, or over the limit. }
        "402": { description: No active plan. }

  /console/studio/voices:
    get:
      summary: Melo Studio — this workspace's cloned voices
      security: []
      responses:
        "200": { description: Cloned voices for this tenant. }
    post:
      summary: Melo Studio — clone a voice from a consented sample
      description: >
        CONSENT IS A HARD GATE (CLAUDE.md §7, "no exceptions"): refused
        without consent_attested, and the attestation is recorded with the
        voice. Cloning a voice you do not have permission to use is how a
        creator tool becomes a deepfake story — the gate is the feature.
        Metered at the platform voice_clone rate; the voice lands in this
        tenant's namespace only.
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, name, consent_attested]
              properties:
                file:             { type: string, format: binary }
                name:             { type: string }
                language:         { type: string }
                consent_attested: { type: boolean }
      responses:
        "200": { description: voice_id of the cloned voice. }
        "400": { description: Missing consent, sample or name. }
        "402": { description: No active plan. }
        "413": { description: Sample over the size limit. }

  /console/studio/transcribe:
    post:
      summary: Percept Studio — upload audio, get a job id (async)
      description: >
        A creator's file is an audiobook or a podcast, not a call turn: the
        upload is staged to object storage and a worker transcribes it BEHIND
        live calls (one job at a time per replica), so a queued file never
        makes a caller wait. Returns immediately with a job id.
      security: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:     { type: string, format: binary }
                language: { type: string }
      responses:
        "200": { description: job_id + status. }
        "400": { description: No audio file. }
        "402": { description: No active plan. }
        "413": { description: Over the studio upload limit. }
        "503": { description: Studio transcription not enabled here. }

  /console/studio/jobs:
    get:
      summary: Percept Studio — this workspace's transcription jobs
      security: []
      responses:
        "200": { description: Recent jobs with status. }

  /console/studio/jobs/{job_id}:
    get:
      summary: Percept Studio — one job; format=srt|txt exports subtitles/text
      security: []
      parameters:
        - in: path
          name: job_id
          required: true
          schema: { type: string }
        - in: query
          name: format
          schema: { type: string, enum: [json, srt, vtt, txt] }
        - in: query
          name: lang
          description: >-
            Return a localised subtitle track instead of the original
            transcript (see POST .../translate). An unknown language 404s
            rather than silently returning the source language.
          schema: { type: string }
      responses:
        "200": { description: Job JSON, or an SRT/VTT/TXT attachment. }
        "404": { description: Not this workspace's job, or no such translation. }

    delete:
      summary: Percept Studio — forget a transcript and the audio behind it
      description: >-
        Deletes the job row and the uploaded audio object. Table stakes for
        confidential recordings, and consistent with the promise that a
        customer's recordings stay theirs. The object delete is best-effort:
        if the store refuses, the response says so rather than implying the
        audio is gone.
      security: []
      parameters:
        - in: path
          name: job_id
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  audio_deleted: { type: boolean }
        "404": { description: Not this workspace's job. }

  /console/studio/jobs/{job_id}/translate:
    post:
      summary: >-
        Percept Studio — translate a finished transcript into another language
        WITH its timings, for subtitles
      description: >-
        Whisper's own translate task only ever emits English, and translating a
        transcript as one blob loses the segment boundaries. Segments are
        translated in numbered batches and mapped back by position; a batch
        whose reply is missing a line is refused rather than written, because
        subtitles shifted onto the wrong timings are worse than none. Metered
        per batch on the noema_tasks dial, on success only.
      security: []
      parameters:
        - in: path
          name: job_id
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_language]
              properties:
                target_language: { type: string, maxLength: 40 }
      responses:
        "200":
          description: How much was translated, and what it cost.
          content:
            application/json:
              schema:
                type: object
                properties:
                  language: { type: string }
                  segments: { type: integer }
                  of_segments: { type: integer }
                  batches: { type: integer }
                  credits: { type: integer }
        "400": { description: target_language missing. }
        "404": { description: Not this workspace's job. }
        "409": { description: Transcript unfinished, or it has no timed segments. }
        "502": { description: The language service returned nothing usable. }

  /console/studio/tts:
    post:
      summary: Melo Studio — render text to a downloadable WAV (creators)
      description: >
        Session-authed console TTS for creators: whole-utterance synthesis
        (accuracy over latency), character-capped so a render can never
        starve the live call fleet, metered on its own studio dial
        (studio_tts_minutes), never the call rate.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:     { type: string, maxLength: 5000 }
                language: { type: string }
                voice_id: { type: string, nullable: true }
                speed:    { type: number, nullable: true }
      responses:
        "200": { description: audio/wav attachment. }
        "400": { description: Empty text or over the character cap. }
        "402": { description: No active plan. }

  /console/numbers:
    get:
      summary: Console — this workspace's phone numbers and their agent routing
      security: []
      responses:
        "200": { description: "numbers: [{did, agent_config_id}]" }
        "401": { description: Not signed in. }

  /console/numbers/assign:
    post:
      summary: Console — point one of the workspace's numbers at one of its agents
      description: >
        Ownership of BOTH the number and the agent is enforced in the SQL
        predicates — a foreign DID or agent id is a 404, never a cross-tenant
        write. Ordering NEW numbers is Phase 3.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [did]
              properties:
                did:             { type: string }
                agent_config_id: { type: integer, nullable: true, description: null un-assigns }
      responses:
        "200": { description: Routed. }
        "404": { description: Number or agent not found in this workspace. }

  /console/outbound:
    get:
      summary: Console — outbound batches and the pacing facts (O1)
      description: >
        Recent outbound rows plus the plan's line count, so the tab can
        explain WHY requests queue — outbound runs at the concurrency the
        plan bought, never more; the rest drains as calls end.
      security: []
      responses:
        "200": { description: Rows + plan_lines + queued count. }
        "503": { description: Outbound calling is not enabled on this deployment. }
    post:
      summary: Console — queue an outbound batch (plan-paced, consent-gated)
      description: >
        One row per recipient. Refused without consent_attested — outbound
        robocalling is regulated (TCPA / ePrivacy / DNC) and the attestation
        is stored on every row. Caller ID (from_did) must be one of the
        workspace's OWN numbers; recipients must be full international
        E.164. The batch runs at the plan's concurrent-line count; anything
        beyond queues and dials as slots free.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agent_config_id, from_did, numbers, consent_attested]
              properties:
                agent_config_id:  { type: integer }
                from_did:         { type: string }
                numbers:
                  type: array
                  maxItems: 500
                  items: { type: string }
                consent_attested: { type: boolean }
      responses:
        "200": { description: batch_id + queued count + plan_lines. }
        "400": { description: Missing consent, foreign from_did, or invalid numbers. }
        "402": { description: No active plan. }
        "404": { description: Agent not found in this workspace. }
        "503": { description: Outbound calling is not enabled on this deployment. }

  /console/outbound/cancel:
    post:
      summary: Console — cancel queued outbound rows (O1)
      description: >
        QUEUED rows only — a dialing or live call is never yanked back.
        By batch_id or explicit ids.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                batch_id: { type: string }
                ids:
                  type: array
                  items: { type: integer }
      responses:
        "200": { description: Count of rows canceled. }

  /console/outbound/suppression:
    get:
      summary: Console — the workspace's do-not-call list (O2)
      security: []
      responses:
        "200": { description: Suppressed numbers with source + date. }
    post:
      summary: Console — add or remove one do-not-call number (O2)
      description: >
        Additions are honored at claim time, so they cover batches already
        queued. The agent's mid-call opt-out tool writes the same list.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [number]
              properties:
                number: { type: string }
                remove: { type: boolean }
      responses:
        "200": { description: Suppressed or removed. }
        "400": { description: Not an E.164 number. }

  /outbound/opt-out:
    post:
      summary: Internal — the agent's mid-call do-not-call (platform key)
      description: >
        The room name identifies the outbound row; the gateway resolves
        tenant + number itself (the agent is never trusted with either) and
        writes the tenant's suppression list. 404 for a non-outbound room.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [room]
              properties:
                room: { type: string }
      responses:
        "200": { description: Number suppressed. }
        "404": { description: Not an outbound call. }

  /console/telephony:
    get:
      summary: Console — interconnect facts + provider matrix (Phase 3)
      description: >
        What the customer's NOC needs about OUR side of a BYO trunk (a
        drift-locked mirror of deploy/SIP_PROVIDER_REQUIREMENTS.md §1 stamped
        values) plus the honest provider matrix — Telnyx managed (live), BYO
        (live, config-only), Twilio (coming soon, deliberately unbuilt until a
        Twilio-shop pilot needs it).
      security: []
      responses:
        "200": { description: interconnect + providers }
        "401": { description: Not signed in. }

  /console/numbers/requests:
    get:
      summary: Console — this workspace's number requests + status
      security: []
      responses:
        "200": { description: "requests: [{id, kind, details, status, created_at}]" }
        "401": { description: Not signed in. }
    post:
      summary: Console — request a number (managed) or trunk connect (BYO)
      description: >
        HONEST self-serve: the platform holds no carrier API credential, so
        this files a request the operator fulfils (orders the DID / wires the
        trunk, then tenant_admin fulfill-number-request maps it). The UI shows
        "being provisioned" — never a fake instant number.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind, details]
              properties:
                kind: { type: string, enum: [managed, byo] }
                details:
                  type: object
                  description: >
                    managed: {country, notes}; byo: {provider, signaling_ips,
                    media_ips, dids (required), auth_model, notes} — strings,
                    stored verbatim for the operator.
      responses:
        "201": { description: Request filed (pending). }
        "400": { description: Invalid kind/fields. }
        "401": { description: Not signed in. }

  /calls/log:
    post:
      summary: Machine — agent call-lifecycle report (started | ended | rejected_busy)
      description: >
        The agent (X-API-Key auth) reports call lifecycle events; the gateway
        writes the tenant-attributed calls index the console reads. The R1 S3
        artifact remains the durable raw record — this is dual-write, and the
        agent fires best-effort (a failure here never affects a live call).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [event, call_id]
              properties:
                event:      { type: string, enum: [started, ended, rejected_busy] }
                call_id:    { type: string }
                tenant_id:  { type: integer, nullable: true }
                did:        { type: string }
                agent_name: { type: string }
                caller:     { type: string, nullable: true, description: "caller's own number (SIP CLI); null/absent for web calls" }
                reason:     { type: string, enum: [tenant_cap, fleet_cap], description: "rejected_busy only — WHOSE limit turned the caller away: tenant_cap (their lines, the upsell signal) or fleet_cap (our deployed fleet was full). Absent = tenant_cap." }
                transcript: { type: array, items: { type: object } }
                hops:       { type: object }
      responses:
        "200": { description: Recorded. }
        "400": { description: Invalid event/fields. }
        "503": { description: Calls store unavailable (agent ignores). }

  /console/calls:
    get:
      summary: Console — this workspace's call log (newest first)
      security: []
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200": { description: "calls: [{call_id, did, agent_name, caller, channel (web|phone), outcome (completed|rejected_busy|rejected_capacity), started_at, ended_at, duration_s, turns, preview, live, unclean}]" }
        "401": { description: Not signed in. }

  /console/calls/{call_id}:
    get:
      summary: Console — one call's transcript + timing (tenant-scoped; foreign ids 404)
      security: []
      parameters:
        - { name: call_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The call record incl. transcript turns and hop timings. }
        "404": { description: Not found in this workspace. }

  /console/calls/{call_id}/audio:
    get:
      summary: Console — the call's audio recording (mixed mono WAV; tenant-scoped)
      security: []
      parameters:
        - { name: call_id, in: path, required: true, schema: { type: string } }
        - { name: download, in: query, schema: { type: integer, default: 0 }, description: "1 = Content-Disposition attachment (the download button)" }
      responses:
        "200": { description: "audio/wav — both sides mixed, 16 kHz mono. Capture caps at 30 min." }
        "404": { description: No recording (older call, instant hangup, or foreign id). }

  /console/numbers/available:
    get:
      summary: Console — numbers this workspace could take, and what its plan allows
      description: >
        READ-ONLY search; never reserves or charges. `instant` is false for
        countries where the carrier requires a verified local address first
        (most of the EU) — those go through /console/numbers/requests instead.
        `purchase_armed` reflects the operator gate: false means no number can
        be bought on this deployment yet, whatever the console shows.
      security: []
      parameters:
        - { name: country, in: query, schema: { type: string, default: US } }
        - { name: area_code, in: query, schema: { type: string } }
      responses:
        "200": { description: "{country, instant, provider_enabled, purchase_armed, included_with_plan, numbers_held, remaining, available: [{did, region, monthly_cost, currency}]}" }
        "401": { description: Not signed in. }

  /console/numbers/claims:
    get:
      summary: Console — numbers this workspace has registered (bring your own)
      security: []
      responses:
        "200": { description: "claims: [{id, did, status (awaiting_call|verified|conflict), created_at, verified_at}]" }
    post:
      summary: Console — register a number you already own; returns what to configure
      description: >
        No documents and no queue. Only the owner of a number can point its
        SIP trunk at us, so the FIRST inbound call from that number proves
        ownership AND switches it on — the customer's test call and their
        go-live are the same event. A number two workspaces claim verifies for
        neither (that is exactly when a human should look).
      security: []
      responses:
        "200": { description: "{id, did, status, sip_uri, transport, instructions}" }
        "400": { description: Not a valid E.164 number. }
        "409": { description: Already in use on the platform, or plan allowance reached. }

  /console/numbers/verification:
    get:
      summary: Console — number verifications for regulated countries, and their status
      security: []
      responses:
        "200": { description: "verifications: [{id, country, group_id, status, did_wanted, ordered_did, last_error, created_at}]" }
    post:
      summary: Console — start verification; returns the carrier's required fields
      description: >
        Most of the EU requires carrier-approved documents before a number is
        issued. The requirements are DISCOVERED from the carrier per request,
        never hardcoded, and returned as the form to render. Subject to the
        same plan number allowance as an instant number.
      security: []
      responses:
        "200": { description: "{group_id, country, status, requirements: [{requirement_id, ...}]}" }
        "409": { description: Plan number allowance reached. }
        "503": { description: Provisioning not configured on this deployment. }

  /console/numbers/verification/{group_id}/documents:
    post:
      summary: Console — upload one proof document (streamed to the carrier, not stored here)
      security: []
      parameters:
        - { name: group_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{document_id}" }
        "413": { description: Over 10 MB. }

  /console/numbers/verification/{group_id}/submit:
    post:
      summary: Console — fill the requirements and submit to the carrier (free; not a purchase)
      security: []
      parameters:
        - { name: group_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: "{group_id, status}" }
        "404": { description: Not this workspace's verification. }

  /telnyx/webhook:
    post:
      summary: Machine — carrier verification decisions; orders and assigns the number on approval
      description: >
        Never trusts the payload beyond a lookup: the group id must already
        exist locally, and the number ordered is the one WE recorded, never one
        named in the request. Purchasing remains behind its own operator gate.
      security: []
      responses:
        "200": { description: Acknowledged (always, so the carrier stops retrying). }

  /console/live:
    get:
      summary: Console — live calls vs line limit + 24h busy count (real-time line slots)
      security: []
      responses:
        "200": { description: "{live, concurrency_limit, busy_24h, capacity_24h} — busy_24h is the tenant's OWN cap-hits (the upsell signal); capacity_24h is platform-side (fleet full), never billed as their shortage." }
        "401": { description: Not signed in. }

  /console/usage:
    get:
      summary: Console — daily usage (calls, connected seconds, busy rejections), 30 days
      security: []
      responses:
        "200": { description: "daily: [{day, calls, seconds, busy}]" }
        "401": { description: Not signed in. }

  /console/keys:
    get:
      summary: Console — this workspace's API keys (prefix + metadata, never secrets)
      security: []
      responses:
        "200": { description: "keys: [{id, key_prefix, note, active, usage_count, created_at, last_used_at}]" }
        "401": { description: Not signed in. }
    post:
      summary: Console — mint an API key (the show-once ceremony)
      description: >
        The raw key exists in this one response and nowhere else — the store
        keeps only its SHA-256 hash; it is never retrievable again. Revocation
        deactivates (never deletes) so billing attribution survives.
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string, maxLength: 200, description: optional label }
      responses:
        "201": { description: "{key (SHOWN ONCE), prefix, shown_once: true}" }
        "401": { description: Not signed in. }

  /console/keys/{key_id}/revoke:
    post:
      summary: Console — revoke (deactivate) one of this workspace's keys
      security: []
      parameters:
        - { name: key_id, in: path, required: true, schema: { type: integer } }
      responses:
        "200": { description: Revoked. }
        "404": { description: Key not found in this workspace. }

  /console/billing:
    get:
      summary: Console — invoice-shaped monthly usage (no prices, no payment UI)
      description: >
        The billing view's data (binding 2): the tenant-attributed calls table
        is the billing source of truth; S3 call artifacts are the
        reconciliation record; discrepancies resolve by undercounting in the
        customer's favor — stated in the on-screen fine print. Rates live in
        the customer's agreement, not in this API; there is no payment method
        on the platform yet (binding 3 — shown honestly as coming soon).
      security: []
      responses:
        "200":
          description: >
            {plan, subscription_status, current_period_end, trial_ends_at,
            credit_balance, enforcement: {state: ok|warning|blocked, reason},
            billing_enabled, months: [{month, calls, seconds, busy}],
            billing_basis, notes} — `enforcement` is the same verdict the
            API/voice gates apply (Phase 4), so the console can explain a
            block; the console itself is never gated.
        "401": { description: Not signed in. }

  /billing/checkout:
    post:
      summary: Console — start a subscription checkout (Stripe-hosted)
      description: >
        Creates a Stripe Checkout session for the chosen v3.1 plan and returns
        its URL; the browser navigates to Stripe's hosted page (card data never
        touches Cervana — PCI SAQ-A). A tenant's FIRST subscription carries the
        14-day card-upfront trial (guardrail G-6). Session-cookie authed. 503
        on deployments without billing configured (on-prem licence model).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [plan]
              properties:
                plan: { type: string, enum: [starter, growth, scale] }
      responses:
        "200":
          description: Checkout session created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: Stripe-hosted Checkout URL }
        "401": { description: Not signed in. }
        "503": { description: Billing not enabled on this deployment/phase. }

  /billing/portal:
    get:
      summary: Console — Stripe customer-portal link
      description: >
        Returns a Stripe-hosted customer-portal URL (payment method, invoices,
        cancel at period end). Session-cookie authed. 503 where billing is not
        configured (on-prem licence model).
      security: []
      responses:
        "200":
          description: Portal session created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        "401": { description: Not signed in. }
        "503": { description: Billing not enabled on this deployment/phase. }

  /billing/webhook:
    post:
      summary: Stripe webhook receiver (signature-authenticated)
      description: >
        Called by Stripe only. Authentication is the Stripe-Signature header
        verified against the webhook signing secret — mandatory; unsigned or
        tampered payloads are rejected 400. Replayed event ids are detected via
        the processed-events table and acknowledged without reprocessing.
      security: []
      responses:
        "200": { description: Event received (duplicates acknowledged idempotently). }
        "400": { description: Invalid or missing signature. }
        "503": { description: Billing webhooks not configured. }

  /health:
    get:
      summary: Liveness
      security: []
      responses:
        "200":
          description: OK
  /readyz:
    get:
      summary: Readiness — 200 only when the service's dependencies are ready
        (gateway gates on its auth backend / RDS key store, D32). Wired to the
        pod readinessProbe, not liveness.
      security: []
      responses:
        "200":
          description: Ready
        "503":
          description: Not ready (dependency unavailable)
