openapi: 3.1.0
info:
  title: Kalba.ai Public API
  version: "1.0.0"
  description: |
    AI-powered B2B cold-calling platform — public API v1.

    **Authentication:** `Authorization: Bearer kalba_live_...` or `kalba_test_...`.
    Keys in query strings are rejected. Secrets are shown exactly once at creation.

    **Modes:** `kalba_test_` keys hit identical endpoints with identical validation but
    never touch telephony. Test calls advance `queued → in_progress → completed` on a
    ~1-minute sweep. Test outcome is deterministic by the LAST DIGIT of `to`:
    `0-2 → booked`, `3-5 → interested`, `6-7 → not_interested`, `8-9 → no_answer`.

    **Rate limits:** per-key fixed windows (default 60 reads / 10 writes per minute).
    Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`,
    `X-RateLimit-Reset` (unix seconds) and `X-Request-Id`.

    **Idempotency:** `Idempotency-Key` header (≤255 chars) on POST /v1/calls and
    POST /v1/campaigns/{id}/leads; replays return the stored response plus
    `Idempotent-Replay: true`. Same key + different body → 409 `idempotency_key_reused`.

    **Errors:** single shape `{"error":{"type","code","message","param?","request_id"}}`.
    Machine codes: invalid_phone, dnc_blocked, outside_calling_hours, reserved_variable,
    campaign_not_active, no_agent_configured, transcript_not_ready, analysis_not_ready,
    key_revoked, read_only_key, rate_limited, quota_exceeded, concurrency_exhausted,
    idempotency_key_reused, batch_too_large, validation_error, not_found,
    authentication, internal_error.

    **Webhooks:** register endpoints via /v1/webhook-endpoints (HTTPS, public hosts only).
    Bodies are Event envelopes signed with `Kalba-Signature: t=<unix>,v1=<hmac-sha256 hex>`
    over `t + "." + rawBody` (±300 s tolerance). Retries: 1m, 5m, 30m, 2h, 12h, then
    exhausted — replay any time via GET /v1/events. Consumers must tolerate unknown
    fields and event types (additive versioning; breaking changes ⇒ /v2).
servers:
  - url: https://kalba.ai/api
security:
  - bearerAuth: []
tags:
  - name: Calls
  - name: Leads
  - name: Campaigns
  - name: DNC
  - name: Events
  - name: Webhooks

paths:
  /v1/ping:
    get:
      operationId: ping
      tags: [Events]
      summary: Verify a key
      responses:
        "200":
          description: Key is valid.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  mode: { $ref: "#/components/schemas/Mode" }
                  key_prefix: { type: string }
        default: { $ref: "#/components/responses/Error" }

  /v1/calls:
    post:
      operationId: createCall
      tags: [Calls]
      summary: Trigger a direct outbound call (write scope)
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to:
                  type: string
                  description: "E.164 number. Non-LT EU prefixes are accepted and flagged `international: true`."
                  example: "+37060000000"
                agent_id:
                  type: string
                  description: Optional agent override (`agt_...`); default = account's default agent.
                variables:
                  type: object
                  additionalProperties: { type: string }
                  description: Merged into dynamic_variables. ≤20 keys, ≤1 KB total. `lead_name` / `campaign_name` are reserved.
                metadata:
                  type: object
                  description: Opaque correlation data, ≤10 keys, ≤2 KB. Echoed on the object and all its events.
      responses:
        "202":
          description: Call accepted (status `queued`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Call" }
        default: { $ref: "#/components/responses/Error" }
    get:
      operationId: listCalls
      tags: [Calls]
      summary: List calls (newest first)
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - { name: status, in: query, schema: { type: string, enum: [queued, in_progress, completed, failed] } }
        - { name: campaign_id, in: query, schema: { type: string } }
        - { name: created_gte, in: query, schema: { type: string, format: date-time } }
        - { name: created_lte, in: query, schema: { type: string, format: date-time } }
      responses:
        "200":
          description: Paginated calls.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Call" } }
                  has_more: { type: boolean }
        default: { $ref: "#/components/responses/Error" }

  /v1/calls/{id}:
    get:
      operationId: getCall
      tags: [Calls]
      summary: Retrieve a call
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The call.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Call" }
        default: { $ref: "#/components/responses/Error" }

  /v1/calls/{id}/transcript:
    get:
      operationId: getCallTranscript
      tags: [Calls]
      summary: Retrieve a call transcript (404 transcript_not_ready until available)
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: Transcript turns.
          content:
            application/json:
              schema:
                type: object
                properties:
                  call_id: { type: string }
                  language: { type: string, example: lt }
                  turns:
                    type: array
                    items:
                      type: object
                      properties:
                        role: { type: string, enum: [agent, caller] }
                        text: { type: string }
                        t_sec: { type: number }
        default: { $ref: "#/components/responses/Error" }

  /v1/calls/{id}/analysis:
    get:
      operationId: getCallAnalysis
      tags: [Calls]
      summary: Retrieve post-call analysis (404 analysis_not_ready until call.analyzed)
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: Sanitized Claude analysis.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Analysis" }
        default: { $ref: "#/components/responses/Error" }

  /v1/campaigns:
    get:
      operationId: listCampaigns
      tags: [Campaigns]
      summary: List campaigns (picker list; no settings exposed)
      responses:
        "200":
          description: Campaigns.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Campaign" } }
                  has_more: { type: boolean }
        default: { $ref: "#/components/responses/Error" }

  /v1/campaigns/{id}/leads:
    post:
      operationId: createLeads
      tags: [Leads]
      summary: Feed leads into a campaign (single object or batch ≤500; write scope)
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/LeadInput"
                - type: object
                  required: [leads]
                  properties:
                    leads:
                      type: array
                      maxItems: 500
                      items: { $ref: "#/components/schemas/LeadInput" }
      responses:
        "200":
          description: Always 200 for batches — per-item statuses inside.
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        index: { type: integer }
                        status: { type: string, enum: [created, duplicate, dnc_blocked] }
                        lead: { $ref: "#/components/schemas/Lead" }
                        lead_id: { type: string }
                  created: { type: integer }
                  duplicates: { type: integer }
                  blocked: { type: integer }
        default: { $ref: "#/components/responses/Error" }
    get:
      operationId: listCampaignLeads
      tags: [Leads]
      summary: List a campaign's leads
      parameters:
        - $ref: "#/components/parameters/PathId"
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/StartingAfter"
        - { name: status, in: query, schema: { $ref: "#/components/schemas/LeadStatus" } }
      responses:
        "200":
          description: Paginated leads.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Lead" } }
                  has_more: { type: boolean }
        default: { $ref: "#/components/responses/Error" }

  /v1/leads/{id}:
    get:
      operationId: getLead
      tags: [Leads]
      summary: Retrieve a lead
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: The lead.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Lead" }
        default: { $ref: "#/components/responses/Error" }

  /v1/dnc:
    post:
      operationId: addDnc
      tags: [DNC]
      summary: Add a number to the platform-wide DNC list (write scope; idempotent)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone: { type: string, example: "+37060000000" }
                reason: { type: string, example: "customer email opt-out" }
      responses:
        "201":
          description: Newly listed (200 when already listed).
          content:
            application/json:
              schema:
                type: object
                properties:
                  listed: { type: boolean }
        default: { $ref: "#/components/responses/Error" }

  /v1/dnc/{phone}:
    get:
      operationId: checkDnc
      tags: [DNC]
      summary: Check whether a number is DNC-listed
      parameters:
        - { name: phone, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Listing status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone: { type: string }
                  listed: { type: boolean }
                  listed_at: { type: string, format: date-time }
        default: { $ref: "#/components/responses/Error" }

  /v1/events:
    get:
      operationId: listEvents
      tags: [Events]
      summary: Cursor replay of the account event ledger (oldest-first after cursor)
      description: |
        The recovery path AND a full polling integration path. Without `after`:
        the most recent 100 events, oldest-first. Events are immutable and kept ≥90 days.
      parameters:
        - { name: after, in: query, schema: { type: string }, description: "Event id cursor (evt_...)" }
        - { name: types, in: query, schema: { type: string }, description: "Comma-separated event types" }
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: Events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/Event" } }
                  has_more: { type: boolean }
        default: { $ref: "#/components/responses/Error" }

  /v1/webhook-endpoints:
    post:
      operationId: createWebhookEndpoint
      tags: [Webhooks]
      summary: Register a webhook endpoint (write scope; secret shown once)
      description: HTTPS only; hosts resolving to private/loopback ranges are rejected. Max 5 endpoints per account per mode. Mode is inherited from the creating key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, example: "https://erp.example.lt/kalba-hook" }
                types:
                  type: array
                  items: { $ref: "#/components/schemas/EventType" }
                  description: Defaults to ["*"] (all types).
      responses:
        "201":
          description: Endpoint + one-time signing secret.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/WebhookEndpoint"
                  - type: object
                    properties:
                      secret: { type: string, example: "whsec_0123456789abcdef0123456789abcdef01234567" }
        default: { $ref: "#/components/responses/Error" }
    get:
      operationId: listWebhookEndpoints
      tags: [Webhooks]
      summary: List webhook endpoints for the key's mode (no secrets)
      responses:
        "200":
          description: Endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/WebhookEndpoint" } }
                  has_more: { type: boolean }
        default: { $ref: "#/components/responses/Error" }

  /v1/webhook-endpoints/{id}:
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      parameters: [{ $ref: "#/components/parameters/PathId" }]
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  object: { type: string, const: webhook_endpoint }
                  deleted: { type: boolean }
        default: { $ref: "#/components/responses/Error" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: kalba_live_<40 hex> or kalba_test_<40 hex>

  parameters:
    PathId:
      name: id
      in: path
      required: true
      schema: { type: string }
      description: Prefixed public id (call_..., lead_..., cmp_..., evt_..., we_...).
    Limit:
      name: limit
      in: query
      schema: { type: integer, default: 20, maximum: 100 }
    StartingAfter:
      name: starting_after
      in: query
      schema: { type: string }
      description: Public id of the last object on the previous page.
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema: { type: string, maxLength: 255 }

  responses:
    Error:
      description: Error (single shape for 4xx/5xx).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Mode:
      type: string
      enum: [live, test]

    EventType:
      type: string
      enum:
        - call.initiated
        - call.completed
        - call.failed
        - call.analyzed
        - booking.created
        - booking.confirmed
        - booking.canceled
        - lead.opted_out
        - "*"

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            type:
              type: string
              enum: [invalid_request, authentication, permission, not_found, conflict, rate_limit, api_error]
            code:
              type: string
              enum:
                - invalid_phone
                - dnc_blocked
                - outside_calling_hours
                - reserved_variable
                - campaign_not_active
                - no_agent_configured
                - transcript_not_ready
                - analysis_not_ready
                - key_revoked
                - read_only_key
                - rate_limited
                - quota_exceeded
                - concurrency_exhausted
                - idempotency_key_reused
                - batch_too_large
                - validation_error
                - not_found
                - authentication
                - internal_error
            message: { type: string }
            param: { type: string }
            request_id: { type: string, example: "req_6f1a..." }

    Call:
      type: object
      properties:
        id: { type: string, example: "call_9a3f..." }
        object: { type: string, const: call }
        mode: { $ref: "#/components/schemas/Mode" }
        direction: { type: string, enum: [outbound, inbound] }
        source: { type: string, enum: [api, dispatch, dashboard, inbound] }
        to: { type: string, example: "+37060000000" }
        campaign_id: { type: [string, "null"] }
        lead_id: { type: [string, "null"] }
        agent_id: { type: [string, "null"] }
        status: { type: string, enum: [queued, in_progress, completed, failed] }
        outcome:
          type: [string, "null"]
          enum: [booked, interested, not_interested, callback, voicemail, no_answer, failed, unknown, null]
        duration_sec: { type: [integer, "null"] }
        answered: { type: boolean }
        interest_score: { type: [integer, "null"] }
        booked: { type: boolean }
        analysis_status: { type: string, enum: [pending, ready, failed] }
        metadata: { type: [object, "null"] }
        created: { type: string, format: date-time }
        ended_at: { type: [string, "null"], format: date-time }

    Analysis:
      type: object
      properties:
        summary: { type: string }
        objections: { type: array, items: { type: string } }
        sentiment: { type: string }
        quality_score: { type: integer }
        booked_detected: { type: boolean }
        language: { type: string, example: lt }

    LeadStatus:
      type: string
      enum: [pending, scheduled, called, completed, opted_out, exhausted]

    LeadInput:
      type: object
      required: [phone]
      properties:
        phone: { type: string, example: "+37061234567" }
        name: { type: string }
        company: { type: string }
        metadata: { type: object }

    Lead:
      type: object
      properties:
        id: { type: string, example: "lead_4b2c..." }
        object: { type: string, const: lead }
        campaign_id: { type: string }
        phone: { type: string }
        name: { type: [string, "null"] }
        company: { type: [string, "null"] }
        status: { $ref: "#/components/schemas/LeadStatus" }
        attempts: { type: integer }
        last_call_id: { type: [string, "null"] }
        metadata: { type: [object, "null"] }
        created: { type: string, format: date-time }

    Campaign:
      type: object
      properties:
        id: { type: string, example: "cmp_7d1e..." }
        object: { type: string, const: campaign }
        name: { type: string }
        status: { type: string }
        is_sandbox: { type: boolean }

    Event:
      type: object
      description: Ledger row = wire format = webhook body.
      properties:
        id: { type: string, example: "evt_1c9b..." }
        object: { type: string, const: event }
        type: { $ref: "#/components/schemas/EventType" }
        created: { type: string, format: date-time }
        mode: { $ref: "#/components/schemas/Mode" }
        data:
          type: object
          description: 'For call.* events — { "call": Call }. Consumers must tolerate unknown keys.'

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, example: "we_8e4d..." }
        object: { type: string, const: webhook_endpoint }
        url: { type: string, format: uri }
        types: { type: array, items: { $ref: "#/components/schemas/EventType" } }
        mode: { $ref: "#/components/schemas/Mode" }
        disabled: { type: boolean, description: Auto-disabled after 10+ consecutive failures with no success for 3 days. }
        created: { type: string, format: date-time }
