openapi: 3.1.0
info:
  title: Boni Voice API
  version: 0.2.1
  summary: API-first Indian business number, inbound routing, call actions, webhooks, and call logs.
  description: >-
    Draft public prospect V1 contract for the assisted beta of Boni Voice API.

    Customers should not need to understand FreeSWITCH, LiveKit, or SIP internals for the starter flow.

    Event webhooks are notifications. Control webhooks and Call Actions are the control plane.


    Security model:

    - API keys are tenant-scoped. Number, route, call, recording, transcript, webhook, delivery, and usage resources are
    only readable/writable within the authenticated tenant.

    - Feature and plan checks can return 403 even when authentication succeeds, for example when outbound, AI-heavy,
    recording, transcription, media-stream, or high-concurrency features are not enabled on the tenant plan.

    - Write requests can return 409 for state conflicts or idempotency conflicts, 422 for semantically invalid
    route/media/webhook inputs, and 429 when tenant or platform rate limits are exceeded.

    - Customer-supplied URLs for control_webhook, media_stream, audioUrl, and webhook endpoints must be public internet
    destinations. Boni rejects localhost, loopback, private/link-local/reserved ranges, cloud metadata IPs, internal DNS
    names, DNS answers that resolve to private ranges, and redirects for outbound delivery.

    - Recording access uses authenticated tenant checks plus short-lived signed/private read URLs. Webhook payloads and
    logs should carry recording IDs or access paths, not long-lived storage URLs.


    Webhook signatures:

    - Every Boni Voice event delivery is signed with HMAC-SHA256 over the exact raw request body.

    - Headers: X-Boni-Event-Id, X-Boni-Timestamp, X-Boni-Signature-Version, and X-Boni-Signature.

    - X-Boni-Signature uses the form v1=<hex_hmac_sha256(timestamp + "." + raw_body)>.

    - Receivers should reject timestamps outside a five-minute replay window and deduplicate X-Boni-Event-Id.

    - Rotation uses an active and previous signing secret during overlap; accept either during the rotation window, then
    remove the previous secret.


    Last updated: 2026-05-26.
servers:
  - url: https://voice.boni.one/v1
security:
  - ApiKeyAuth: []
tags:
  - name: Account
    description: Tenant profile, plan, feature, and limit state for the authenticated API key.
  - name: Numbers
    description: Tenant-scoped Indian business numbers and assisted number requests.
  - name: WhatsApp Calling
    description: Assisted WhatsApp Calling enablement and route management for eligible numbers.
  - name: Routes
    description: >-
      Inbound route targets. Customer URL targets are egress-screened for SSRF, private IPs, metadata IPs, internal DNS,
      and redirects.
  - name: Voice Agents
    description: Hosted voice-agent profiles available to the authenticated tenant.
  - name: Calls
    description: Tenant-scoped call logs, timelines, recordings, transcripts, and private recording access paths.
  - name: Call Actions
    description: Idempotent active-call controls such as hangup, transfer, playback, recording, and media stream actions.
  - name: Webhooks
    description: Event webhook endpoints, signed event deliveries, retries, replay, and delivery audit logs.
  - name: Usage
    description: Tenant usage, fair-use counters, and rate/plan limit visibility.
