openapi: 3.1.0
info:
  title: Wind API
  version: 0.1.0
  description: >
    Metered billing gateway for AI. Money is integer micro-cents, USD
    (100_000_000 mc = $1.00; 1¢ = 1_000_000 mc). This spec is the source of truth for the API
    reference, the SDKs, and API tests.
servers:
  - url: https://api.usewind.app
    description: All server-to-server calls
  - url: https://accounts.usewind.app
    description: Browser OAuth surface (authorize + consent)

tags:
  - name: OAuth
  - name: Inference
  - name: Voice
  - name: Connections
  - name: Account
  - name: Features
  - name: Webhooks

components:
  securitySchemes:
    clientBasic:
      type: http
      scheme: basic
      description: HTTP Basic with client_id as user and client_secret as password.
    connectionBearer:
      type: http
      scheme: bearer
      description: A connection access token. The token *is* the connection.
    realtimeTicket:
      type: http
      scheme: bearer
      description: >
        A voice session's single-use `rtt_…` ticket (from `client.ticket` on
        POST /v1/realtime/sessions). Valid for one offer exchange, about 60
        seconds. Safe to hand to the browser — it can do nothing else.
  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: allocation_exhausted }
            message: { type: string }
            type: { type: string, example: insufficient_funds }
            connection_id: { type: string, nullable: true }
            increase_url: { type: string, nullable: true }
            review_url:
              type: string
              nullable: true
              description: >
                Set on connection_frozen: a dashboard page where the user can resume
                the connection, allow it to spend faster (up to 3×), or revoke it.
                Null otherwise.
    ChatTool:
      type: object
      required: [type, function]
      properties:
        type: { type: string, enum: [function] }
        function:
          type: object
          required: [name]
          properties:
            name: { type: string, pattern: "^[a-zA-Z0-9_-]{1,64}$" }
            description: { type: string }
            parameters: { type: object, description: JSON Schema for the arguments. }
    WindBlock:
      type: object
      properties:
        feature: { type: string, nullable: true }
        hold_amount_mc: { type: integer, description: The pre-settlement estimate this call was authorized against. }
        provider_cost_mc: { type: integer }
        wind_fee_mc: { type: integer }
        dev_margin_mc: { type: integer }
        user_charge_mc: { type: integer }
        allocation_remaining_mc: { type: integer }
    LedgerEntry:
      type: object
      description: >
        Immutable, append-only. Covers every balance movement on an account —
        spend and funding both. A reversal is always a new row (reversal_of
        pointing at the original); the original is never edited in place.
      properties:
        id: { type: string, example: led_9f8e }
        type: { type: string, enum: [spend, topup] }
        account_id: { type: string, example: wac_owner }
        created_at: { type: string, format: date-time }
        status: { type: string, enum: [settled, reversed, pending], description: pending is topup-only, awaiting Stripe clearance. }
        reversal_of: { type: string, nullable: true }
        reason: { type: string, nullable: true, description: Set only on reversed rows, e.g. provider_error. }
        # type: spend
        connection_id: { type: string, nullable: true }
        app_id: { type: string, nullable: true }
        feature: { type: string, nullable: true }
        model: { type: string, nullable: true }
        hold_amount_mc: { type: integer, nullable: true }
        provider_cost_mc: { type: integer, nullable: true }
        wind_fee_mc: { type: integer, nullable: true }
        dev_margin_mc: { type: integer, nullable: true }
        user_charge_mc: { type: integer, nullable: true }
        request_id: { type: string, nullable: true }
        # type: topup
        amount_mc: { type: integer, nullable: true, description: Credited to balance_available_mc. }
        stripe_payment_intent: { type: string, nullable: true }
        hold_until: { type: string, format: date-time, nullable: true, description: Payment-clearance hold — distinct from a spend-side authorization hold. }
    TokenResponse:
      type: object
      properties:
        access_token: { type: string }
        expires_in: { type: integer, example: 900 }
        refresh_token: { type: string, description: Rotates on every use. }
        connection_id: { type: string, example: conn_1a2b }
        allocation_mc: { type: integer }
    Connection:
      type: object
      properties:
        id: { type: string, example: conn_1a2b }
        app_id: { type: string }
        user_account_id: { type: string }
        reserved_allocation_mc: { type: integer }
        consumed_mc: { type: integer }
        held_mc: { type: integer }
        velocity_limit_mc: { type: integer, nullable: true }
        status: { type: string, enum: [active, exhausted, revoked, frozen] }
        created_at: { type: string, format: date-time }
        recent_ledger:
          type: array
          items: { $ref: "#/components/schemas/LedgerEntry" }
          description: Only present on GET /v1/connections/{id}, not the list endpoint.
    Feature:
      type: object
      required: [slug, display_name, description]
      properties:
        slug: { type: string, pattern: "^[a-z0-9-]{1,40}$" }
        display_name: { type: string }
        description: { type: string }
        max_cost_per_call_mc: { type: integer, nullable: true }
        max_tokens: { type: integer, nullable: true }
        rate_limit: { type: integer, nullable: true }
        default_model:
          type: string
          nullable: true
          description: >
            SDK-only sugar on POST /v1/ai/{model} (path model still wins there).
            Server-authoritative on POST /v1/ai/features/{slug} — required for
            that route to work at all.
        dev_margin_rate:
          type: number
          nullable: true
          description: >
            Overrides App.dev_margin_rate for calls tagged with this feature
            (either route). Null falls back to the app-wide rate. Unregistered
            feature slugs on POST /v1/ai/{model} always use the app-wide rate —
            pricing customization requires registering the feature.
    Account:
      type: object
      properties:
        wac_id: { type: string, example: wac_owner }
        email: { type: string }
        sandbox: { type: boolean }
        balance_available_mc: { type: integer }
        balance_reserved_mc: { type: integer }

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string }
        app_id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          description: Subscribed event types. Empty = every event.
          items:
            type: string
            enum: [connection.created, connection.exhausted, connection.revoked, connection.frozen, connection.resumed, connection.risk_warning, allocation_change.requested, allocation_change.approved, allocation_change.denied, app.spend_limit_reached, request.settled, realtime_session.ended]
        status: { type: string, enum: [active, disabled] }
        created_at: { type: string, format: date-time }
        secret:
          type: string
          description: whsec_… signing secret. Returned only on create and rotate-secret.
    RealtimeReceipt:
      type: object
      description: One billed model reply in a voice session — one ledger entry each.
      properties:
        id: { type: string, example: led_9f8e }
        created_at: { type: string, format: date-time }
        usage_source:
          type: string
          enum: [provider, estimated]
          description: >
            estimated when the user's browser cut the reply off before the provider
            reported usage — Wind's estimate, rounded down and capped at the reply's hold.
        input_tokens: { type: integer }
        output_tokens: { type: integer }
        usage_detail: { type: object, description: Per-modality token counts (text, audio, cached). }
        provider_cost_mc: { type: integer }
        wind_fee_mc: { type: integer }
        dev_margin_mc: { type: integer }
        user_charge_mc: { type: integer }
    RealtimeSession:
      type: object
      properties:
        id: { type: string, example: rts_4c1d }
        status: { type: string, enum: [pending, live, ended] }
        model: { type: string, example: gpt-realtime-2.1 }
        feature: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        ended_at: { type: string, format: date-time, nullable: true }
        end_reason:
          type: string
          nullable: true
          description: >
            client_closed, app_ended, max_duration, max_cost, idle_timeout,
            budget_not_approved, control_lost, config_tampering, sideband_lost,
            provider_error, ticket_expired, connection_revoked, connection_frozen,
            server_shutdown, or a hold error code such as allocation_exhausted.
        max_duration_s: { type: integer }
        max_cost_mc: { type: integer, nullable: true }
        response_count: { type: integer }
        charged_mc: { type: integer }
        live:
          type: object
          nullable: true
          description: Present while the session is live.
          properties:
            charged_mc: { type: integer }
            response_count: { type: integer }
            ending: { type: boolean }
            control_attached: { type: boolean }
        receipts:
          type: array
          items: { $ref: "#/components/schemas/RealtimeReceipt" }
    WebhookDelivery:
      type: object
      properties:
        id: { type: string }
        event_id: { type: string, description: "evt_… — also sent as the Wind-Event-Id header" }
        event_type: { type: string }
        status: { type: string, enum: [pending, delivered, failed] }
        attempts: { type: integer }
        next_attempt_at: { type: string, format: date-time, nullable: true }
        last_error: { type: string, nullable: true }
        response_status: { type: integer, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

paths:
  /oauth/authorize:
    get:
      tags: [OAuth]
      summary: Authorization request (browser redirect)
      description: >
        Served from accounts.usewind.app. Redirect the user's browser here to
        start the connect flow; Wind redirects back to redirect_uri with ?code.
      parameters:
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string } }
        - { name: code_challenge, in: query, required: true, schema: { type: string } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
        - { name: state, in: query, required: true, schema: { type: string } }
        - { name: requested_allocation_mc, in: query, required: false, schema: { type: integer } }
        - { name: features, in: query, required: false, schema: { type: string }, description: comma-separated feature slugs }
      responses:
        "302": { description: Redirect to redirect_uri with code and state. }

  /oauth/token:
    post:
      tags: [OAuth]
      summary: Exchange a code or refresh token for tokens
      security: [{ clientBasic: [] }]
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                code: { type: string }
                redirect_uri: { type: string }
                code_verifier: { type: string }
                refresh_token: { type: string }
      responses:
        "200":
          description: Tokens
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          description: Invalid grant
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/models:
    get:
      tags: [Inference]
      summary: Models Wind offers
      description: >
        Every model an app can call (or restrict itself to with allowed_models), with what Wind pays
        the provider per 1k tokens (users pay this plus Wind's fee and your
        margin). No auth. The list changes as Wind adds and retires models.
      security: []
      responses:
        "200":
          description: Models
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, example: claude-sonnet-5-5 }
                    provider: { type: string, enum: [openai, anthropic, google] }
                    input_cost_per_1k_usd: { type: string, example: "0.002000" }
                    output_cost_per_1k_usd: { type: string, example: "0.010000" }

  /v1/ai/{model}:
    post:
      tags: [Inference]
      summary: Billed inference call
      security: [{ connectionBearer: [] }]
      parameters:
        - name: model
          in: path
          required: true
          schema: { type: string, example: gpt-4o }
          description: Any model Wind offers, or one in the app's allowed_models if it sets any.
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string, format: uuid } }
        - { name: X-Wind-Feature, in: header, required: false, schema: { type: string }, description: A feature slug. }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                messages:
                  type: array
                  items: { type: object }
                  description: >
                    OpenAI-format messages for every provider: system/user/assistant/tool.
                    For a tool round-trip, send the assistant message back unchanged
                    (including any `extra_content` on its `tool_calls` — Gemini needs it),
                    then one `{role: tool, tool_call_id, content}` per call. `image_url`
                    parts may be data: URLs or public http(s) URLs (max 5MB each,
                    20 per request); for Anthropic and Gemini models Wind downloads
                    remote images itself, and an unreachable one is a 400.
                max_tokens: { type: integer, description: Optional; falls back to feature/app/model default. }
                temperature: { type: number }
                tools:
                  type: array
                  maxItems: 128
                  items: { $ref: "#/components/schemas/ChatTool" }
                  description: >
                    Function tools, OpenAI format — Wind maps them onto Anthropic and Gemini.
                    Responses carry OpenAI-shaped `tool_calls` and `finish_reason: tool_calls`
                    whichever provider served the call. In sandbox mock mode the canned
                    model calls the first (or forced) tool with `{}` arguments, then answers
                    in text once the last message is a tool result.
                tool_choice:
                  description: Requires tools.
                  oneOf:
                    - { type: string, enum: [auto, none, required] }
                    - type: object
                      required: [type, function]
                      properties:
                        type: { type: string, enum: [function] }
                        function: { type: object, required: [name], properties: { name: { type: string } } }
                parallel_tool_calls: { type: boolean, description: "Requires tools. Ignored for Gemini models, which have no equivalent." }
                stream: { type: boolean, default: false, description: "When true, the response is text/event-stream — provider chunks relayed as-is, a final `data: {wind_final:{usage,wind}}` event once settlement completes, then data: [DONE]. `wind_final.settlement` is `settled`; or, if the stream ended without the provider's usage, `estimated` (charged on Wind's tokenizer count) or `pending` (`usage` and `wind` null; billed later from the provider's own record, reported by the `request.settled` webhook)." }
      responses:
        "200":
          description: Provider completion plus a wind block
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  choices: { type: array, items: { type: object } }
                  usage: { type: object }
                  wind: { $ref: "#/components/schemas/WindBlock" }
        "402":
          description: allocation_exhausted or feature_cost_exceeded
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: connection_revoked, connection_frozen, app_suspended or model_not_allowed
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: rate_limited, risk_throttled, daily_limit_reached, app_spend_limited or sandbox_live_cost_limited (see Retry-After)
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "502":
          description: provider_error — retry with the same Idempotency-Key, not billed
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/ai/features/{slug}:
    post:
      tags: [Inference]
      summary: Billed inference call, feature-resolved model
      description: >
        Same billing (cost-plus), ledger, and hold mechanism as POST
        /v1/ai/{model} — the only difference is there's no model in the
        request. The feature must be registered with default_model set; that
        model is resolved server-side and, if the app sets allowed_models,
        must be in it. No fallback models, no non-cost-plus pricing (e.g.
        per-minute) — both deferred, not built.
      security: [{ connectionBearer: [] }]
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string, pattern: "^[a-z0-9-]{1,40}$" }
          description: Must be a registered feature with default_model set.
        - { name: Idempotency-Key, in: header, required: true, schema: { type: string, format: uuid } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                messages:
                  type: array
                  items: { type: object }
                  description: >
                    OpenAI-format messages for every provider: system/user/assistant/tool.
                    For a tool round-trip, send the assistant message back unchanged
                    (including any `extra_content` on its `tool_calls` — Gemini needs it),
                    then one `{role: tool, tool_call_id, content}` per call. `image_url`
                    parts may be data: URLs or public http(s) URLs (max 5MB each,
                    20 per request); for Anthropic and Gemini models Wind downloads
                    remote images itself, and an unreachable one is a 400.
                max_tokens: { type: integer, description: Optional; falls back to feature/app/model default. }
                temperature: { type: number }
                tools:
                  type: array
                  maxItems: 128
                  items: { $ref: "#/components/schemas/ChatTool" }
                  description: >
                    Function tools, OpenAI format — Wind maps them onto Anthropic and Gemini.
                    Responses carry OpenAI-shaped `tool_calls` and `finish_reason: tool_calls`
                    whichever provider served the call. In sandbox mock mode the canned
                    model calls the first (or forced) tool with `{}` arguments, then answers
                    in text once the last message is a tool result.
                tool_choice:
                  description: Requires tools.
                  oneOf:
                    - { type: string, enum: [auto, none, required] }
                    - type: object
                      required: [type, function]
                      properties:
                        type: { type: string, enum: [function] }
                        function: { type: object, required: [name], properties: { name: { type: string } } }
                parallel_tool_calls: { type: boolean, description: "Requires tools. Ignored for Gemini models, which have no equivalent." }
                stream: { type: boolean, default: false, description: "When true, the response is text/event-stream — provider chunks relayed as-is, a final `data: {wind_final:{usage,wind}}` event once settlement completes, then data: [DONE]. `wind_final.settlement` is `settled`; or, if the stream ended without the provider's usage, `estimated` (charged on Wind's tokenizer count) or `pending` (`usage` and `wind` null; billed later from the provider's own record, reported by the `request.settled` webhook)." }
      responses:
        "200":
          description: Provider completion plus a wind block
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  choices: { type: array, items: { type: object } }
                  usage: { type: object }
                  wind: { $ref: "#/components/schemas/WindBlock" }
        "400":
          description: feature_model_not_configured — the feature has no default_model set
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "402":
          description: allocation_exhausted or feature_cost_exceeded
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: connection_revoked, connection_frozen, app_suspended or model_not_allowed
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: rate_limited, risk_throttled, daily_limit_reached, app_spend_limited or sandbox_live_cost_limited (see Retry-After)
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "502":
          description: provider_error — retry with the same Idempotency-Key, not billed
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/realtime/sessions:
    post:
      tags: [Voice]
      summary: Create a voice session
      description: |
        Called by your **server** with the connection's access token. Returns a
        `client` object for the user's browser (one single-use offer exchange)
        and a `control_url` for your server.

        Audio then flows browser ↔ OpenAI directly over WebRTC; Wind watches the
        call server-side and bills each model reply as its own call. Live models
        only — mock mode is rejected with 400 invalid_request. The first reply's
        hold is placed now. At most 2 pending or live sessions per connection.

        **Control channel** (`control_url`, WebSocket, header
        `Authorization: Bearer <access_token>`): one JSON event per message.
        App → Wind: `session.update`, `conversation.item.create` (tool results as
        `{type:"conversation.item.create", item:{type:"function_call_output", call_id, output}}`
        then `{type:"response.create"}`), `response.create`, `response.cancel`,
        `conversation.item.truncate`, `wind.session.end` (`when`), and
        `wind.session.approve_block`. Wind → App: relayed OpenAI events
        (`response.function_call_arguments.done`, `response.output_audio_transcript.done`,
        `response.output_text.done`, `input_audio_buffer.speech_started` / `speech_stopped`,
        `response.created`, `error`) plus `wind.session.started`,
        `wind.response.settled`, `wind.session.spend_warning`,
        `wind.session.budget_checkpoint`, `wind.session.config_reset`,
        `wind.session.ending` and `wind.session.ended`, after which the socket
        closes. Close codes: 4004 session not found, 4009 session not live.
      security: [{ connectionBearer: [] }]
      x-wind-websocket:
        url: wss://api.usewind.app/v1/realtime/sessions/{id}/control
        auth: Bearer <access_token>
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: One of model or feature is required.
              properties:
                model: { type: string, example: gpt-realtime-2.1, description: A realtime model in the app's allowed_models. }
                feature: { type: string, description: A feature slug; its default_model is used when model is omitted. }
                instructions: { type: string }
                voice: { type: string, example: marin }
                tools:
                  type: array
                  description: >
                    Function tools — the chat shape ({type, function:{name, description, parameters}})
                    or the Realtime flat shape ({type, name, description, parameters}).
                  items: { type: object }
                tool_choice: {}
                max_output_tokens: { type: integer, default: 1024, description: Per reply. 1024 ≈ 51 s of speech; capped at the model's maximum. }
                max_duration_s: { type: integer, minimum: 30, maximum: 3540, default: 900 }
                max_cost_mc: { type: integer, description: Session cost cap. }
                client_updatable:
                  type: array
                  items: { type: string, enum: [voice, instructions] }
                  description: Fields the browser may change without Wind putting them back.
                on_control_lost:
                  type: string
                  enum: [continue, end]
                  default: continue
                  description: If the control channel drops — carry on, or end after 10 s.
                budget_block_mc: { type: integer, description: Emit wind.session.budget_checkpoint each time spend crosses another block. }
                confirm_each_block: { type: boolean, description: "Requires budget_block_mc. The call ends at the next reply unless the app sends wind.session.approve_block within 30 s." }
      responses:
        "201":
          description: Session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, example: rts_4c1d }
                  status: { type: string, enum: [pending] }
                  model: { type: string }
                  mock: { type: boolean, description: "True when the app's provider mode is Mock: the session runs on Wind's mock voice — a canned placeholder reply, billed at sandbox prices. Switch the app to Live models in the console to talk to a real model." }
                  notice: { type: string, description: Mock sessions only. What the mock is and how to switch to live models. }
                  client:
                    type: object
                    description: Hand this to the browser. Contains nothing secret.
                    properties:
                      mode: { type: string, enum: [live, mock], description: "mock: connectVoice uses mock_url (a WebSocket) instead of the WebRTC offer." }
                      offer_url: { type: string, example: "https://api.usewind.app/v1/realtime/sessions/rts_4c1d/offer" }
                      mock_url: { type: string, description: "Mock sessions only. wss:// URL of Wind's mock voice; the ticket goes in a ticket query parameter.", example: "wss://api.usewind.app/v1/realtime/sessions/rts_4c1d/mock" }
                      notice: { type: string, description: Mock sessions only. }
                      ticket: { type: string, example: rtt_… }
                      expires_at: { type: string, format: date-time, description: About 60 s out. }
                  control_url: { type: string, example: "wss://api.usewind.app/v1/realtime/sessions/rts_4c1d/control" }
        "400":
          description: invalid_request — not a realtime model
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "402":
          description: allocation_exhausted — the first reply's hold doesn't fit
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "403":
          description: connection_frozen (with review_url), connection_revoked or model_not_allowed
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "429":
          description: too_many_sessions (2 pending or live per connection) or sandbox_live_cost_limited
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/realtime/sessions/{id}/offer:
    post:
      tags: [Voice]
      summary: Exchange the browser's WebRTC offer (from the browser)
      description: >
        The browser's only call to Wind. Send the RTCPeerConnection's SDP offer
        as text; the response body is the SDP answer for
        `setRemoteDescription({type: "answer", sdp})`. Create a data channel named
        `oai-events` and add a microphone track (or a recvonly audio transceiver)
        before creating the offer. CORS is open to any origin, without
        credentials. The ticket is single use.
      security: [{ realtimeTicket: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/sdp:
            schema: { type: string, example: "v=0\r\no=- 4611… 2 IN IP4 127.0.0.1\r\n…" }
      responses:
        "201":
          description: SDP answer
          content:
            application/sdp:
              schema: { type: string }
        "400":
          description: invalid_request — the body isn't an SDP offer
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "401":
          description: invalid_ticket — wrong, expired (~60 s) or already used
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
        "502":
          description: provider_error
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/realtime/sessions/{id}:
    get:
      tags: [Voice]
      summary: Get a voice session, with its receipts
      security: [{ connectionBearer: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Session
          content: { application/json: { schema: { $ref: "#/components/schemas/RealtimeSession" } } }
        "404":
          description: Not found, or not this connection's session
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/realtime/sessions/{id}/end:
    post:
      tags: [Voice]
      summary: End a voice session
      description: >
        after_heard (default) waits until the user has heard the current reply;
        immediate cancels the reply in progress, bills its partial usage, then
        hangs up. A pending session ends at once.
      security: [{ connectionBearer: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                when: { type: string, enum: [after_heard, immediate], default: after_heard }
      responses:
        "202":
          description: Ending
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  ending: { type: boolean, enum: [true] }
        "404":
          description: Not found, or not this connection's session
          content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  /v1/connections:
    get:
      tags: [Connections]
      summary: List the app's connections
      security: [{ clientBasic: [] }]
      responses:
        "200":
          description: Connections
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Connection" } }

  /v1/connections/{id}:
    get:
      tags: [Connections]
      summary: Get one connection
      security: [{ clientBasic: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Connection
          content: { application/json: { schema: { $ref: "#/components/schemas/Connection" } } }

  /v1/connections/{id}/revoke:
    post:
      tags: [Connections]
      summary: Revoke a connection
      security: [{ clientBasic: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Revoked; unspent reservation returned to the user's balance
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [revoked] }
                  revoked_at: { type: string, format: date-time }
                  released_mc: { type: integer }

  /v1/connections/{id}/allocation-requests:
    post:
      tags: [Connections]
      summary: Request an allocation increase (user must approve)
      description: A connection has at most one pending request — filing a new one marks the previous pending request `superseded`.
      security: [{ clientBasic: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_allocation_mc]
              properties:
                target_allocation_mc: { type: integer }
                reason: { type: string }
      responses:
        "200":
          description: Pending request
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, example: acr_55 }
                  connection_id: { type: string }
                  status: { type: string, enum: [pending, approved, denied, superseded] }
                  current_allocation_mc: { type: integer }
                  target_allocation_mc: { type: integer }
                  approval_url: { type: string }

  /v1/account:
    get:
      tags: [Account]
      summary: The developer's own account and app context
      security: [{ clientBasic: [] }]
      responses:
        "200":
          description: Account
          content: { application/json: { schema: { $ref: "#/components/schemas/Account" } } }

  /v1/apps/{id}/features:
    get:
      tags: [Features]
      summary: List registered features
      security: [{ clientBasic: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Features
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/Feature" } }
    post:
      tags: [Features]
      summary: Register a feature
      security: [{ clientBasic: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/Feature" } } }
      responses:
        "200":
          description: Created
          content: { application/json: { schema: { $ref: "#/components/schemas/Feature" } } }

  /v1/apps/{id}/features/{slug}:
    patch:
      tags: [Features]
      summary: Update a feature
      security: [{ clientBasic: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: slug, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/Feature" } } }
      responses:
        "200":
          description: Updated
          content: { application/json: { schema: { $ref: "#/components/schemas/Feature" } } }

  /v1/apps/{id}/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      security: [{ clientBasic: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Endpoints
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/WebhookEndpoint" } }
    post:
      tags: [Webhooks]
      summary: Add a webhook endpoint
      description: The response includes the signing `secret`, shown once.
      security: [{ clientBasic: [] }]
      parameters: [{ name: id, in: path, required: true, schema: { type: string } }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { type: string }, description: "Empty or omitted = every event." }
      responses:
        "200":
          description: Created
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }

  /v1/apps/{id}/webhooks/{webhookId}:
    patch:
      tags: [Webhooks]
      summary: Update a webhook endpoint
      security: [{ clientBasic: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: webhookId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events: { type: array, items: { type: string } }
                status: { type: string, enum: [active, disabled] }
      responses:
        "200":
          description: Updated
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      security: [{ clientBasic: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: webhookId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema: { type: object, properties: { ok: { type: boolean } } }

  /v1/apps/{id}/webhooks/{webhookId}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate the signing secret
      description: The old secret stops working immediately. The response includes the new `secret`, shown once.
      security: [{ clientBasic: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: webhookId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Rotated
          content: { application/json: { schema: { $ref: "#/components/schemas/WebhookEndpoint" } } }

  /v1/apps/{id}/webhooks/{webhookId}/deliveries:
    get:
      tags: [Webhooks]
      summary: Recent deliveries (latest 50)
      security: [{ clientBasic: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: webhookId, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Deliveries, newest first
          content:
            application/json:
              schema: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } }
