openapi: 3.1.0
info:
  title: Kata Voice API
  version: "1"
  contact:
    name: Kata.ai
    url: https://kata.ai
  description: |
    Place AI voice calls from your backend, talk to your agent from a browser,
    and receive every call event on your own webhook.

    You only talk to this API. The voice agent, speech models and telephony
    sit behind it, and a key can only reach its own tenant's calls.

    | | |
    |---|---|
    | Base URL | `https://voice-api.kata.ai` |
    | Auth header | `X-Partner-Key` |
    | Format | JSON, `snake_case` |

    # Onboarding

    ## What you send Kata

    | Item | Needed when |
    |---|---|
    | Call script and business requirements | Always. There is no generic default conversation. |
    | HTTPS webhook URL | You want call events pushed to you (recommended) |
    | Phone numbers (DIDs) for inbound calls | You want inbound calls |
    | SIP trunk details and your PBX/SBC public IPs | Your own PBX or carrier connects to the agent |
    | Human-agent transfer number or SIP URI | You want the agent to hand calls to a person |

    Kata does not buy or port numbers. That is a telco step done before onboarding.

    ## What Kata sends you

    Out of band, never by email in plain text:

    - Your `X-Partner-Key`. A tenant can hold several active keys, so you can rotate without downtime.
    - Your webhook secret, used to verify `X-Kata-Signature`.
    - Your limits, set by Kata: concurrent calls, calls per day, maximum call duration, and your credit budget if prepaid.
    - Whether telephony (outbound/inbound phone calls) is enabled for your tenant.

    ## Checklist

    1. `GET /v1/credits` returns 200 with your key.
    2. `POST /v1/test-sessions`, join the room, talk to the agent, then `POST /v1/test-sessions/{call_id}/end`.
    3. Your webhook receives `call.started`, `transcript` and `call.ended` for that session, and the signature verifies.
    4. Once telephony is enabled: one real outbound call succeeds end to end.

    # Calls

    ## Lifecycle

    `POST /v1/calls` returns `200 {"call_id": "...", "status": "dialing"}` as soon as
    the call is queued. The rest of the call happens asynchronously:

    | Status | Meaning | Final |
    |---|---|---|
    | `dialing` | Accepted, the agent is dialing | |
    | `connected` | The callee answered and the agent is talking | |
    | `ended` | The call finished normally | yes |
    | `no_answer` | Busy, declined, or not answered | yes |
    | `failed` | Could not be placed; see `detail` | yes |

    Test sessions start as `test-session-started` and end as `ended`.

    **A 200 does not mean the phone rang.** If telephony is not enabled for your
    tenant, the call is still accepted and then ends as `failed` with
    `detail: "no outbound trunk configured"`. Use test sessions until Kata confirms
    telephony is on.

    `call_id` looks like `call-20261015-021000-1a2b3c4d5e6f7a8b`. Treat it as an opaque string.

    ## Webhooks or polling

    Prefer webhooks. Use `GET /v1/calls/{call_id}` and `.../transcript` to catch up
    after a missed delivery or to reconcile.

    # Browser test sessions

    `POST /v1/test-sessions` creates a room with your agent and returns `livekit_url`
    and `token`. Join it with any LiveKit client SDK (`room.connect(livekit_url, token)`),
    or paste both into [meet.livekit.io](https://meet.livekit.io) under **Custom**.

    Test sessions count toward your concurrency and daily limits and are metered
    like calls. Always end them when done.

    # Limits and credits

    Kata sets every limit and the credit budget for your tenant during
    onboarding. The API only reports them; nothing in it can change them.
    To change a value, ask your Kata contact.

    ## Guardrails

    | Guardrail | When hit |
    |---|---|
    | Concurrent calls | `429 {"error": "too many concurrent calls"}` |
    | Calls per day | `429 {"error": "daily call limit reached"}`. Counts outbound calls and test sessions since midnight in your tenant's timezone. |
    | Call duration | The agent ends the call, inbound included |

    Do not retry a 429 in a tight loop. Back off, and check what is placing calls.

    ## Credit budget

    If your contract is prepaid, Kata meters your tenant against a credit
    balance. `GET /v1/credits` is read-only and shows the balance and rate Kata
    configured.

    - Before every outbound call and test session the balance is checked. With
      less than one billing block left the request returns
      `402 {"error": "credit exhausted"}` and nothing is placed.
    - After the call, the duration is rounded up to whole billing blocks
      (`billing_block_seconds`) and `credits_per_block` is deducted per block.
    - `metered: false` means usage is billed under your contract instead of a
      prepaid balance. The guardrails still apply.

    Contact Kata for a top-up.

    # Errors

    Every error body is `{"error": "message"}`.

    | Status | Meaning | What to do |
    |---|---|---|
    | 400 | Invalid body, e.g. missing `phone_number` | Fix the request |
    | 401 | Missing, unknown or revoked `X-Partner-Key` | Check the key |
    | 402 | Credit exhausted | Top up |
    | 403 | Tenant suspended | Contact Kata |
    | 404 | No such call for your tenant | Check the `call_id` |
    | 429 | Concurrency or daily limit reached | Back off |
    | 500 | Dispatch failed | Retry with backoff |
    | 503 | Platform dependency unavailable, or the credit check could not run | Retry with backoff; nothing was placed |

    # Webhooks

    Give Kata one HTTPS endpoint. Every event is a `POST` with this envelope:

    ```json
    {
      "id": "6f1c0e9a-...",
      "event": "call.ended",
      "created_at": "2026-10-15T02:12:31Z",
      "tenant_id": "...",
      "call_id": "call-20261015-021000-1a2b3c4d5e6f7a8b",
      "data": { }
    }
    ```

    | Event | Sent | `data` |
    |---|---|---|
    | `call.started` | Once, when the call connects | `status`, `phone_number`, `kind` (`outbound`, `inbound`, `test_session`) |
    | `transcript` | Every spoken turn, in real time | `turn`: `turn_index`, `role` (`user`, `assistant`), `text`, `recorded_at` |
    | `handover` | The agent tried to transfer to a human | `target`, `result` (`transferred`, `failed`), `reason` |
    | `call.ended` | Once, after hangup | `status`, `duration_s`, `outcome` (includes `transferred`) |

    Full payload schemas are in the **Webhooks** section of this reference.

    ## Headers

    | Header | Use |
    |---|---|
    | `X-Kata-Event` | Event name |
    | `X-Kata-Event-Id` | Same as `id`. Stable across retries and manual resends: deduplicate on it |
    | `X-Kata-Delivery` | Delivery attempt record, quote it in support requests |
    | `X-Kata-Signature` | `t=<unix seconds>,v1=<hex>` |

    ## Verifying the signature

    `v1` is HMAC-SHA256 of `<t>.<raw request body>` with your webhook secret.
    Verify against the raw bytes before parsing JSON, and reject timestamps older
    than 5 minutes.

    ```js
    const crypto = require("crypto");
    function verify(secret, header, rawBody) {
      const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || "");
      if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false;
      const expect = crypto.createHmac("sha256", secret).update(`${m[1]}.${rawBody}`).digest("hex");
      return crypto.timingSafeEqual(Buffer.from(expect), Buffer.from(m[2]));
    }
    ```

    ```go
    func verify(secret, header string, body []byte) bool {
        var t int64
        var sig string
        if _, err := fmt.Sscanf(header, "t=%d,v1=%s", &t, &sig); err != nil ||
            time.Since(time.Unix(t, 0)).Abs() > 5*time.Minute {
            return false
        }
        mac := hmac.New(sha256.New, []byte(secret))
        fmt.Fprintf(mac, "%d.%s", t, body)
        return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(sig))
    }
    ```

    ## Delivery and retries

    Return any 2xx within 10 seconds. Anything else is retried after 10 s, 30 s,
    2 min, 10 min, 30 min, 1 h, 2 h and 6 h (9 attempts in total). Events can arrive
    out of order or more than once: order by `created_at`, deduplicate by
    `X-Kata-Event-Id`. The endpoint must be HTTPS on a public address.

    # Network and allowlisting

    | Direction | Address |
    |---|---|
    | Your systems → Kata API | `https://voice-api.kata.ai`, TLS on port 443 |
    | Kata → your webhook | `34.101.235.42` and `34.50.87.39` |

    Allowlist **both** egress IPs; either can be used for any request. They are
    shared Kata platform egress addresses, not dedicated to your tenant. Kata
    will notify you before they change.

    # PBX / SIP integration

    Connecting your PBX or carrier trunk directly to the agent is enabled per
    tenant during onboarding.

    | Item | Value |
    |---|---|
    | SIP signaling | `34.101.208.203`, port `5090`, UDP or TCP |
    | Media (RTP) | UDP `10000–20000` |
    | Allowlist | Send Kata the public IPs of your PBX/SBC. SIP from unknown IPs is dropped. |
    | Inbound numbers | Kata maps your DIDs to your tenant's agent |

    Inbound calls have no API step: the caller dials your DID and the agent answers.
    You receive the same webhook events, with `kind: inbound`.
servers:
  - url: https://voice-api.kata.ai
    description: Production
security:
  - PartnerKey: []
tags:
  - name: Calls
    description: Place phone calls and read their status and transcript
  - name: Test sessions
    description: Talk to your agent from a browser, no phone line needed
  - name: Credits
    description: Credit balance for metered tenants
paths:
  /v1/calls:
    post:
      tags: [Calls]
      operationId: placeCall
      summary: Place an outbound call
      description: |
        Queues an outbound call and returns immediately with status `dialing`.
        Follow the call through webhooks or `GET /v1/calls/{call_id}`.
        Requires telephony to be enabled for your tenant; otherwise the call ends as `failed`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone_number]
              properties:
                phone_number:
                  type: string
                  description: Number to call, in international format.
                  example: "+6281234567890"
                note:
                  type: string
                  description: Free text stored with the call, e.g. your own reference.
                  example: order 1234
                sip_call_to:
                  type: string
                  description: Dial target when it differs from `phone_number`, e.g. an extension behind your PBX.
      responses:
        "200":
          description: Call queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  call_id: { type: string, example: call-20261015-021000-1a2b3c4d5e6f7a8b }
                  status: { type: string, example: dialing }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooMany" }
        "500": { $ref: "#/components/responses/DispatchFailed" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /v1/calls/{call_id}:
    get:
      tags: [Calls]
      operationId: getCall
      summary: Get call status
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Call
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Call" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/calls/{call_id}/transcript:
    get:
      tags: [Calls]
      operationId: getTranscript
      summary: Get call transcript
      description: Returns up to 500 turns in spoken order. Works during the call and after it ends.
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Transcript
          content:
            application/json:
              schema:
                type: object
                properties:
                  call_id: { type: string }
                  turns:
                    type: array
                    items: { $ref: "#/components/schemas/Turn" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/test-sessions:
    post:
      tags: [Test sessions]
      operationId: startTestSession
      summary: Start a browser test session
      description: |
        Creates a room with your agent and returns what a LiveKit client needs to join it.
        Counts toward your limits and credits like a call.
      responses:
        "200":
          description: Session created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TestSession" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/CreditExhausted" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooMany" }
        "500": { $ref: "#/components/responses/DispatchFailed" }
        "503": { $ref: "#/components/responses/Unavailable" }
  /v1/test-sessions/{call_id}/end:
    post:
      tags: [Test sessions]
      operationId: endTestSession
      summary: End a test session
      parameters:
        - $ref: "#/components/parameters/CallId"
      responses:
        "200":
          description: Ended
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/credits:
    get:
      tags: [Credits]
      operationId: getCredits
      summary: Get credit balance
      description: Read-only. Returns `metered=false` and nothing else when your tenant is not on a prepaid credit balance.
      responses:
        "200":
          description: Balance
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Credits" }
              examples:
                unmetered:
                  value: { metered: false }
                metered:
                  value:
                    metered: true
                    credit: 1000
                    credit_expire: "2026-12-31T16:59:59Z"
                    free_credit: 200
                    free_credit_expire: "2026-11-30T16:59:59Z"
                    remaining: 1200
                    credits_per_block: 1
                    billing_block_seconds: 60
                    estimated_minutes_remaining: 1200
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/Unavailable" }
webhooks:
  call.started:
    post:
      summary: call.started
      description: Sent once, when the call connects.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    event: { const: call.started }
                    data:
                      type: object
                      properties:
                        status: { type: string, example: connected }
                        phone_number: { type: string, example: "+6281234567890" }
                        kind: { type: string, enum: [outbound, inbound, test_session] }
      responses:
        "200": { description: Return any 2xx within 10 seconds }
  transcript:
    post:
      summary: transcript
      description: Sent for every spoken turn, in real time.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    event: { const: transcript }
                    data:
                      type: object
                      properties:
                        turn: { $ref: "#/components/schemas/Turn" }
      responses:
        "200": { description: Return any 2xx within 10 seconds }
  handover:
    post:
      summary: handover
      description: Sent when the agent tries to transfer the caller to a human.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    event: { const: handover }
                    data:
                      type: object
                      properties:
                        target: { type: string, description: Transfer destination configured for your tenant }
                        result: { type: string, enum: [transferred, failed] }
                        reason: { type: string, description: Why the agent decided to transfer }
      responses:
        "200": { description: Return any 2xx within 10 seconds }
  call.ended:
    post:
      summary: call.ended
      description: Sent once, after the call ends.
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/WebhookEnvelope"
                - type: object
                  properties:
                    event: { const: call.ended }
                    data:
                      type: object
                      properties:
                        status: { type: string, enum: [ended, failed, no_answer] }
                        duration_s: { type: integer, example: 151 }
                        outcome:
                          type: object
                          description: Call summary. Ignore fields you do not recognise; more may be added.
                          properties:
                            transferred: { type: boolean, description: True if the call was handed to a human }
                          additionalProperties: true
      responses:
        "200": { description: Return any 2xx within 10 seconds }
components:
  securitySchemes:
    PartnerKey:
      type: apiKey
      in: header
      name: X-Partner-Key
      description: Tenant key issued by Kata. Keep it server-side.
  parameters:
    CallId:
      name: call_id
      in: path
      required: true
      schema: { type: string }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
    Call:
      type: object
      properties:
        call_id: { type: string }
        status:
          type: string
          description: "`dialing`, `connected`, `ended`, `no_answer`, `failed`; test sessions start as `test-session-started`"
          example: ended
        detail: { type: string, description: Reason for a `failed` call, empty otherwise }
        started_at: { type: string, format: date-time }
        ended_at: { type: string, format: date-time }
    Turn:
      type: object
      properties:
        turn_index: { type: integer }
        role: { type: string, enum: [user, assistant] }
        text: { type: string }
        recorded_at: { type: string, format: date-time }
    TestSession:
      type: object
      properties:
        call_id: { type: string }
        room: { type: string }
        livekit_url: { type: string, example: "wss://..." }
        token: { type: string, description: LiveKit participant token for the browser or SDK client }
    Credits:
      type: object
      properties:
        metered: { type: boolean }
        credit: { type: number }
        credit_expire: { type: string }
        free_credit: { type: number }
        free_credit_expire: { type: string }
        remaining: { type: number }
        credits_per_block: { type: number }
        billing_block_seconds: { type: integer }
        estimated_minutes_remaining: { type: number }
    WebhookEnvelope:
      type: object
      properties:
        id: { type: string, description: Event id, equal to the X-Kata-Event-Id header }
        event: { type: string }
        created_at: { type: string, format: date-time }
        tenant_id: { type: string }
        call_id: { type: string }
        data: { type: object }
  responses:
    BadRequest:
      description: Invalid request body
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { error: phone_number is required } } }
    Unauthorized:
      description: Missing, unknown or revoked partner key
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { error: unauthorized } } }
    CreditExhausted:
      description: Less than one billing block of credit left; nothing was placed
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { error: credit exhausted } } }
    Forbidden:
      description: Tenant suspended
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { error: tenant suspended } } }
    NotFound:
      description: No such call for this tenant
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { error: not found } } }
    TooMany:
      description: Concurrency or daily limit reached
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { error: too many concurrent calls } } }
    DispatchFailed:
      description: The agent could not be dispatched
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unavailable:
      description: A platform dependency is unavailable or the credit check could not run; nothing was placed
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { error: credit check failed } } }