paths:
  /me:
    get:
      tags:
        - Account
      summary: Get the authenticated tenant, plan, limits, and enabled features.
      responses:
        '200':
          description: Tenant profile.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantProfile'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /numbers:
    get:
      tags:
        - Numbers
      summary: List assigned numbers.
      parameters:
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Assigned numbers.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Number'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /numbers/{number_id}:
    get:
      tags:
        - Numbers
      summary: Inspect one number.
      parameters:
        - $ref: '#/components/parameters/NumberId'
      responses:
        '200':
          description: Number detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Number'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /number-requests:
    post:
      tags:
        - Numbers
      summary: Request a new Indian business number.
      description: Assisted/KYC-gated in the first launch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NumberRequestCreate'
      responses:
        '201':
          description: Number request created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NumberRequest'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /numbers/{number_id}/route:
    get:
      tags:
        - Routes
      summary: Get the active inbound route for a number.
      parameters:
        - $ref: '#/components/parameters/NumberId'
      responses:
        '200':
          description: Active route.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboundRoute'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
    put:
      tags:
        - Routes
      summary: Replace the inbound route for a number.
      description: Idempotent customer-facing route abstraction.
      parameters:
        - $ref: '#/components/parameters/NumberId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboundRouteUpsert'
      responses:
        '200':
          description: Route saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboundRoute'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /whatsapp-calling/numbers:
    get:
      tags:
        - WhatsApp Calling
      summary: List WhatsApp Calling numbers.
      description: WhatsApp Calling numbers use the same route, event, log, and call-action model as PSTN numbers.
      parameters:
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: WhatsApp Calling numbers.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WhatsAppCallingNumber'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /whatsapp-calling/numbers/{whatsapp_number_id}/enable:
    post:
      tags:
        - WhatsApp Calling
      summary: Enable or update WhatsApp Calling for a business phone number.
      description: Assisted/Boni-ops gated until Meta/WABA eligibility, permissions, and SIP/media configuration are verified.
      parameters:
        - $ref: '#/components/parameters/WhatsAppNumberId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WhatsAppCallingEnableRequest'
      responses:
        '202':
          description: Enablement accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WhatsAppCallingNumber'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /whatsapp-calling/numbers/{whatsapp_number_id}/route:
    get:
      tags:
        - WhatsApp Calling
      summary: Get the active WhatsApp Calling route.
      parameters:
        - $ref: '#/components/parameters/WhatsAppNumberId'
      responses:
        '200':
          description: Active route.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboundRoute'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
    put:
      tags:
        - WhatsApp Calling
      summary: Replace the route for a WhatsApp Calling number.
      description: Uses the same `InboundRouteUpsert` body as normal numbers.
      parameters:
        - $ref: '#/components/parameters/WhatsAppNumberId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboundRouteUpsert'
      responses:
        '200':
          description: Route saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboundRoute'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /voice-agents:
    get:
      tags:
        - Voice Agents
      summary: List hosted voice agents.
      description: Assisted/beta in the first launch. Hosted agents use Boni media infrastructure under the hood.
      responses:
        '200':
          description: Voice agents.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/VoiceAgent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Voice Agents
      summary: Create a hosted voice agent profile.
      description: Maps to a managed AI media route without exposing LiveKit/SIP internals.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VoiceAgentCreate'
      responses:
        '201':
          description: Voice agent created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceAgent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /voice-agents/{voice_agent_id}:
    get:
      tags:
        - Voice Agents
      summary: Inspect one hosted voice agent.
      parameters:
        - $ref: '#/components/parameters/VoiceAgentId'
      responses:
        '200':
          description: Voice agent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VoiceAgent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls:
    get:
      tags:
        - Calls
      summary: Search call logs.
      parameters:
        - name: number_id
          in: query
          schema:
            type: string
        - name: channel
          in: query
          schema:
            enum:
              - pstn
              - whatsapp_calling
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Calls.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Call'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}:
    get:
      tags:
        - Calls
      summary: Inspect one call timeline.
      parameters:
        - $ref: '#/components/parameters/CallId'
      responses:
        '200':
          description: Call detail.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/actions/hangup:
    post:
      tags:
        - Call Actions
      summary: End an active call.
      description: Terminate the call by API. Safe to retry with an Idempotency-Key.
      parameters:
        - $ref: '#/components/parameters/CallId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HangupActionRequest'
      responses:
        '202':
          description: Hangup accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallActionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/actions/transfer:
    post:
      tags:
        - Call Actions
      summary: Transfer an active call to another target or saved route.
      parameters:
        - $ref: '#/components/parameters/CallId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransferActionRequest'
      responses:
        '202':
          description: Transfer accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallActionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/actions/play:
    post:
      tags:
        - Call Actions
      summary: Play audio or text-to-speech into an active call.
      parameters:
        - $ref: '#/components/parameters/CallId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlayActionRequest'
      responses:
        '202':
          description: Playback accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallActionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/actions/recording/start:
    post:
      tags:
        - Call Actions
      summary: Start recording an active call.
      parameters:
        - $ref: '#/components/parameters/CallId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RecordingStartActionRequest'
      responses:
        '202':
          description: Recording start accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallActionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/actions/recording/stop:
    post:
      tags:
        - Call Actions
      summary: Stop recording an active call.
      parameters:
        - $ref: '#/components/parameters/CallId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '202':
          description: Recording stop accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallActionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/actions/stream/start:
    post:
      tags:
        - Call Actions
      summary: Start a WebSocket media stream on an active call.
      description: Advanced/beta media action for real-time audio integrations.
      parameters:
        - $ref: '#/components/parameters/CallId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StreamStartActionRequest'
      responses:
        '202':
          description: Stream start accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallActionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/actions/stream/stop:
    post:
      tags:
        - Call Actions
      summary: Stop a WebSocket media stream on an active call.
      parameters:
        - $ref: '#/components/parameters/CallId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StreamStopActionRequest'
      responses:
        '202':
          description: Stream stop accepted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CallActionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/recordings:
    get:
      tags:
        - Calls
      summary: List recordings and private access URLs when recording is enabled.
      parameters:
        - $ref: '#/components/parameters/CallId'
      responses:
        '200':
          description: Recordings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Recording'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /calls/{call_id}/transcripts:
    get:
      tags:
        - Calls
      summary: List transcripts and summaries when transcription is enabled.
      parameters:
        - $ref: '#/components/parameters/CallId'
      responses:
        '200':
          description: Transcripts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Transcript'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-endpoints:
    get:
      tags:
        - Webhooks
      summary: List webhook endpoints.
      responses:
        '200':
          description: Webhook endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEndpoint'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
    post:
      tags:
        - Webhooks
      summary: Create or update a webhook endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreate'
      responses:
        '201':
          description: Webhook endpoint saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpoint'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-endpoints/{webhook_endpoint_id}/test:
    post:
      tags:
        - Webhooks
      summary: Send a sample event to a webhook endpoint.
      parameters:
        - $ref: '#/components/parameters/WebhookEndpointId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                event:
                  $ref: '#/components/schemas/EventType'
      responses:
        '202':
          description: Test event queued.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-deliveries:
    get:
      tags:
        - Webhooks
      summary: List webhook delivery attempts.
      parameters:
        - name: status
          in: query
          schema:
            enum:
              - pending
              - delivered
              - failed
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Delivery attempts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookDelivery'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /webhook-deliveries/{webhook_delivery_id}/replay:
    post:
      tags:
        - Webhooks
      summary: Replay a webhook delivery.
      parameters:
        - $ref: '#/components/parameters/WebhookDeliveryId'
      responses:
        '202':
          description: Replay queued.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
  /usage:
    get:
      tags:
        - Usage
      summary: Current usage, fair-use counters, and billable add-ons.
      parameters:
        - name: from
          in: query
          schema:
            type: string
            format: date
        - name: to
          in: query
          schema:
            type: string
            format: date
      responses:
        '200':
          description: Usage summary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageSummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/RateLimited'
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Tenant-scoped Boni Voice API key. Platform/admin keys are outside this public prospect spec.
  parameters:
    NumberId:
      name: number_id
      in: path
      required: true
      schema:
        type: string
    WhatsAppNumberId:
      name: whatsapp_number_id
      in: path
      required: true
      schema:
        type: string
    CallId:
      name: call_id
      in: path
      required: true
      schema:
        type: string
    VoiceAgentId:
      name: voice_agent_id
      in: path
      required: true
      schema:
        type: string
    WebhookEndpointId:
      name: webhook_endpoint_id
      in: path
      required: true
      schema:
        type: string
    WebhookDeliveryId:
      name: webhook_delivery_id
      in: path
      required: true
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Recommended for all call-control writes. Reuse the same key only for the same intended operation and body.
      schema:
        type: string
        maxLength: 128
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
  schemas:
    TenantProfile:
      type: object
      required:
        - id
        - key
        - displayName
        - plan
        - status
        - features
        - limits
      properties:
        id:
          type: string
        key:
          type: string
        displayName:
          type: string
        plan:
          type: string
        status:
          enum:
            - provisioning
            - active
            - suspended
            - disabled
        features:
          type: object
          additionalProperties:
            type: boolean
        limits:
          type: object
          additionalProperties:
            type: number
    Number:
      type: object
      required:
        - id
        - phoneNumber
        - status
        - type
        - routeStatus
      properties:
        id:
          type: string
          example: num_123
        phoneNumber:
          type: string
          example: '+910000000001'
        label:
          type: string
        status:
          enum:
            - available
            - reserved
            - assigned
            - disabled
            - quarantine
        activationStatus:
          enum:
            - not_configured
            - carrier_ready
            - platform_ready
            - test_passed
            - live
        routeStatus:
          enum:
            - not_configured
            - configured
            - failing
            - healthy
        type:
          enum:
            - indian_business_number
            - whatsapp_calling
        capabilities:
          type: object
          properties:
            inbound:
              type: boolean
            recording:
              type: boolean
            transcription:
              type: boolean
            aiAgent:
              type: boolean
    WhatsAppCallingNumber:
      allOf:
        - $ref: '#/components/schemas/Number'
        - type: object
          properties:
            type:
              enum:
                - whatsapp_calling
            whatsapp:
              type: object
              properties:
                phoneNumberId:
                  type: string
                  description: Meta Cloud API phone number ID. Omitted or masked for non-admin users.
                wabaId:
                  type: string
                  description: WhatsApp Business Account ID. Omitted or masked for non-admin users.
                callingStatus:
                  enum:
                    - not_enabled
                    - pending
                    - active
                    - disabled
                    - failing
                consentStatus:
                  enum:
                    - unknown
                    - not_required
                    - available
                    - unavailable
                bowChatInboxId:
                  type: string
                lastHealthCheckAt:
                  type: string
                  format: date-time
    WhatsAppCallingEnableRequest:
      type: object
      required:
        - phoneNumberId
      properties:
        phoneNumberId:
          type: string
          description: Meta Cloud API phone number ID for the WhatsApp business number.
        wabaId:
          type: string
        businessPhoneNumber:
          type: string
          example: '+910000000002'
        bowChatInboxId:
          type: string
        defaultRoute:
          $ref: '#/components/schemas/InboundRouteUpsert'
        metadata:
          type: object
          additionalProperties: true
    NumberRequestCreate:
      type: object
      required:
        - useCase
      properties:
        useCase:
          type: string
        preferredCity:
          type: string
          example: Bengaluru
        fallbackPhoneNumber:
          type: string
          example: '+910000000003'
        metadata:
          type: object
          additionalProperties: true
    NumberRequest:
      type: object
      required:
        - id
        - status
      properties:
        id:
          type: string
        status:
          enum:
            - submitted
            - kyc_required
            - approved
            - rejected
            - fulfilled
        numberId:
          type: string
    InboundRouteUpsert:
      type: object
      required:
        - primary
      properties:
        primary:
          $ref: '#/components/schemas/RouteTarget'
        fallback:
          type: array
          items:
            $ref: '#/components/schemas/RouteTarget'
        events:
          type: object
          properties:
            webhookEndpointId:
              type: string
            subscribedEvents:
              type: array
              items:
                $ref: '#/components/schemas/EventType'
        recording:
          type: object
          properties:
            enabled:
              type: boolean
        metadata:
          type: object
          additionalProperties: true
    InboundRoute:
      allOf:
        - $ref: '#/components/schemas/InboundRouteUpsert'
        - type: object
          required:
            - id
            - numberId
            - status
          properties:
            id:
              type: string
              example: route_123
            numberId:
              type: string
            status:
              enum:
                - active
                - disabled
                - failing
            createdAt:
              type: string
              format: date-time
            updatedAt:
              type: string
              format: date-time
    VoiceAgentCreate:
      type: object
      required:
        - key
        - name
        - language
        - greeting
        - instructions
      properties:
        key:
          type: string
          example: support-intake
        name:
          type: string
          example: Support Intake
        language:
          type: string
          example: hi-IN
        greeting:
          type: string
        instructions:
          type: string
        fallback:
          $ref: '#/components/schemas/RouteTarget'
        metadata:
          type: object
          additionalProperties: true
    VoiceAgent:
      allOf:
        - $ref: '#/components/schemas/VoiceAgentCreate'
        - type: object
          required:
            - id
            - status
          properties:
            id:
              type: string
              example: agent_support
            status:
              enum:
                - draft
                - active
                - disabled
            createdAt:
              type: string
              format: date-time
            updatedAt:
              type: string
              format: date-time
    RouteTarget:
      type: object
      required:
        - type
      properties:
        type:
          enum:
            - control_webhook
            - ai_agent
            - phone_number
            - team
            - sip_uri
            - media_stream
            - bow_chat
            - hangup
        url:
          type: string
          format: uri
          description: >-
            Required when type is control_webhook or media_stream. Must be a public http(s) or wss destination.
            Localhost, private/link-local/reserved IP ranges, metadata endpoints, internal DNS names, DNS answers to
            private ranges, and redirects are rejected.
        timeoutMs:
          type: integer
          description: Decision timeout for control_webhook targets.
        agentId:
          type: string
          description: Required when type is ai_agent.
        phoneNumber:
          type: string
          description: Required when type is phone_number.
        timeoutSeconds:
          type: integer
        teamId:
          type: string
        sipUri:
          type: string
          example: sip:agent@example.com
        bidirectional:
          type: boolean
          description: Used by media_stream targets when two-way audio is enabled.
        contentType:
          type: string
          description: Media stream content type such as audio/x-mulaw;rate=8000.
        bowChatInboxId:
          type: string
    CallActionResult:
      type: object
      required:
        - id
        - callId
        - action
        - status
      properties:
        id:
          type: string
          example: cact_123
        callId:
          type: string
          example: call_123
        action:
          enum:
            - hangup
            - transfer
            - play
            - recording_start
            - recording_stop
            - stream_start
            - stream_stop
        status:
          enum:
            - accepted
            - queued
            - running
            - completed
            - failed
        message:
          type: string
        streamId:
          type: string
          description: Present for stream actions when available.
        createdAt:
          type: string
          format: date-time
    HangupActionRequest:
      type: object
      properties:
        reason:
          enum:
            - normal
            - rejected
            - busy
            - no_answer
          default: normal
    TransferActionRequest:
      type: object
      required:
        - target
      properties:
        target:
          $ref: '#/components/schemas/RouteTarget'
        mode:
          enum:
            - blind
            - warm
            - redirect
          default: blind
        fallback:
          $ref: '#/components/schemas/RouteTarget'
    PlayActionRequest:
      type: object
      properties:
        audioUrl:
          type: string
          format: uri
          description: >-
            Public HTTPS audio URL to play. Localhost, private IPs, metadata endpoints, internal DNS, DNS rebinding
            targets, and redirects are rejected.
        text:
          type: string
          description: Text-to-speech content to play.
        voice:
          type: string
        loop:
          type: integer
          minimum: 1
          default: 1
      anyOf:
        - required:
            - audioUrl
        - required:
            - text
    RecordingStartActionRequest:
      type: object
      properties:
        channels:
          enum:
            - mixed
            - dual
          default: mixed
        callbackWebhookEndpointId:
          type: string
    StreamStartActionRequest:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          example: wss://example.com/voice/media
          description: >-
            Public WebSocket media endpoint. Localhost, private IPs, metadata endpoints, internal DNS, DNS rebinding
            targets, and redirects are rejected.
        bidirectional:
          type: boolean
          default: false
        track:
          enum:
            - inbound
            - outbound
            - both
          default: inbound
        contentType:
          type: string
          default: audio/x-mulaw;rate=8000
        statusCallbackWebhookEndpointId:
          type: string
    StreamStopActionRequest:
      type: object
      properties:
        streamId:
          type: string
          description: If omitted, stop all streams on the call.
    Call:
      type: object
      required:
        - id
        - direction
        - from
        - to
        - status
        - startedAt
      properties:
        id:
          type: string
        direction:
          enum:
            - inbound
            - outbound
        channel:
          enum:
            - pstn
            - whatsapp_calling
        from:
          type: string
        to:
          type: string
        status:
          enum:
            - received
            - ringing
            - answered
            - no_answer
            - completed
            - failed
        startedAt:
          type: string
          format: date-time
        answeredAt:
          type: string
          format: date-time
        endedAt:
          type: string
          format: date-time
        durationSeconds:
          type: number
        hangupCause:
          type: string
    CallDetail:
      allOf:
        - $ref: '#/components/schemas/Call'
        - type: object
          properties:
            timeline:
              type: array
              items:
                type: object
                properties:
                  at:
                    type: string
                    format: date-time
                  event:
                    type: string
                  detail:
                    type: object
                    additionalProperties: true
    Recording:
      type: object
      properties:
        id:
          type: string
        durationSeconds:
          type: number
        accessUrl:
          type: string
          format: uri
          description: >-
            Short-lived signed/private read URL. Do not persist it or copy it into webhook payloads/logs; fetch a fresh
            URL when needed.
        expiresAt:
          type: string
          format: date-time
          description: Expiry for accessUrl. Starter policy targets a short TTL, typically minutes rather than hours.
    Transcript:
      type: object
      properties:
        id:
          type: string
        status:
          enum:
            - pending
            - completed
            - failed
        text:
          type: string
        summary:
          type: string
    WebhookEndpointCreate:
      type: object
      required:
        - url
        - events
      properties:
        url:
          type: string
          format: uri
          description: >-
            Public HTTPS event receiver URL. Boni verifies it against the outbound egress policy and rejects localhost,
            private IPs, metadata endpoints, internal DNS, DNS rebinding targets, and redirects.
        events:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
        enabled:
          type: boolean
          default: true
    WebhookEndpoint:
      allOf:
        - $ref: '#/components/schemas/WebhookEndpointCreate'
        - type: object
          required:
            - id
            - signingSecretPrefix
          properties:
            id:
              type: string
            signingSecretPrefix:
              type: string
              description: >-
                Display-only prefix of the current webhook signing secret. The full secret is shown once at creation or
                rotated through the customer console.
    WebhookDelivery:
      type: object
      properties:
        id:
          type: string
        event:
          $ref: '#/components/schemas/EventType'
        status:
          enum:
            - pending
            - delivered
            - failed
        attemptCount:
          type: integer
        lastStatusCode:
          type: integer
        lastError:
          type: string
        createdAt:
          type: string
          format: date-time
        eventId:
          type: string
          description: Delivery event ID used for replay deduplication.
        nextRetryAt:
          type: string
          format: date-time
        signature:
          $ref: '#/components/schemas/WebhookSignature'
    UsageSummary:
      type: object
      properties:
        window:
          type: object
          properties:
            from:
              type: string
              format: date
            to:
              type: string
              format: date
        totals:
          type: array
          items:
            type: object
            properties:
              eventType:
                type: string
              amount:
                type: number
              unit:
                type: string
              billableAmountInr:
                type: number
    EventType:
      type: string
      enum:
        - call.inbound.received
        - call.ringing
        - call.answered
        - call.no_answer
        - call.completed
        - call.failed
        - call.action.accepted
        - call.transferred
        - whatsapp_calling.enabled
        - whatsapp_calling.permission.updated
        - stream.started
        - stream.stopped
        - recording.available
        - transcript.available
        - ai.call.completed
        - usage.updated
        - number.activated
        - number.suspended
    ErrorDetail:
      type: object
      properties:
        field:
          type: string
        message:
          type: string
        code:
          type: string
    ErrorResponse:
      type: object
      required:
        - success
        - error
      properties:
        success:
          type: boolean
          example: false
        error:
          type: string
          example: validation_failed
        message:
          type: string
        requestId:
          type: string
          description: Request identifier for support and audit correlation.
        details:
          type: array
          items:
            $ref: '#/components/schemas/ErrorDetail'
    WebhookSignature:
      type: object
      description: Headers sent with Boni Voice webhook deliveries. Verify against the raw body before JSON parsing.
      required:
        - eventId
        - timestamp
        - signatureVersion
        - signature
      properties:
        eventId:
          type: string
          description: Value of X-Boni-Event-Id. Store it for replay deduplication.
        timestamp:
          type: integer
          description: Unix seconds from X-Boni-Timestamp. Reject outside the replay window.
        signatureVersion:
          type: string
          example: v1
          description: Value of X-Boni-Signature-Version.
        signature:
          type: string
          example: v1=9f86d081884c7d659a2feaa0c55ad015
          description: Value of X-Boni-Signature.
        replayWindowSeconds:
          type: integer
          default: 300
    WebhookEventEnvelope:
      type: object
      required:
        - event_id
        - event_type
        - timestamp
        - data
      properties:
        event_id:
          type: string
          description: Stable event ID also sent in X-Boni-Event-Id.
        event_type:
          $ref: '#/components/schemas/EventType'
        timestamp:
          type: integer
          description: Unix seconds.
        data:
          type: object
          additionalProperties: true
  responses:
    BadRequest:
      description: Malformed request or schema validation failed.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Missing, invalid, expired, or revoked API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: Authenticated key is not allowed for this tenant, resource, feature, plan, or operation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: Resource not found in the authenticated tenant scope.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Conflict:
      description: Request conflicts with current state, existing idempotency result, route state, or recording availability.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnprocessableEntity:
      description: Semantically invalid request, including blocked unsafe outbound URLs or route targets.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    RateLimited:
      description: Tenant or platform rate limit exceeded.
      headers:
        Retry-After:
          description: Seconds to wait before retrying when available.
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
