openapi: 3.1.0

info:
  title: Voxtra Public API
  version: "1.0.0"
  description: |
    HTTP API for Closedit (and future external tenants) to interact with the
    Voxtra voice platform.

    **Integration boundary:** HTTP-only, authenticated with a per-tenant
    `vx_live_*` bearer key (ADR-016). No SDK, no shared infrastructure
    credentials.

    **Wave 4 URL migration (2026-05-16):** The canonical base URL is now
    `https://api.caller.closedit.ai/v1`. Legacy `/admin/api/tenants/:id/*`
    paths are dual-mounted on `caller.closedit.ai` and continue to serve
    traffic until 2026-08-14 (Sunset header). Migrate to `/v1/` paths before
    that date. All deprecated responses include `Deprecation: true`, `Sunset:`,
    and `Link:` headers (RFC 8594). After the sunset date, the legacy mount
    will return 410 Gone. Code all new clients against
    `api.caller.closedit.ai/v1`. See ADR-016 for the architectural rationale.

    **Versioning:** `/v1/` is the only version. Future breaking changes will
    ship as `/v2/`. Non-breaking additions (new fields, new optional endpoints)
    are added to `/v1/` directly. The operator announces breaking changes
    directly to the Closedit engineering team.

    **ADR-017:** Bearer keys now authorize Twilio import and unimport, enabling
    Closedit-style consumer backends to auto-onboard end-users without
    operator involvement.

    ## Idempotency

    Stripe-style idempotency is supported on all five tenant-scoped POST
    mutation endpoints (dispatch, agents, trunks, phone-numbers, twilio-imports).

    Include an `Idempotency-Key` header with a client-generated unique string
    (a UUID per logical attempt is the standard pattern):

    ```
    Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
    ```

    **Behavior:**
    - First request with a given key processes normally and caches the response
      in Redis for 24 hours, keyed by `(tenant_id, Idempotency-Key)`.
    - Subsequent requests with the same key and the same request body receive the
      cached response immediately, with `Idempotent-Replayed: true` header.
    - Same key, different body: `422 idempotency_key_in_use`.
    - 5xx responses are never cached — a retry after a server error re-processes.
    - 4xx responses are cached — they are deterministic given the request body.

    **Client pattern (JS):**

    ```js
    import { randomUUID } from 'crypto';

    async function dispatchWithRetry(tenantId, payload, { maxRetries = 3 } = {}) {
      const idempotencyKey = randomUUID(); // generate once per logical attempt
      for (let attempt = 0; attempt < maxRetries; attempt++) {
        const res = await fetch(
          `https://api.caller.closedit.ai/v1/tenants/${tenantId}/dispatch`,
          {
            method: 'POST',
            headers: {
              'Authorization': `Bearer ${process.env.VOXTRA_API_KEY}`,
              'Content-Type': 'application/json',
              'Idempotency-Key': idempotencyKey,
            },
            body: JSON.stringify(payload),
          }
        );
        if (res.ok || res.status < 500) return res; // success or deterministic error
        if (attempt < maxRetries - 1) {
          const retryAfter = parseInt(res.headers.get('Retry-After') || '5', 10);
          await new Promise(r => setTimeout(r, retryAfter * 1000));
        }
      }
    }
    ```

    See also: [Stripe idempotency guide](https://stripe.com/docs/api/idempotent_requests)

    ## Rate Limits

    Every response includes `RateLimit-*` headers (IETF draft-7):

    | Header | Meaning |
    |--------|---------|
    | `RateLimit-Limit` | Maximum requests in the current window |
    | `RateLimit-Remaining` | Requests remaining in the current window |
    | `RateLimit-Reset` | Seconds until the window resets |
    | `RateLimit-Policy` | Rate limit policy string |
    | `Retry-After` | Seconds to wait before retrying (only on 429) |

    On a 429 response, read `Retry-After` and back off before retrying:

    ```js
    if (res.status === 429) {
      const retryAfter = parseInt(res.headers.get('Retry-After') || '60', 10);
      await new Promise(r => setTimeout(r, retryAfter * 1000));
      // then retry with the same Idempotency-Key if applicable
    }
    ```

  contact:
    name: Voxtra operator
    email: zaroual.zaroual.oussama@gmail.com

servers:
  - url: https://api.caller.closedit.ai/v1
    description: Production — public API. The only host for /v1/* endpoints.

tags:
  - name: Dispatch
    description: Place outbound calls
  - name: Agents
    description: Configure AI voice agents
  - name: Phone Numbers
    description: Manage tenant phone numbers (SIP provider)
  - name: Trunks
    description: Manage SIP trunks
  - name: Calls
    description: Call history and detail
  - name: Bookings
    description: Appointment bookings captured during calls
  - name: Tools
    description: HTTP function tools available to agents
  - name: Twilio Imports
    description: Import and release Twilio Programmable Voice numbers (bearer key or operator session)
  - name: Webhooks
    description: |
      Manage tenant webhook endpoints. Voxtra delivers HMAC-signed HTTP POST events
      to configured HTTPS endpoints for call and booking lifecycle events.
      Secret is returned once on creation/rotation and never again (Stripe pattern).
  - name: Webhook Deliveries
    description: View delivery history and individual attempt detail for debugging.

security:
  - BearerAuth: []

# ─────────────────────────────────────────────────────────────────────────────
# Paths
# ─────────────────────────────────────────────────────────────────────────────

paths:

  # ── Dispatch ───────────────────────────────────────────────────────────────

  /tenants/{tenantId}/dispatch:
    post:
      operationId: createDispatch
      summary: Place an outbound call
      description: |
        Dispatches a LiveKit agent and initiates an outbound SIP call to
        `to_number`. The response is returned synchronously once LiveKit accepts
        the dispatch job and the SIP participant creation is enqueued — actual
        ringing begins asynchronously.

        **Not idempotent.** Each call creates a new LiveKit room and call record.
        Do not retry on 5xx without checking call history first.

        Requires `trunk_id` and `agent_id` that belong to this tenant and are
        active. `client_ref` is passed through to call records and webhooks
        for Closedit-side correlation (see ADR-016).
      tags: [Dispatch]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DispatchRequest'
            example:
              trunk_id: "tt_a1b2c3d4e5f6"
              agent_id: "ta_f6e5d4c3b2a1"
              to_number: "+15551234567"
              client_ref: "lead_abc123"
              lead:
                name: "Jane Smith"
                email: "jane@example.com"
              metadata:
                campaign_id: "q2_outbound"
      responses:
        "201":
          description: Call dispatched successfully
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DispatchResponse'
              example:
                call_id: "AJ_deadbeefcafe0001"
                room_name: "tenant-tn_abc123-1715820000000"
                status: "dispatched"
        "400":
          description: Validation error (invalid E.164, missing trunk_id or agent_id)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_phone:
                  value:
                    error: invalid_phone_format
                    message: "to_number must be a valid E.164 number (e.g. +15555550123)"
                missing_fields:
                  value:
                    error: missing_required_fields
                    message: "trunk_id is required"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Tenant, trunk, or agent not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                trunk_not_found:
                  value:
                    error: trunk_not_found
                    message: "Trunk not found or not active"
                agent_not_found:
                  value:
                    error: agent_not_found
                    message: "Agent not found"
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Agents ─────────────────────────────────────────────────────────────────

  /tenants/{tenantId}/agents:
    get:
      operationId: listAgents
      summary: List agents for a tenant
      description: |
        Returns all agents for the tenant. Optionally filter by `client_ref`
        to find agents associated with a specific Closedit entity.
      tags: [Agents]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: client_ref
          in: query
          required: false
          schema:
            type: string
          description: Filter agents by client_ref (exact match)
      responses:
        "200":
          description: List of agents
          content:
            application/json:
              schema:
                type: object
                properties:
                  agents:
                    type: array
                    items:
                      $ref: '#/components/schemas/Agent'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

    post:
      operationId: createAgent
      summary: Create an agent
      description: |
        Creates a new AI voice agent for this tenant. `name` and `instructions`
        are required. Voice ID must be a valid ElevenLabs or Cartesia voice ID
        depending on the platform's active TTS provider.

        `tool_ids` — if omitted or `null`, the agent inherits all enabled tenant
        tools. To assign a specific subset, pass an array of tool IDs. Passing
        `[]` explicitly assigns no tools.

        **Idempotency:** Include `Idempotency-Key` to safely retry after timeouts.
        A cached 201 response will be replayed with `Idempotent-Replayed: true`.
      tags: [Agents]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentCreateRequest'
            example:
              name: "Rania - Sales Agent"
              instructions: "You are Rania, a friendly sales agent for Closedit. Your goal is to qualify leads and book discovery calls."
              welcome: "Hi, this is Rania calling from Closedit. Is this a good time?"
              voice_id: "cgSgspJ2msm6clMCkdW9"
              client_ref: "closedit_agent_v2"
              tool_ids: ["tt_abc123", "tt_def456"]
      responses:
        "201":
          description: Agent created
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: The new agent's ID
                  agent:
                    $ref: '#/components/schemas/Agent'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                missing_fields:
                  value:
                    error: missing_required_fields
                    message: "name and instructions are required"
                unknown_tools:
                  value:
                    error: unknown_tool_ids
                    message: "unknown or cross-tenant tool_ids"
                    unknown_tool_ids: ["tt_bad"]
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/agents/{agentId}:
    get:
      operationId: getAgent
      summary: Get a single agent
      tags: [Agents]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/agentId'
      responses:
        "200":
          description: Agent detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  agent:
                    $ref: '#/components/schemas/Agent'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Agent or tenant not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

    put:
      operationId: updateAgent
      summary: Update an agent
      description: |
        Partial update. Only provided fields are changed. `tool_ids: null` reverts
        to inheriting all tenant tools. `tool_ids: []` explicitly assigns no tools.
      tags: [Agents]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/agentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentUpdateRequest'
      responses:
        "200":
          description: Agent updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  agent:
                    $ref: '#/components/schemas/Agent'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Agent or tenant not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

    delete:
      operationId: deleteAgent
      summary: Delete an agent
      tags: [Agents]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/agentId'
      responses:
        "200":
          description: Agent deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Agent or tenant not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Trunks ─────────────────────────────────────────────────────────────────

  /tenants/{tenantId}/trunks:
    get:
      operationId: listTrunks
      summary: List SIP trunks for a tenant
      description: |
        Returns all active trunks for the tenant. SIP passwords are never
        included in responses — they are returned only once at creation and at
        credential rotation.

        Requires `BYO_TRUNK_API_KEY_ENABLED=true` on the server when
        authenticating with a bearer key (as opposed to an operator session).
      tags: [Trunks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
      responses:
        "200":
          description: List of trunks
          content:
            application/json:
              schema:
                type: object
                properties:
                  trunks:
                    type: array
                    items:
                      $ref: '#/components/schemas/Trunk'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

    post:
      operationId: createTrunk
      summary: Create a SIP trunk
      description: |
        Creates a SIP outbound trunk and registers it with LiveKit. `sip_user`
        and `sip_password` are optional — if omitted, secure values are
        auto-generated. The `sip_password_oneshot` in the response is the
        only time the plaintext password is available. Store it immediately.

        **Idempotency:** Include `Idempotency-Key` to safely retry after timeouts.
        A cached 201 (including `sip_password_oneshot`) will be replayed with
        `Idempotent-Replayed: true`.
      tags: [Trunks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TrunkCreateRequest'
            example:
              label: "Twilio US East"
              provider: "twilio"
              host: "voxtra-out.pstn.twilio.com"
              transport: "tls"
              caller_id_number: "+15551230000"
      responses:
        "201":
          description: Trunk created. Save `sip_password_oneshot` now.
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrunkCreateResponse'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_host:
                  value:
                    error: invalid_host
                    message: "host must be a valid FQDN (no scheme, no path, e.g. voxtra-out.pstn.twilio.com)"
                invalid_provider:
                  value:
                    error: invalid_provider
                    message: "provider must be one of: twilio, telnyx, bandwidth, vonage, other"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "502":
          description: LiveKit trunk creation failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: trunk_create_failed
                message: "Failed to create trunk. Verify SIP credentials and host."
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/trunks/{trunkId}:
    get:
      operationId: getTrunk
      summary: Get a single SIP trunk
      tags: [Trunks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/trunkId'
      responses:
        "200":
          description: Trunk detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  trunk:
                    $ref: '#/components/schemas/Trunk'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Trunk or tenant not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

    put:
      operationId: updateTrunk
      summary: Update trunk metadata
      description: |
        Partial update. Allowed fields: `label`, `caller_id_number`,
        `client_ref`, `settings_json`. All other fields (including `host`,
        `transport`, `sip_user`) are rejected with 400 — use
        `rotate-credentials` for credential changes.
      tags: [Trunks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/trunkId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TrunkUpdateRequest'
      responses:
        "200":
          description: Trunk updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  trunk:
                    $ref: '#/components/schemas/Trunk'
        "400":
          description: Validation error or disallowed fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: invalid_update_fields
                message: "Cannot update fields: host, transport. Use rotate-credentials to change SIP credentials."
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Trunk or tenant not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

    delete:
      operationId: deleteTrunk
      summary: Archive (soft-delete) a SIP trunk
      description: |
        Soft-deletes the trunk. Blocked with 409 if any active phone numbers
        still reference this trunk — release those numbers first.
      tags: [Trunks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/trunkId'
      responses:
        "200":
          description: Trunk archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Trunk or tenant not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "409":
          description: Trunk has active phone numbers
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Error'
                  - type: object
                    properties:
                      phone_numbers:
                        type: array
                        items:
                          type: string
              example:
                error: trunk_has_active_numbers
                message: "Trunk has active phone numbers; release them first"
                phone_numbers: ["+15551230000"]
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/trunks/{trunkId}/rotate-credentials:
    post:
      operationId: rotateTrunkCredentials
      summary: Rotate SIP credentials for a trunk
      description: |
        Rotates `sip_user` and `sip_password` for the trunk. Updates LiveKit
        first; if LiveKit update succeeds, updates the DB. On LiveKit failure,
        the old credentials are unchanged and 502 is returned.

        The new plaintext password appears once in `sip_password_oneshot`. Save
        it and update your SIP provider (Twilio/Telnyx/etc.) immediately.

        `sip_user` and `sip_password` are optional in the request — if omitted,
        secure values are auto-generated.
      tags: [Trunks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/trunkId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                sip_user:
                  type: string
                  minLength: 3
                  maxLength: 64
                  pattern: '^[a-zA-Z0-9._-]{3,64}$'
                sip_password:
                  type: string
                  minLength: 16
      responses:
        "201":
          description: Credentials rotated. Save `sip_password_oneshot` now.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrunkCreateResponse'
        "400":
          description: Invalid sip_user or sip_password format
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Trunk or tenant not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'
        "502":
          description: LiveKit update failed; old credentials unchanged
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: livekit_update_failed
                message: "Failed to update LiveKit-side trunk credentials. Old credentials are unchanged."

  # ── Phone Numbers ──────────────────────────────────────────────────────────

  /tenants/{tenantId}/phone-numbers:
    get:
      operationId: listPhoneNumbers
      summary: List phone numbers for a tenant
      description: |
        Returns phone numbers for the tenant. Defaults to excluding released
        numbers. Filter by `status` to include released numbers or show only
        a specific status.

        Requires `BYO_TRUNK_API_KEY_ENABLED=true` on the server when
        authenticating with a bearer key.
      tags: [Phone Numbers]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [active, disabled, released]
          description: "Filter by status. Default: active and disabled (released excluded)"
      responses:
        "200":
          description: List of phone numbers
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone_numbers:
                    type: array
                    items:
                      $ref: '#/components/schemas/PhoneNumber'
        "400":
          description: Invalid status value
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

    post:
      operationId: assignPhoneNumber
      summary: Assign a phone number to a tenant trunk
      description: |
        Registers a SIP phone number and associates it with the given trunk.
        Optionally links a default agent for inbound call routing.

        The same E.164 number cannot be assigned twice — a second attempt returns
        409. With `Idempotency-Key`, a retry after timeout replays the 201 safely.

        Note: this endpoint manages SIP-provider numbers (`provider_kind=sip`).
        For Twilio Programmable Voice numbers use the Twilio Imports endpoints.
      tags: [Phone Numbers]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PhoneNumberAssignRequest'
            example:
              number: "+15551230001"
              trunk_id: "tt_a1b2c3d4e5f6"
              default_agent_id: "ta_f6e5d4c3b2a1"
              label: "Main Outbound"
      responses:
        "201":
          description: Phone number assigned
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone_number:
                    $ref: '#/components/schemas/PhoneNumber'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_phone:
                  value:
                    error: invalid_phone_format
                    message: "number must be a valid E.164 phone number (e.g. +15555550123)"
                missing_trunk:
                  value:
                    error: missing_required_fields
                    message: "trunk_id is required"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Tenant, trunk, or agent not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "409":
          description: Phone number already in use by another tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: phone_already_in_use
                message: "This phone number is already registered. If you believe this is incorrect, contact support."
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/phone-numbers/{number}:
    get:
      operationId: getPhoneNumber
      summary: Get a single phone number
      description: |
        The `{number}` path parameter must be URL-encoded (`+` → `%2B`).
        Released numbers return 404.
      tags: [Phone Numbers]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/phoneNumber'
      responses:
        "200":
          description: Phone number detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone_number:
                    $ref: '#/components/schemas/PhoneNumber'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Phone number not found or released
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

    put:
      operationId: updatePhoneNumber
      summary: Update phone number metadata
      description: |
        Partial update. Allowed fields: `default_agent_id`, `status`,
        `label`, `client_ref`, `settings_json`. All other fields are rejected
        with 400. `status` accepts `active` or `disabled` — use DELETE to
        release.
      tags: [Phone Numbers]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/phoneNumber'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PhoneNumberUpdateRequest'
      responses:
        "200":
          description: Phone number updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone_number:
                    $ref: '#/components/schemas/PhoneNumber'
        "400":
          description: Validation error or disallowed fields
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Phone number not found or released
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

    delete:
      operationId: releasePhoneNumber
      summary: Release (remove) a phone number
      description: |
        Marks the number as `released`. This is permanent — a released number
        returns 404 on subsequent GET requests. The number can be re-assigned
        with a new POST call.
      tags: [Phone Numbers]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/phoneNumber'
      responses:
        "200":
          description: Phone number released
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Phone number not found or already released
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Calls ──────────────────────────────────────────────────────────────────

  /tenants/{tenantId}/calls:
    get:
      operationId: listCallsByTenant
      summary: List calls for a tenant
      description: |
        Returns calls for this tenant, newest first. Supports time-range
        filtering via Unix millisecond timestamps. Max 1000 per request.
        Filter by `client_ref` to scope results to a Closedit entity.
      tags: [Calls]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: from
          in: query
          schema:
            type: integer
            format: int64
          description: Unix timestamp (ms) — start of range
        - name: to
          in: query
          schema:
            type: integer
            format: int64
          description: Unix timestamp (ms) — end of range
        - name: limit
          in: query
          schema:
            type: integer
            default: 200
            minimum: 1
            maximum: 1000
          description: Maximum number of results
        - name: client_ref
          in: query
          schema:
            type: string
          description: Filter by client_ref
      responses:
        "200":
          description: List of calls
          content:
            application/json:
              schema:
                type: object
                properties:
                  calls:
                    type: array
                    items:
                      $ref: '#/components/schemas/Call'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/calls/{callId}:
    get:
      operationId: getCallByTenant
      summary: Get a single call with turns and transcript
      description: |
        Returns the call record plus per-turn latency metrics, the full
        transcript, and (if S3 is configured) a pre-signed recording URL
        valid for 1 hour. A 404 is returned if the call belongs to a
        different tenant.
      tags: [Calls]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/callId'
      responses:
        "200":
          description: Call detail
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallDetail'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Call not found or belongs to a different tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/calls/{callId}/tool-calls:
    get:
      operationId: listCallToolCalls
      summary: List tool-call log for a call
      description: |
        Returns every HTTP function call the agent made during this call,
        including status, latency, and response summary.
      tags: [Calls]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/callId'
      responses:
        "200":
          description: Tool call log
          content:
            application/json:
              schema:
                type: object
                properties:
                  tool_calls:
                    type: array
                    items:
                      $ref: '#/components/schemas/ToolCallLog'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Call not found or belongs to a different tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Bookings ───────────────────────────────────────────────────────────────

  /tenants/{tenantId}/bookings:
    get:
      operationId: listBookingsByTenant
      summary: List bookings for a tenant
      description: |
        Returns appointment bookings captured during calls for this tenant.
        Filter by `client_ref` to scope to a Closedit entity. Max 1000.
      tags: [Bookings]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: limit
          in: query
          schema:
            type: integer
            default: 200
            minimum: 1
            maximum: 1000
        - name: client_ref
          in: query
          schema:
            type: string
          description: Filter by client_ref
      responses:
        "200":
          description: List of bookings
          content:
            application/json:
              schema:
                type: object
                properties:
                  bookings:
                    type: array
                    items:
                      $ref: '#/components/schemas/Booking'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/bookings/{bookingId}/status:
    put:
      operationId: updateBookingStatus
      summary: Update a booking's status
      description: |
        Transitions the booking status. Valid values: `pending`, `confirmed`,
        `cancelled`, `completed`. Returns 404 if the booking doesn't belong
        to this tenant.
      tags: [Bookings]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: bookingId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [pending, confirmed, cancelled, completed]
      responses:
        "200":
          description: Status updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "400":
          description: Invalid status value
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Booking not found or belongs to a different tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Tools ──────────────────────────────────────────────────────────────────

  /tenants/{tenantId}/tools:
    get:
      operationId: listTools
      summary: List tools for a tenant
      description: |
        Returns all tools (enabled and disabled) for the tenant. Accepts a
        tenant bearer API key (`Authorization: Bearer vx_live_...`) or an
        operator session cookie — consistent with POST/PUT/DELETE on this
        resource.
      tags: [Tools]
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/tenantId'
      responses:
        "200":
          description: List of tools
          content:
            application/json:
              schema:
                type: object
                properties:
                  tools:
                    type: array
                    items:
                      $ref: '#/components/schemas/Tool'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "500":
          $ref: '#/components/responses/InternalError'

    post:
      operationId: createTool
      summary: Create a tool
      description: |
        Creates an HTTP function tool that agents can invoke during calls.
        `auth_secret` is the bearer token Voxtra will send when calling
        your tool endpoint — it is encrypted at rest and never returned
        in GET responses. `auth_secret_set: true` in responses indicates
        a secret is stored.
      tags: [Tools]
      parameters:
        - $ref: '#/components/parameters/tenantId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ToolCreateRequest'
            example:
              name: "book_appointment"
              display_name: "Book Appointment"
              description: "Books a discovery call in the CRM"
              args_schema:
                type: object
                properties:
                  full_name:
                    type: string
                  preferred_datetime:
                    type: string
                required: [full_name, preferred_datetime]
              url: "https://api.closedit.ai/webhooks/voxtra/book"
              method: "POST"
              auth_mode: "bearer"
              auth_secret: "sk_live_yourcloseditwebhooksecret"
              timeout_ms: 6000
              retry_count: 1
      responses:
        "201":
          description: Tool created
          content:
            application/json:
              schema:
                type: object
                properties:
                  tool:
                    $ref: '#/components/schemas/Tool'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "409":
          description: Tool name already exists for this tenant
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: tool_name_conflict
                message: "Tool name \"book_appointment\" already exists for this tenant"
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/tools/{toolId}:
    put:
      operationId: updateTool
      summary: Update a tool
      description: |
        Partial update. Merges provided fields with existing values.
        Setting `auth_secret` replaces the stored secret.
        Changing `auth_mode` away from `bearer` clears the stored secret.
      tags: [Tools]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/toolId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ToolUpdateRequest'
      responses:
        "200":
          description: Tool updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  tool:
                    $ref: '#/components/schemas/Tool'
        "400":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Tool not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "409":
          description: Tool name conflict
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

    delete:
      operationId: deleteTool
      summary: Delete a tool
      description: |
        If the tool is referenced by any agents, it is soft-deleted
        (deprecated) and `soft_deleted: true` is returned with the list
        of referencing agent IDs. If unreferenced, it is hard-deleted.
      tags: [Tools]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/toolId'
      responses:
        "200":
          description: Tool deleted or deprecated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  soft_deleted:
                    type: boolean
                  referenced_by:
                    type: array
                    items:
                      type: string
                    description: Agent IDs still referencing the tool (only present when soft_deleted is true)
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Tool not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Twilio Imports ─────────────────────────────────────────────────────────

  /tenants/{tenantId}/twilio-imports:
    post:
      operationId: importTwilioNumber
      summary: Import a Twilio Programmable Voice number
      description: |
        Accepts either a `vx_live_*` bearer key or an operator session cookie
        (ADR-017, supersedes ADR-013 §2.7's operator-only restriction).

        Closedit-style consumer backends can call this with their tenant
        `vx_live_*` bearer key to auto-onboard end-users' Twilio numbers.
        Rate-limited to 10 imports/hour per bearer key. Operator-session
        access is uncapped.

        Validates Twilio credentials, verifies phone number ownership, and
        provisions:
        - A Twilio Elastic SIP Trunk for the tenant (idempotent on re-import)
        - A Twilio Credential List and credential for LK→Twilio SIP DIGEST auth
        - A LiveKit outbound SIP trunk per tenant
        - The phone number row linked to the trunk

        The `auth_token` is encrypted at rest with AES-256-GCM. The plaintext
        is never returned in any response.
      tags: [Twilio Imports]
      security:
        - BearerAuth: []
        - SessionAuth: []
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TwilioImportRequest'
            example:
              phone_number: "+15551230099"
              account_sid: "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
              auth_token: "your32characterauthtokenhere0000"
              label: "Twilio Main"
      responses:
        "200":
          description: Number imported and trunk provisioned
          headers:
            RateLimit-Limit:
              $ref: '#/components/headers/RateLimitLimit'
            RateLimit-Remaining:
              $ref: '#/components/headers/RateLimitRemaining'
            RateLimit-Reset:
              $ref: '#/components/headers/RateLimitReset'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TwilioImportResponse'
        "400":
          description: Validation error or Twilio credential/ownership check failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalid_phone:
                  value:
                    error: invalid_phone_format
                    message: "phone_number must be a valid E.164 number (e.g. +15555550123)"
                invalid_sid:
                  value:
                    error: invalid_account_sid
                    message: "account_sid must be a Twilio Account SID (AC + 32 hex chars)"
                invalid_creds:
                  value:
                    error: twilio_credentials_invalid
                    message: "Twilio credentials invalid"
                number_not_owned:
                  value:
                    error: phone_not_in_account
                    message: "Phone number not found in this Twilio account"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "409":
          description: Number already imported or attached to a third-party trunk
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                already_in_use:
                  value:
                    error: phone_already_in_use
                    message: "This phone number is already assigned to another agent"
                third_party_trunk:
                  value:
                    error: third_party_trunk_conflict
                    message: "Phone number is attached to a Twilio Elastic SIP Trunk we don't manage. Detach it in Twilio Console first."
        "422":
          description: Idempotency key reused with a different request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: idempotency_key_in_use
                message: "This Idempotency-Key was previously used with a different request body."
        "429":
          $ref: '#/components/responses/RateLimitExceeded'
        "500":
          $ref: '#/components/responses/InternalError'
        "502":
          description: Twilio API error during provisioning (all changes rolled back)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: twilio_api_error
                message: "Twilio API error during trunk provisioning. All changes rolled back."

  /tenants/{tenantId}/twilio-imports/{phoneNumber}:
    delete:
      operationId: unimportTwilioNumber
      summary: Release a Twilio-imported phone number
      description: |
        Accepts either a `vx_live_*` bearer key or an operator session cookie
        (ADR-017, supersedes ADR-013 §2.7's operator-only restriction).

        Closedit-style consumer backends can call this with their tenant
        `vx_live_*` bearer key to release end-users' Twilio numbers.
        Operator-session access is uncapped. Deleting a never-imported number
        returns 404 (idempotency: safe to call on already-released numbers
        only if you expect and handle 404).

        Detaches the number from the Twilio Elastic SIP Trunk, removes it from
        the LiveKit inbound trunk's number filter, and marks the DB row as
        released. Twilio trunk detach and LK update failures are non-fatal
        (logged and audited) — the DB row is always released if the number
        exists.

        The `{phoneNumber}` path parameter must be URL-encoded (`+` → `%2B`).
      tags: [Twilio Imports]
      security:
        - BearerAuth: []
        - SessionAuth: []
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: phoneNumber
          in: path
          required: true
          schema:
            $ref: '#/components/schemas/E164PhoneNumber'
          description: "E.164 phone number, URL-encoded (+ → %2B)"
      responses:
        "200":
          description: Number released
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone_number:
                    $ref: '#/components/schemas/PhoneNumber'
        "400":
          description: Number uses SIP provider, not Twilio PV
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: wrong_endpoint
                message: "Wrong endpoint: this number uses the SIP provider, not Twilio PV"
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Phone number not found or already released
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Webhooks ───────────────────────────────────────────────────────────────

  /tenants/{tenantId}/webhooks:
    get:
      operationId: listWebhookEndpoints
      summary: List webhook endpoints
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
      responses:
        "200":
          description: List of webhook endpoints (no secrets)
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_endpoints:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEndpoint'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

    post:
      operationId: createWebhookEndpoint
      summary: Create a webhook endpoint
      description: |
        Creates a new HTTPS webhook endpoint for the tenant.
        The `signing_secret` is returned **once** in the response and never again.
        Store it securely immediately — it cannot be retrieved after this response.
        To rotate, call `POST /webhooks/:id/rotate-secret`.
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreateRequest'
            example:
              url: "https://app.closedit.ai/webhooks/voxtra"
              description: "Production Closedit webhook receiver"
              event_types: ["*"]
      responses:
        "201":
          description: Endpoint created. signing_secret shown once.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointSecretReveal'
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/webhooks/{webhookId}:
    get:
      operationId: getWebhookEndpoint
      summary: Get a webhook endpoint
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/webhookId'
      responses:
        "200":
          description: Webhook endpoint (no secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_endpoint:
                    $ref: '#/components/schemas/WebhookEndpoint'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

    put:
      operationId: updateWebhookEndpoint
      summary: Update url, description, enabled, or event_types
      description: |
        Updates mutable fields. Cannot change the signing secret here — use
        `POST /webhooks/:id/rotate-secret` for that.
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/webhookId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointUpdateRequest'
      responses:
        "200":
          description: Updated endpoint (no secret)
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_endpoint:
                    $ref: '#/components/schemas/WebhookEndpoint'
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

    delete:
      operationId: archiveWebhookEndpoint
      summary: Archive (soft-delete) a webhook endpoint
      description: |
        Marks the endpoint as archived. Pending deliveries for this endpoint
        will be dead-lettered by the delivery worker.
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/webhookId'
      responses:
        "200":
          description: Endpoint archived
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                  deleted:
                    type: boolean
                    enum: [true]
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/webhooks/{webhookId}/rotate-secret:
    post:
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret
      description: |
        Generates a new signing secret. The old secret is **immediately invalid**.
        The new secret is returned **once** in the response body and never again.
        Update your HMAC verification code before rotating in production.
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/webhookId'
      responses:
        "200":
          description: New signing_secret shown once
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointSecretReveal'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/webhooks/{webhookId}/test:
    post:
      operationId: testWebhookEndpoint
      summary: Send a synthetic webhook.test event
      description: |
        Immediately delivers a `webhook.test` event to the endpoint URL.
        Useful for verifying the URL is reachable and your signature
        verification is correct before enabling real events.
      tags: [Webhooks]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - $ref: '#/components/parameters/webhookId'
      responses:
        "200":
          description: Test delivery result (whether it succeeded or not)
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery_id:
                    type: string
                  ok:
                    type: boolean
                  http_status:
                    type: integer
                    nullable: true
                  error:
                    type: string
                    nullable: true
                  excerpt:
                    type: string
                    nullable: true
        "400":
          $ref: '#/components/responses/ValidationError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  # ── Webhook Deliveries ──────────────────────────────────────────────────────

  /tenants/{tenantId}/webhook-deliveries:
    get:
      operationId: listWebhookDeliveries
      summary: List webhook delivery attempts
      description: |
        Returns recent delivery attempts for debugging. Optionally filter
        by `endpoint_id`. Ordered by `created_at DESC`.
      tags: [Webhook Deliveries]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: endpoint_id
          in: query
          required: false
          schema:
            type: string
          description: Filter by endpoint ID (twe_...)
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 50
            maximum: 200
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: List of delivery attempts
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_deliveries:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookDelivery'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

  /tenants/{tenantId}/webhook-deliveries/{deliveryId}:
    get:
      operationId: getWebhookDelivery
      summary: Get a single delivery attempt
      tags: [Webhook Deliveries]
      parameters:
        - $ref: '#/components/parameters/tenantId'
        - name: deliveryId
          in: path
          required: true
          schema:
            type: string
          description: Delivery ID (wdl_...)
      responses:
        "200":
          description: Delivery attempt detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook_delivery:
                    $ref: '#/components/schemas/WebhookDelivery'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/TenantNotFound'
        "500":
          $ref: '#/components/responses/InternalError'

# ─────────────────────────────────────────────────────────────────────────────
# Outbound webhooks (Voxtra → tenant HTTPS endpoints)
# OpenAPI 3.1 top-level 'webhooks' section documents the events Voxtra POSTs.
# ─────────────────────────────────────────────────────────────────────────────

webhooks:

  call.started:
    post:
      summary: A SIP call has started (first SIP participant joined)
      description: |
        Fired when the first SIP participant joins a room.
        Also triggers `call.participant_joined` simultaneously for that participant.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventEnvelope'
            example:
              id: wdl_a1b2c3d4e5f6a1b2
              type: call.started
              created: 1716000000
              api_version: "2026-05-16"
              data:
                object:
                  tenant_id: tn_abc123
                  room_name: tenant-tn_abc123-1716000000000
                  participant_identity: lead_a1b2c3d4
                  participant_kind: SIP
                  started_at: "2026-05-16T00:00:00.000Z"
      responses:
        "2XX":
          description: |
            Any 2xx response is treated as success.
            Body is ignored.
        "410":
          description: |
            Endpoint no longer exists. Voxtra will auto-disable this endpoint
            and stop sending future events.

  call.participant_joined:
    post:
      summary: A SIP participant joined the room
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventEnvelope'
      responses:
        "2XX":
          description: Acknowledged
        "410":
          description: Endpoint auto-disabled

  call.participant_left:
    post:
      summary: A SIP participant left the room
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventEnvelope'
      responses:
        "2XX":
          description: Acknowledged
        "410":
          description: Endpoint auto-disabled

  call.completed:
    post:
      summary: A call room has finished
      description: |
        Fired when the LiveKit room closes. Includes call metadata from the
        call_logs table (may be null if the call ended before the record was written).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventEnvelope'
            example:
              id: wdl_b2c3d4e5f6a1b2c3
              type: call.completed
              created: 1716001800
              api_version: "2026-05-16"
              data:
                object:
                  tenant_id: tn_abc123
                  room_name: tenant-tn_abc123-1716000000000
                  call_id: AJ_deadbeefcafe0001
                  client_ref: lead_closedit_789
                  direction: outbound
                  to_number: "+15551234567"
                  status: completed
                  duration_sec: 127
                  ended_at: "2026-05-16T00:30:00.000Z"
      responses:
        "2XX":
          description: Acknowledged
        "410":
          description: Endpoint auto-disabled

  booking.created:
    post:
      summary: A booking was captured during a call
      description: |
        Fired when an AI agent successfully calls the `book_appointment` tool.
        The `client_ref` field matches the call's `client_ref` for correlation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventEnvelope'
            example:
              id: wdl_c3d4e5f6a1b2c3d4
              type: booking.created
              created: 1716001200
              api_version: "2026-05-16"
              data:
                object:
                  id: bk_a1b2c3d4e5f6
                  call_id: AJ_deadbeefcafe0001
                  tenant_id: tn_abc123
                  full_name: Jane Smith
                  email: jane@example.com
                  phone: "+15551234567"
                  preferred_datetime: "2026-05-20T14:00:00"
                  timezone: America/New_York
                  notes: "BANT qualified"
                  client_ref: lead_closedit_789
                  status: pending
                  created_at: "2026-05-16T00:20:00.000Z"
      responses:
        "2XX":
          description: Acknowledged
        "410":
          description: Endpoint auto-disabled

  agent.dispatch_failed:
    post:
      summary: An outbound SIP call dispatch failed
      description: |
        Fired when `createSipParticipant` throws during a phone-test or
        outbound dispatch. Use this to detect calls that never connected.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventEnvelope'
      responses:
        "2XX":
          description: Acknowledged
        "410":
          description: Endpoint auto-disabled

  webhook.test:
    post:
      summary: Synthetic test event (POST /webhooks/:id/test)
      description: |
        Sent immediately when you call the test endpoint. Used to verify
        your URL is reachable and your HMAC verification is correct.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventEnvelope'
            example:
              id: wdl_d4e5f6a1b2c3d4e5
              type: webhook.test
              created: 1716000000
              api_version: "2026-05-16"
              data:
                object:
                  endpoint_id: twe_a1b2c3d4e5f6a1b2
                  message: "This is a test event from Voxtra. If you receive this, your endpoint is correctly configured."
      responses:
        "2XX":
          description: Acknowledged
        "410":
          description: Endpoint auto-disabled

# ─────────────────────────────────────────────────────────────────────────────
# Components
# ─────────────────────────────────────────────────────────────────────────────

components:

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "vx_live_<24 hex chars>"
      description: |
        Per-tenant API key in the format `vx_live_<24 lowercase hex chars>`.
        Generated in the Voxtra admin UI under Tenants → API Keys.
        The key is scoped to a single tenant — passing it for a different
        tenant's path returns 403.
    SessionAuth:
      type: apiKey
      in: cookie
      name: connect.sid
      description: |
        Operator session cookie. Set by POST /admin/api/login.
        Accepted alongside BearerAuth on Twilio Import routes (ADR-017).

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        minLength: 1
        maxLength: 255
      description: |
        Optional client-generated idempotency key (1–255 ASCII printable characters).
        Recommended format: UUID v4. When present, the server caches the first
        non-5xx response for 24 hours and replays it on subsequent requests with
        the same key and tenant, returning `Idempotent-Replayed: true`.
        Using the same key with a different request body returns 422.

    tenantId:
      name: tenantId
      in: path
      required: true
      schema:
        type: string
      description: "Tenant ID (tn_<hex>) or slug"

    agentId:
      name: agentId
      in: path
      required: true
      schema:
        type: string
      description: "Agent ID (ta_<hex>)"

    trunkId:
      name: trunkId
      in: path
      required: true
      schema:
        type: string
      description: "Trunk ID (tt_<hex>)"

    phoneNumber:
      name: number
      in: path
      required: true
      schema:
        $ref: '#/components/schemas/E164PhoneNumber'
      description: "E.164 phone number, URL-encoded (+ → %2B)"

    callId:
      name: callId
      in: path
      required: true
      schema:
        type: string
      description: "Call ID"

    toolId:
      name: toolId
      in: path
      required: true
      schema:
        type: string
      description: "Tool ID (tt_<hex>)"

    webhookId:
      name: webhookId
      in: path
      required: true
      schema:
        type: string
      description: "Webhook endpoint ID (twe_<hex>)"

  responses:
    Unauthorized:
      description: Missing or invalid authentication
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unauthorized
            message: "Authentication required"

    Forbidden:
      description: API key does not match tenant or feature flag disabled
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            key_mismatch:
              value:
                error: "forbidden"
                message: "API key does not match URL tenant"
            flag_disabled:
              value:
                error: "forbidden"
                message: "API-key trunk operations not enabled"

    TenantNotFound:
      description: Tenant not found or archived
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: tenant_not_found
            message: "Tenant not found or archived"

    InternalError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
            message: "An internal error occurred. Please try again."

    ValidationError:
      description: Request validation failed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: invalid_url
            message: "url must start with https://"

    RateLimitExceeded:
      description: Rate limit exceeded
      headers:
        Retry-After:
          description: Seconds to wait before retrying this request
          schema:
            type: integer
        RateLimit-Limit:
          description: Maximum requests allowed in the current window
          schema:
            type: integer
        RateLimit-Remaining:
          description: Requests remaining in the current window (0 on 429)
          schema:
            type: integer
        RateLimit-Reset:
          description: Seconds until the rate limit window resets
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: rate_limit_exceeded
            message: "Too many Twilio imports from this API key. Retry in 1 hour."
            details:
              retry_after_seconds: 3600

  headers:
    RateLimitLimit:
      description: Maximum requests allowed in the current window
      schema:
        type: integer
    RateLimitRemaining:
      description: Requests remaining in the current window
      schema:
        type: integer
    RateLimitReset:
      description: Seconds until the rate limit window resets
      schema:
        type: integer
    RateLimitPolicy:
      description: Rate limit policy string
      schema:
        type: string
    IdempotentReplayed:
      description: Present and `true` when this response is a cached replay of a previous request
      schema:
        type: string
        enum: ['true']

  schemas:

    E164PhoneNumber:
      type: string
      pattern: '^\+[1-9]\d{6,14}$'
      description: "E.164 phone number: + followed by 1-9, then 6-14 more digits"
      example: "+15551234567"

    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: Machine-readable error code
        message:
          type: string
          description: Human-readable description
        details:
          type: object
          additionalProperties: true
          description: Optional structured details (e.g. validation errors)

    # ── Dispatch ─────────────────────────────────────────────────────────────

    DispatchRequest:
      type: object
      required: [trunk_id, agent_id, to_number]
      properties:
        trunk_id:
          type: string
          description: "ID of the tenant's SIP trunk to dial out through"
        agent_id:
          type: string
          description: "ID of the AI agent to dispatch"
        to_number:
          $ref: '#/components/schemas/E164PhoneNumber'
        client_ref:
          type: string
          nullable: true
          description: |
            Closedit-side correlation identifier (lead ID, campaign ID, etc.).
            Passed through to call records, audit log, and any webhooks.
            Falls back to the trunk's `client_ref` if omitted.
        lead:
          type: object
          description: Lead context passed to the agent's dispatch metadata
          additionalProperties: true
        metadata:
          type: object
          description: Additional key/value pairs merged into dispatch metadata
          additionalProperties: true

    DispatchResponse:
      type: object
      required: [call_id, room_name, status]
      properties:
        call_id:
          type: string
          description: "LiveKit agent dispatch job ID (use to poll call detail)"
        room_name:
          type: string
          description: "LiveKit room name for this call"
        status:
          type: string
          enum: [dispatched]

    # ── Agent ─────────────────────────────────────────────────────────────────

    Agent:
      type: object
      properties:
        id:
          type: string
          description: "Agent ID (ta_<hex>)"
        tenant_id:
          type: string
        name:
          type: string
        instructions:
          type: string
          description: System prompt / instructions for the AI agent
        welcome:
          type: string
          description: First utterance spoken when the call connects
        voice_id:
          type: string
          description: TTS voice ID (ElevenLabs or Cartesia)
        avatar_id:
          type: string
          nullable: true
        default_lead_json:
          type: object
          nullable: true
          additionalProperties: true
        tool_ids:
          type: array
          nullable: true
          items:
            type: string
          description: "Explicit tool list, or null to inherit all tenant tools"
        tools:
          type: array
          description: Resolved tool summaries
          items:
            type: object
            properties:
              id:
                type: string
              name:
                type: string
              display_name:
                type: string
                nullable: true
              description:
                type: string
              enabled:
                type: boolean
              deprecated_at:
                type: string
                format: date-time
                nullable: true
        client_ref:
          type: string
          nullable: true
        kind:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    AgentCreateRequest:
      type: object
      required: [name, instructions]
      properties:
        name:
          type: string
        instructions:
          type: string
        welcome:
          type: string
          description: "Default: 'Hi, this is a test agent. Can you hear me?'"
        voice_id:
          type: string
          description: "Default: 'cgSgspJ2msm6clMCkdW9' (ElevenLabs)"
        avatar_id:
          type: string
          nullable: true
        default_lead_json:
          type: object
          nullable: true
          additionalProperties: true
        tool_ids:
          type: array
          nullable: true
          items:
            type: string
          description: "null = inherit all tenant tools; [] = no tools; [id, ...] = explicit list"
        client_ref:
          type: string
          nullable: true

    AgentUpdateRequest:
      type: object
      properties:
        name:
          type: string
        instructions:
          type: string
        welcome:
          type: string
        voice_id:
          type: string
        avatar_id:
          type: string
          nullable: true
        default_lead_json:
          type: object
          nullable: true
          additionalProperties: true
        tool_ids:
          type: array
          nullable: true
          items:
            type: string
        client_ref:
          type: string
          nullable: true

    # ── Trunk ─────────────────────────────────────────────────────────────────

    Trunk:
      type: object
      properties:
        id:
          type: string
          description: "Trunk ID (tt_<hex>)"
        tenant_id:
          type: string
        label:
          type: string
        livekit_trunk_id:
          type: string
          description: LiveKit SIP trunk identifier (ST_...)
        caller_id_number:
          $ref: '#/components/schemas/E164PhoneNumber'
        provider:
          type: string
          enum: [twilio, telnyx, bandwidth, vonage, other]
        transport:
          type: string
          enum: [tls, tcp, udp]
        host:
          type: string
          nullable: true
          description: SIP outbound FQDN
        sip_user:
          type: string
          nullable: true
        client_ref:
          type: string
          nullable: true
        status:
          type: string
          enum: [active, archived]
        settings_json:
          type: object
          nullable: true
          additionalProperties: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        archived_at:
          type: string
          format: date-time
          nullable: true

    TrunkCreateRequest:
      type: object
      required: [label, provider, host, transport, caller_id_number]
      properties:
        label:
          type: string
          maxLength: 100
        provider:
          type: string
          enum: [twilio, telnyx, bandwidth, vonage, other]
        host:
          type: string
          description: "SIP outbound FQDN, no scheme/path (e.g. voxtra-out.pstn.twilio.com)"
        transport:
          type: string
          enum: [tls, tcp, udp]
        caller_id_number:
          $ref: '#/components/schemas/E164PhoneNumber'
        sip_user:
          type: string
          minLength: 3
          maxLength: 64
          pattern: '^[a-zA-Z0-9._-]{3,64}$'
          description: "Auto-generated if omitted"
        sip_password:
          type: string
          minLength: 16
          description: "Auto-generated if omitted. Min 16 chars."
        client_ref:
          type: string
          nullable: true

    TrunkUpdateRequest:
      type: object
      properties:
        label:
          type: string
          maxLength: 100
        caller_id_number:
          $ref: '#/components/schemas/E164PhoneNumber'
        client_ref:
          type: string
          nullable: true
        settings_json:
          type: object
          nullable: true
          additionalProperties: true

    TrunkCreateResponse:
      type: object
      properties:
        trunk:
          $ref: '#/components/schemas/Trunk'
        sip_password_oneshot:
          type: string
          description: "Plaintext SIP password. Shown only once. Store now."
        warning:
          type: string

    # ── Phone Number ──────────────────────────────────────────────────────────

    PhoneNumber:
      type: object
      properties:
        number:
          $ref: '#/components/schemas/E164PhoneNumber'
        tenant_id:
          type: string
        trunk_id:
          type: string
          nullable: true
        default_agent_id:
          type: string
          nullable: true
        status:
          type: string
          enum: [active, disabled, released]
        client_ref:
          type: string
          nullable: true
        label:
          type: string
          nullable: true
        settings_json:
          type: object
          nullable: true
          additionalProperties: true
        provider_kind:
          type: string
          enum: [sip, twilio_pv]
          description: "'sip' for BYO SIP numbers, 'twilio_pv' for Twilio-imported numbers"
        twilio_account_id:
          type: string
          nullable: true
        twilio_pn_sid:
          type: string
          nullable: true
        previous_voice_url:
          type: string
          nullable: true
          description: "Kept for backwards-compat. No-op in ADR-015 Phase 2."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        released_at:
          type: string
          format: date-time
          nullable: true

    PhoneNumberAssignRequest:
      type: object
      required: [number, trunk_id]
      properties:
        number:
          $ref: '#/components/schemas/E164PhoneNumber'
        trunk_id:
          type: string
        default_agent_id:
          type: string
          nullable: true
        label:
          type: string
          nullable: true
        client_ref:
          type: string
          nullable: true

    PhoneNumberUpdateRequest:
      type: object
      properties:
        default_agent_id:
          type: string
          nullable: true
        status:
          type: string
          enum: [active, disabled]
          description: "Use DELETE to release"
        label:
          type: string
          nullable: true
        client_ref:
          type: string
          nullable: true
        settings_json:
          type: object
          nullable: true
          additionalProperties: true

    # ── Call ─────────────────────────────────────────────────────────────────

    Call:
      type: object
      properties:
        id:
          type: string
        room_name:
          type: string
        sip_call_id:
          type: string
          nullable: true
        tenant_id:
          type: string
          nullable: true
        lead_id:
          type: string
          nullable: true
        campaign_id:
          type: string
          nullable: true
        direction:
          type: string
          enum: [outbound, inbound, web]
        to_number:
          $ref: '#/components/schemas/E164PhoneNumber'
          nullable: true
        voice_id:
          type: string
          nullable: true
        status:
          type: string
          enum: [dispatched, dialing, ringing, in_progress, completed, failed, no_answer]
        client_ref:
          type: string
          nullable: true
        started_at:
          type: string
          format: date-time
          nullable: true
        ended_at:
          type: string
          format: date-time
          nullable: true
        duration_sec:
          type: integer
          nullable: true
        llm_input_tokens:
          type: integer
        llm_output_tokens:
          type: integer
        tts_chars:
          type: integer
        stt_audio_sec:
          type: number
        user_turns:
          type: integer
        interruptions:
          type: integer
        twilio_cost_usd:
          type: number
          nullable: true
        llm_cost_usd:
          type: number
          nullable: true
        tts_cost_usd:
          type: number
          nullable: true
        stt_cost_usd:
          type: number
          nullable: true
        total_cost_usd:
          type: number
          nullable: true
        egress_id:
          type: string
          nullable: true
        recording_key:
          type: string
          nullable: true
        recording_status:
          type: string
          nullable: true

    CallDetail:
      type: object
      properties:
        call:
          $ref: '#/components/schemas/Call'
        turns:
          type: array
          items:
            type: object
            description: Per-turn latency breakdown
            additionalProperties: true
        transcripts:
          type: array
          items:
            type: object
            properties:
              role:
                type: string
                enum: [agent, user]
              text:
                type: string
              turn_n:
                type: integer
                nullable: true
              ts:
                type: string
                format: date-time
        recording_url:
          type: string
          nullable: true
          description: "Pre-signed S3 URL (1 hour TTL), null if no recording"
        recording_missing:
          type: boolean
          description: "True if a recording_key exists but the S3 object is missing"

    ToolCallLog:
      type: object
      properties:
        id:
          type: string
        call_id:
          type: string
        tool_id:
          type: string
        tool_name:
          type: string
        tool_version:
          type: integer
        args_json:
          type: string
          nullable: true
        status:
          type: string
          enum: [success, error, timeout]
        http_status:
          type: integer
          nullable: true
        duration_ms:
          type: integer
          nullable: true
        response_summary:
          type: string
          nullable: true
        error_message:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time

    # ── Booking ───────────────────────────────────────────────────────────────

    Booking:
      type: object
      properties:
        id:
          type: string
          description: "Booking ID (bk_<hex>)"
        call_id:
          type: string
          nullable: true
        tenant_id:
          type: string
          nullable: true
        agent_id:
          type: string
          nullable: true
        full_name:
          type: string
        email:
          type: string
          format: email
          nullable: true
        phone:
          $ref: '#/components/schemas/E164PhoneNumber'
          nullable: true
        preferred_datetime:
          type: string
          description: "ISO 8601 datetime string or human-readable"
        timezone:
          type: string
          nullable: true
        notes:
          type: string
          nullable: true
        client_ref:
          type: string
          nullable: true
        status:
          type: string
          enum: [pending, confirmed, cancelled, completed]
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    # ── Tool ─────────────────────────────────────────────────────────────────

    Tool:
      type: object
      properties:
        id:
          type: string
          description: "Tool ID (tt_<hex>)"
        tenant_id:
          type: string
        name:
          type: string
          description: Machine name (unique per tenant, used in agent dispatch metadata)
        display_name:
          type: string
          nullable: true
        description:
          type: string
        args_schema:
          type: object
          nullable: true
          description: "JSON Schema object for the tool's arguments"
          additionalProperties: true
        url:
          type: string
          format: uri
        method:
          type: string
          enum: [POST, GET]
        auth_mode:
          type: string
          enum: [bearer, none]
        auth_secret_set:
          type: boolean
          description: "True if an auth secret is stored (secret itself is never returned)"
        timeout_ms:
          type: integer
          description: "Default: 6000"
        retry_count:
          type: integer
          description: "Default: 1"
        enabled:
          type: boolean
        version:
          type: integer
        deprecated_at:
          type: string
          format: date-time
          nullable: true
        pre_call_say:
          type: string
          nullable: true
          description: "Agent says this phrase before invoking the tool (e.g. 'Let me check that for you.')"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    ToolCreateRequest:
      type: object
      required: [name, description, url]
      properties:
        name:
          type: string
          description: "Machine name, unique per tenant"
        display_name:
          type: string
          nullable: true
        description:
          type: string
        args_schema:
          type: object
          nullable: true
          description: "JSON Schema for tool arguments. Also accepted as args_schema_json (string)."
          additionalProperties: true
        url:
          type: string
          format: uri
        method:
          type: string
          enum: [POST, GET]
          default: POST
        auth_mode:
          type: string
          enum: [bearer, none]
          default: bearer
        auth_secret:
          type: string
          description: "Bearer token Voxtra sends to your endpoint. Encrypted at rest."
        timeout_ms:
          type: integer
          default: 6000
        retry_count:
          type: integer
          default: 1
        enabled:
          type: boolean
          default: true
        pre_call_say:
          type: string
          nullable: true

    ToolUpdateRequest:
      type: object
      properties:
        name:
          type: string
        display_name:
          type: string
          nullable: true
        description:
          type: string
        args_schema:
          type: object
          nullable: true
          additionalProperties: true
        url:
          type: string
          format: uri
        method:
          type: string
          enum: [POST, GET]
        auth_mode:
          type: string
          enum: [bearer, none]
        auth_secret:
          type: string
          description: "Replaces the stored bearer token"
        timeout_ms:
          type: integer
        retry_count:
          type: integer
        pre_call_say:
          type: string
          nullable: true

    # ── Twilio Imports ────────────────────────────────────────────────────────

    TwilioImportRequest:
      type: object
      required: [phone_number, account_sid, auth_token]
      properties:
        phone_number:
          $ref: '#/components/schemas/E164PhoneNumber'
        account_sid:
          type: string
          pattern: '^AC[0-9a-fA-F]{32}$'
          description: "Twilio Account SID"
        auth_token:
          type: string
          minLength: 16
          description: "Twilio Auth Token. Encrypted at rest, never returned."
        label:
          type: string
          description: "Human label for the imported number"
        sms_enabled:
          type: boolean
          description: "Accepted for API compatibility but not acted on in Phase 2"

    TwilioImportResponse:
      type: object
      properties:
        phone_number:
          $ref: '#/components/schemas/PhoneNumber'
        twilio_account_id:
          type: string
          description: "Internal ID of the Twilio account record"
        twilio_trunk_id:
          type: string
          nullable: true
          description: "Internal ID of the provisioned Twilio trunk record"

    # ── Webhooks ──────────────────────────────────────────────────────────────

    WebhookEndpoint:
      type: object
      description: A tenant-configured HTTPS endpoint that receives signed event POSTs.
      properties:
        id:
          type: string
          description: "Endpoint ID (twe_<16hex>)"
          example: "twe_a1b2c3d4e5f6a1b2"
        tenant_id:
          type: string
        url:
          type: string
          format: uri
          description: "HTTPS endpoint URL"
          example: "https://app.closedit.ai/webhooks/voxtra"
        description:
          type: string
          nullable: true
        enabled:
          type: boolean
          default: true
        event_types:
          type: array
          items:
            type: string
          description: |
            Event types this endpoint subscribes to. ['*'] = all events.
            Valid values: call.started, call.participant_joined, call.participant_left,
            call.completed, booking.created, agent.dispatch_failed, webhook.test
          example: ["*"]
        status:
          type: string
          enum: [active, disabled, archived]
        last_delivery_at:
          type: string
          format: date-time
          nullable: true
        last_delivery_status:
          type: integer
          nullable: true
          description: "HTTP status of the last delivery attempt"
        consecutive_failures:
          type: integer
          default: 0
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        created_by:
          type: string
          nullable: true

    WebhookEndpointCreateRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: "HTTPS endpoint URL (must start with https://)"
          example: "https://app.closedit.ai/webhooks/voxtra"
        description:
          type: string
          nullable: true
          description: "Optional operator note"
        event_types:
          type: array
          items:
            type: string
          default: ["*"]
          description: |
            Event types to subscribe to. ['*'] subscribes to all current and future events.
            Specific: ['call.completed', 'booking.created']

    WebhookEndpointUpdateRequest:
      type: object
      description: "All fields are optional. Omit to leave unchanged."
      properties:
        url:
          type: string
          format: uri
        description:
          type: string
          nullable: true
        enabled:
          type: boolean
        event_types:
          type: array
          items:
            type: string

    WebhookEndpointSecretReveal:
      type: object
      description: |
        Response from create or rotate-secret. signing_secret is shown ONCE
        and cannot be retrieved again. Store it securely immediately.
      properties:
        webhook_endpoint:
          $ref: '#/components/schemas/WebhookEndpoint'
        signing_secret:
          type: string
          description: |
            Plaintext HMAC-SHA256 signing secret (32 hex chars).
            Used to verify Voxtra-Signature headers. Shown once only.
          example: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"
        _notice:
          type: string
          description: "Reminder to store the secret"
          example: "Store signing_secret securely. It will not be shown again."

    WebhookDelivery:
      type: object
      description: A single webhook delivery attempt record.
      properties:
        id:
          type: string
          description: "Delivery ID (wdl_<16hex>). Also sent as Voxtra-Webhook-Id header."
          example: "wdl_a1b2c3d4e5f6a1b2"
        endpoint_id:
          type: string
          description: "Target endpoint (twe_...)"
        tenant_id:
          type: string
        event_type:
          type: string
          example: "call.completed"
        event_payload:
          type: object
          description: "The data.object portion of the event"
          additionalProperties: true
        status:
          type: string
          enum: [pending, succeeded, failed, dead_lettered]
        attempts:
          type: integer
          description: "Number of delivery attempts made so far"
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
          description: "When the next retry will be attempted (null for terminal statuses)"
        last_http_status:
          type: integer
          nullable: true
          description: "HTTP status code of the last attempt"
        last_response_body_excerpt:
          type: string
          nullable: true
          description: "First 500 characters of the last response body"
        last_error:
          type: string
          nullable: true
          description: "Network error message if no HTTP response was received"
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    WebhookEventEnvelope:
      type: object
      description: |
        Standard event envelope delivered to tenant webhook endpoints.
        Verify authenticity using the Voxtra-Signature header before processing.
      required: [id, type, created, api_version, data]
      properties:
        id:
          type: string
          description: "Delivery ID (wdl_<16hex>). Use for replay deduplication."
          example: "wdl_a1b2c3d4e5f6a1b2"
        type:
          type: string
          description: "Event type"
          example: "call.completed"
        created:
          type: integer
          description: "Unix timestamp (seconds) when the event was created"
          example: 1716000000
        api_version:
          type: string
          description: "API version that produced this event"
          example: "2026-05-16"
        data:
          type: object
          required: [object]
          properties:
            object:
              type: object
              description: "The event payload (shape varies by event type)"
              additionalProperties: true
