openapi: 3.1.0
info:
  title: CallCloud Agent Dialer API
  version: "0.6.0"
  description: |
    Give an AI agent a phone line. Dials real numbers, screens out answering machines, and connects
    live humans to a browser leg over WebRTC.

    Two things shape every integration:

    1. **Somebody must be online in a browser before humans can be connected.** A run with nobody
       online screens correctly and then has nowhere to put the humans.
    2. **Dials are prepaid.** A zero balance means `POST /dial` refuses with 402.

    There is no AI voice: a real person has the conversation. There is no option to ring a phone,
    because dialing one after the prospect answers makes them wait through your ring time.

    The same key also manages the CallCloud WEB dialer's data (the rep-driven parallel dialer):
    lead lists, contacts, draft campaigns, read-only performance analytics, and the company/account
    view - everything except starting or stopping web dialing, which needs a rep in the browser.

    Prose guides (fetchable, no auth):
    - https://cdn.jsdelivr.net/npm/callcloud-agent-dialer-mcp/INTEGRATION.md
    - https://cdn.jsdelivr.net/npm/callcloud-agent-dialer-mcp/BUILD-A-DIALER.md
  license:
    name: MIT

servers:
  - url: https://dialer.callcloud.app/api/agent-dialer

security:
  - ApiKey: []

tags:
  - name: Dialing
  - name: Results
  - name: Callbacks
  - name: Numbers
  - name: Billing
  - name: Webhooks
  - name: Browser
  - name: Web dialer

components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      description: |
        Workspace API key, `cak_…`, minted at https://dialer.callcloud.app/mcp.
        **Server-side only.** Never ship this to a browser: it can place calls and spend credits.
    BrowserToken:
      type: http
      scheme: bearer
      description: |
        Short-lived `cbt_…` from `POST /browser-token`. Safe in a browser by design; it can only
        heartbeat a session and read the current call.

  responses:
    Unauthorized:
      description: Missing, malformed or revoked API key.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    PaymentRequired:
      description: |
        Out of dial credits. Body carries `dials_per_pack` and `pack_price_cents`. Input is
        validated BEFORE this check, so a 402 always means the request was otherwise valid.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Forbidden:
      description: |
        Account not approved for outbound calling (`pending` or `suspended`), or a lapsed trial with
        no dial balance. Not retryable.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    NotFound:
      description: No such object in this workspace. Ids from another workspace return this, never a silent success.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
    Conflict:
      description: Wrong state for this action. The body names the current state.
      content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  schemas:
    ContactInput:
      type: object
      description: One contact for a list import. Give `phone` for a single number, or `phones` for several labeled ones (the first mobile becomes the primary - mobile-first dialing).
      properties:
        first_name: { type: string }
        last_name: { type: string }
        company: { type: string }
        title: { type: string }
        email: { type: string }
        notes: { type: string }
        phone: { type: string, description: Any common format; normalized to E.164 }
        phones:
          type: array
          maxItems: 5
          items:
            type: object
            required: [number]
            properties:
              label: { type: string, enum: [mobile, home, office] }
              number: { type: string }
    Error:
      type: object
      properties:
        error: { type: string, description: Machine-readable code. }
        message: { type: string, description: Human-readable explanation, safe to surface. }
        status: { type: string }

    Run:
      type: object
      properties:
        run_id: { type: string }
        status:
          type: string
          enum: [queued, dialing, paused, done, stopped, error]
        screening: { type: string, enum: [amd, gate] }
        gate_ring_seconds: { type: number }
        caller_id: { type: string }
        total: { type: integer }
        dialed: { type: integer }
        humans: { type: integer }
        machines: { type: integer }
        no_answer: { type: integer }
        connected: { type: integer }
        error:
          type: [string, "null"]
          description: Set when something ended the run early, e.g. `out_of_credits`.
        started_at: { type: string, format: date-time }
        finished_at: { type: [string, "null"], format: date-time }

    Result:
      type: object
      properties:
        result_id: { type: string }
        number: { type: string }
        answered_by:
          type: [string, "null"]
          enum: [human, unknown, machine_start, machine_end, fax, no_answer, failed, null]
          description: |
            **`unknown` means CONNECTED, not an error.** Both screening modes fail open, so an
            ambiguous verdict is treated as human and bridged. Do not render it as a failure and do
            not filter it out of humans; `filter=humans` already includes it.
        status: { type: string, enum: [queued, dialing, connected, done, failed] }
        duration: { type: [integer, "null"], description: Seconds of talk time. }
        disposition: { type: [string, "null"] }
        notes: { type: [string, "null"] }
        transcript:
          type: [string, "null"]
          description: |
            Arrives AFTER the call ends, once transcription finishes. A `done` result with a null
            transcript is normal for a short window; re-read rather than treating null as final.
            Machines are never recorded, so they never have one.
        recording_url:
          type: [string, "null"]
          description: Signed link, valid 15 minutes, no auth header needed. Mint on render; do not store.

    Callback:
      type: object
      properties:
        callback_id: { type: string }
        number: { type: string }
        scheduled_for: { type: string, format: date-time }
        note: { type: [string, "null"] }
        status: { type: string, enum: [pending, done, cancelled] }
        result_id: { type: [string, "null"] }
        completed_at: { type: [string, "null"], format: date-time }
        is_due: { type: boolean }

paths:
  /dial:
    post:
      tags: [Dialing]
      summary: Start a dial run
      description: |
        Dials each number, screening pickups, and connects screened-in humans to the browser leg.

        `screening` is the latency decision and the only real choice here:
        - `amd` (default): carrier answering-machine detection. Most accurate, but the carrier must
          hear enough to rule, so every connect carries that delay, real people included.
        - `gate`: ring duration judged before the bridge completes. Anything that passes connects
          with no detection delay.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [numbers]
              properties:
                numbers:
                  type: array
                  items: { type: string }
                  description: E.164, e.g. `+15551234567`.
                screening: { type: string, enum: [amd, gate], default: amd }
                gate_ring_seconds:
                  type: number
                  default: 0.5
                  minimum: 0.2
                  maximum: 20
                  description: |
                    Gate mode only. A pickup faster than this many seconds of real RINGING is a
                    machine. 0.5 is the proven production value: voicemail answers within a few
                    hundred ms of ring start, a person never does. Above ~1 you start hanging up on
                    people who answer quickly.
                caller_id: { type: string, description: Must be a number this workspace owns. }
                parallel:
                  type: integer
                  minimum: 1
                  maximum: 10
                  default: 1
                  description: |
                    Concurrent lines. NOT concurrent conversations: a browser leg holds exactly one
                    call, so this is how many chances you take to find one human. For N simultaneous
                    conversations you need N reps each with their own leg and their own run.
      responses:
        "200":
          description: Run started.
          content:
            application/json:
              schema:
                type: object
                properties:
                  run_id: { type: string }
                  status: { type: string }
                  total: { type: integer }
        "400": { $ref: '#/components/responses/Conflict' }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "402": { $ref: '#/components/responses/PaymentRequired' }
        "403": { $ref: '#/components/responses/Forbidden' }

  /runs:
    get:
      tags: [Dialing]
      summary: List runs
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
        - { name: status, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: string, format: date-time }, description: ISO8601. Ignored if unparseable rather than erroring. }
      responses:
        "200":
          description: Paginated run history, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
                  has_more: { type: boolean }
                  runs: { type: array, items: { $ref: '#/components/schemas/Run' } }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /runs/{run_id}:
    get:
      tags: [Dialing]
      summary: Poll a run
      description: Poll every 2 to 3 seconds while `status` is `dialing`. Stop on `done` or `stopped`.
      parameters:
        - { name: run_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Live counters plus the currently bridged call.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Run'
                  - type: object
                    properties:
                      remaining: { type: integer }
                      credits_remaining: { type: integer }
                      current_call:
                        type: [object, "null"]
                        properties:
                          number: { type: string }
                          answered_by: { type: string }
                          duration: { type: [integer, "null"] }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }

  /runs/{run_id}/pause:
    post:
      tags: [Dialing]
      summary: Pause without losing the queue
      description: |
        Stops pulling new numbers. Calls already in flight are LEFT ALONE and finish normally,
        because killing a live conversation to take a break is worse than the break.
      parameters:
        - { name: run_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Paused.
          content:
            application/json:
              schema:
                type: object
                properties:
                  run_id: { type: string }
                  status: { type: string }
                  in_flight: { type: integer, description: Calls still up. They finish; nothing new starts. }
                  remaining: { type: integer }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "404": { $ref: '#/components/responses/NotFound' }
        "409": { $ref: '#/components/responses/Conflict' }

  /runs/{run_id}/resume:
    post:
      tags: [Dialing]
      summary: Resume a paused run
      description: Re-checks account standing and dial balance, so a run paused before the balance emptied stops again rather than resuming into a wall.
      parameters:
        - { name: run_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Dialing again. }
        "401": { $ref: '#/components/responses/Unauthorized' }
        "409": { $ref: '#/components/responses/Conflict' }

  /runs/{run_id}/stop:
    post:
      tags: [Dialing]
      summary: End a run and hang up anything live
      parameters:
        - { name: run_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Stopped. Safe to call on an already-finished run. }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /runs/{run_id}/results:
    get:
      tags: [Results]
      summary: Per-number outcomes
      parameters:
        - { name: run_id, in: path, required: true, schema: { type: string } }
        - name: filter
          in: query
          schema: { type: string, enum: [humans, machines, connected, all], default: all }
          description: '`humans` includes `unknown`, matching the fail-open rule.'
        - { name: limit, in: query, schema: { type: integer, default: 500, maximum: 1000 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: Paginated results.
          content:
            application/json:
              schema:
                type: object
                properties:
                  run_id: { type: string }
                  total: { type: integer }
                  has_more: { type: boolean }
                  results: { type: array, items: { $ref: '#/components/schemas/Result' } }
        "401": { $ref: '#/components/responses/Unauthorized' }

  /results/{result_id}:
    get:
      tags: [Results]
      summary: One result in full
      parameters:
        - { name: result_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Result including transcript.
          content: { application/json: { schema: { $ref: '#/components/schemas/Result' } } }
        "404": { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Results]
      summary: Record an outcome
      description: |
        `disposition` is FREE TEXT. This product ships no CRM and no fixed outcome list, so you own
        your taxonomy. Use `GET /outcomes` for the list your UI should offer; this endpoint accepts
        anything, because rejecting an unlisted value would strand a call needing an outcome nobody
        configured.
      parameters:
        - { name: result_id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                disposition: { type: [string, "null"], maxLength: 80 }
                notes: { type: [string, "null"], maxLength: 5000 }
      responses:
        "200": { description: Updated. }
        "400": { $ref: '#/components/responses/Conflict' }
        "404": { $ref: '#/components/responses/NotFound' }

  /results/{result_id}/hangup:
    post:
      tags: [Results]
      summary: End one call, leave the run dialing
      description: The "next" button in a cockpit. Use `/runs/{id}/stop` to end everything instead.
      parameters:
        - { name: result_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Hung up. }
        "409": { $ref: '#/components/responses/Conflict' }

  /results/{result_id}/transfer:
    post:
      tags: [Results]
      summary: Bring a third party into a live call
      description: |
        **Warm by construction.** The prospect, the rep's browser leg and the transferee all end up
        in the same conference, so the rep can introduce them and hang up to leave the other two
        connected. A cold transfer is a warm one where the rep leaves immediately.

        **Costs one dial credit** - it is a real outbound leg. Refunded if the leg fails to place.
      parameters:
        - { name: result_id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, description: E.164 number to bring in. }
      responses:
        "201": { description: Transferee is joining the conference. }
        "402": { $ref: '#/components/responses/PaymentRequired' }
        "409": { $ref: '#/components/responses/Conflict' }

  /outcomes:
    get:
      tags: [Results]
      summary: The outcome taxonomy your UI should offer
      description: Shared with the CallCloud web dialer, so a workspace on both products has one taxonomy. Returns sensible defaults when unconfigured.
      responses:
        "200": { description: Outcome list. }
    put:
      tags: [Results]
      summary: Replace the outcome list
      description: Labels are slugged into stable ids and de-duplicated. Max 30. `kind` is DATA for CRM mapping and analytics, not a colour.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [outcomes]
              properties:
                outcomes:
                  type: array
                  maxItems: 30
                  items:
                    type: object
                    required: [label]
                    properties:
                      label: { type: string, maxLength: 60 }
                      kind: { type: string, enum: [positive, neutral, negative] }
      responses:
        "200": { description: Stored list. }

  /callbacks:
    get:
      tags: [Callbacks]
      summary: Scheduled callbacks, soonest first
      parameters:
        - name: due
          in: query
          schema: { type: boolean }
          description: Only those ready to call now. This is the work queue an agent should poll.
        - { name: status, in: query, schema: { type: string, enum: [pending, done, cancelled, all], default: pending } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: offset, in: query, schema: { type: integer, default: 0 } }
      responses:
        "200":
          description: Paginated callbacks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  total: { type: integer }
                  has_more: { type: boolean }
                  callbacks: { type: array, items: { $ref: '#/components/schemas/Callback' } }
    post:
      tags: [Callbacks]
      summary: Book a follow-up
      description: |
        Stored and queryable. **Never auto-dialled**: when to actually call someone back is your
        orchestration, and a dialer that surprises people by ringing them on its own is a support
        incident waiting to happen.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone, scheduled_for]
              properties:
                phone: { type: string }
                scheduled_for:
                  type: string
                  format: date-time
                  description: ISO8601. Unparseable values are a 400, not a silent default - a callback booked at the wrong time is worse than one that failed to book.
                note: { type: string, maxLength: 1000 }
                result_id: { type: string, description: The call this came from, if any. Validated against your workspace. }
      responses:
        "201": { description: Booked. }
        "400": { $ref: '#/components/responses/Conflict' }
        "404": { $ref: '#/components/responses/NotFound' }

  /callbacks/{callback_id}:
    patch:
      tags: [Callbacks]
      summary: Complete, cancel, reschedule or annotate
      parameters:
        - { name: callback_id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string, enum: [pending, done, cancelled] }
                scheduled_for: { type: string, format: date-time }
                note: { type: [string, "null"] }
      responses:
        "200": { description: Updated. Marking `done` stamps `completed_at`; reopening clears it. }
        "404": { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Callbacks]
      summary: Delete a callback
      parameters:
        - { name: callback_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Deleted. }
        "404": { $ref: '#/components/responses/NotFound' }

  /numbers:
    get:
      tags: [Numbers]
      summary: Your numbers and remaining allotment
      responses:
        "200": { description: Numbers with reputation status, plus included allotment counts. }
    post:
      tags: [Numbers]
      summary: Provision a number
      description: Free while the included allotment lasts, then $1/month each, which requires a card already on file.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                area_code: { type: string, description: 3-digit US area code. Picks the best available. }
                number: { type: string, description: Exact E.164 from /numbers/search. }
      responses:
        "201": { description: Provisioned. `monthly_cents` is 0 while covered by the allotment. }
        "402":
          description: '`no_card`: allotment used and no card on file. A paid number is a recurring charge created with nobody at a checkout page.'
        "409": { description: '`no_inventory`: that area code is dry.' }
    delete:
      tags: [Numbers]
      summary: Release a number
      description: |
        Irreversible. Refuses to release your ONLY number, since that stops all dialing. Carriers
        also commonly refuse release within ~14 days of purchase.
      parameters:
        - { name: number, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Released. `billing_stopped` says whether the charge actually ended. }
        "409": { description: '`last_number`, or the carrier refused.' }

  /numbers/search:
    get:
      tags: [Numbers]
      summary: Carrier inventory in an area code
      description: Read-only. Reserves nothing, so a listed number can be taken before you claim it.
      parameters:
        - { name: area_code, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 5, maximum: 20 } }
      responses:
        "200": { description: Available numbers. }

  /caller-ids:
    get:
      tags: [Numbers]
      summary: Numbers this workspace can dial from
      responses:
        "200": { description: Caller IDs. }

  /usage:
    get:
      tags: [Billing]
      summary: Consumption and spend
      description: |
        Everything to render a usage view in one call. All money is INTEGER CENTS: summing
        floating-point dollars loses accuracy and this reconciles against a card statement.
        `purchased` and `consumed` are different things and will not match, because prepaid credits
        move independently.
      parameters:
        - { name: days, in: query, schema: { type: integer, default: 30, maximum: 365 } }
      responses:
        "200": { description: Credits, dials, outcomes, a zero-filled daily series, purchases, auto top-up state, recent runs. }

  /analytics:
    get:
      tags: [Billing]
      summary: Performance, as opposed to spend
      description: |
        Answers "how do I get more conversations". `by_caller_id` is the one worth acting on:
        number reputation is where outbound performance lives, and one number at 4% next to another
        at 22% is immediately actionable. `human_ring_ms` percentiles are the evidence for tuning
        `gate_ring_seconds` - if p10 sits below your threshold you are hanging up on real people.
      parameters:
        - { name: days, in: query, schema: { type: integer, default: 30, maximum: 365 } }
      responses:
        "200": { description: Totals plus breakdowns by caller ID, hour of day and screening mode. }

  /calls:
    get:
      tags: [Results]
      summary: Workspace call history
      parameters:
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: q, in: query, schema: { type: string } }
        - { name: disposition, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, maximum: 100 } }
      responses:
        "200": { description: Calls across the workspace, including the human dialer. }

  /calls/{call_id}:
    get:
      tags: [Results]
      summary: One call in full
      parameters:
        - { name: call_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Transcript, AI summary and next steps, notes, signed recording link. }

  /webhook:
    get:
      tags: [Webhooks]
      summary: Webhook config and delivery health
      description: Check this FIRST when events stop arriving; `consecutive_failures` and `last_error` say why.
      responses:
        "200": { description: 'Config plus delivery health. Returns `configured: false` when unset.' }
    put:
      tags: [Webhooks]
      summary: Register the endpoint that receives events
      description: |
        Events: `call.ringing`, `call.connected`, `call.voicemail`, `call.failed`, `call.completed`,
        `call.transcript`, `run.finished`, `session.paused`, `session.resumed`, `rep.disconnected`.

        Each delivery carries `X-CallCloud-Event`, `X-CallCloud-Timestamp` and
        `X-CallCloud-Signature`: HMAC-SHA256 hex over `{timestamp}.{rawBody}`. Verify against the
        RAW body before parsing, and reject timestamps older than five minutes or a captured payload
        replays forever.

        Three delivery attempts, retrying only 429 and 5xx. After 20 consecutive failures the hook
        disables itself rather than hammering a dead URL; saving again re-enables it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri, description: HTTPS only (http allowed for localhost during development). }
                events: { type: array, items: { type: string }, description: Omit for all events. }
                active: { type: boolean }
                rotate_secret: { type: boolean, description: Existing receivers break until they take the new secret, so this is explicit. }
      responses:
        "200": { description: Updated. }
        "201": { description: Created. Returns the signing secret. }
    delete:
      tags: [Webhooks]
      summary: Remove the webhook
      responses:
        "200": { description: Removed. }

  /browser-token:
    post:
      tags: [Browser]
      summary: Mint credentials for a browser leg
      description: |
        The one call your frontend cannot make directly, because it needs the `cak_` key. Returns
        browser-safe credentials: connect a WebRTC client with `sw_token`, dial `dial_number`, then
        send `pin` as DTMF **once the leg reports connected**. Sending digits before the LaML
        `<Gather>` is listening loses the leading ones and the bind silently never completes.
      responses:
        "200":
          description: Session credentials.
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_id: { type: string }
                  sw_token: { type: string }
                  browser_token: { type: string }
                  dial_number: { type: string }
                  pin: { type: string }
                  expires_at: { type: string, format: date-time }

  /browser-session:
    get:
      tags: [Browser]
      security:
        - BrowserToken: []
      summary: Heartbeat and current call
      description: |
        Poll every ~2 seconds while online, INCLUDING during a call: a leg that stops polling for 30
        seconds is treated as gone and screened-in humans get hung up on.

        Drive your online indicator from `online` here, never from local WebRTC state: the DTMF bind
        can fail after the call connects and only the server knows.
      responses:
        "200":
          description: Session state.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  online: { type: boolean }
                  current:
                    type: [object, "null"]
                    properties:
                      number: { type: string }
                      name: { type: [string, "null"] }
                      run_id: { type: string }
                      result_id: { type: string }
    post:
      tags: [Browser]
      security:
        - BrowserToken: []
      summary: Go offline
      description: Call on unmount, tab close and explicit offline. A leaked leg keeps billing and keeps looking available.
      responses:
        "200": { description: Disconnected. }

  # ── Web dialer data (lists, contacts, campaigns, analytics, companies) ──────
  # Manages the rep-driven parallel dialer's data with the same cak_ key. No start/stop of web
  # dialing on purpose: parallel dialing needs a rep's browser connected to answer calls.

  /lists:
    get:
      tags: [Web dialer]
      summary: Lead lists
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 100 } }
      responses:
        "200": { description: Lists newest first, each with prospect count and import breakdown. }
    post:
      tags: [Web dialer]
      summary: Create a list, optionally with contacts
      description: |
        Import semantics are identical to a CSV upload: numbers normalized to E.164, toll-free
        dropped, and a contact whose number already exists in the workspace is LINKED into the
        list instead of duplicated. The returned `import` breakdown explains any gap between
        contacts sent and prospects created - read it instead of assuming everything imported.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                contacts:
                  type: array
                  maxItems: 1000
                  items: { $ref: "#/components/schemas/ContactInput" }
      responses:
        "200": { description: "list_id plus the import breakdown (created, linked_existing, dropped_*)." }

  /contacts:
    get:
      tags: [Web dialer]
      summary: Search prospects
      description: Free-text search over name/company/title/email/phone (the web search box's engine), plus company and list filters.
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: company, in: query, schema: { type: string } }
        - { name: list_id, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 100 } }
        - { name: offset, in: query, schema: { type: integer } }
      responses:
        "200": { description: Paged contact objects with total + has_more. }
    post:
      tags: [Web dialer]
      summary: Add contacts to an existing list
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [list_id, contacts]
              properties:
                list_id: { type: string }
                contacts:
                  type: array
                  maxItems: 1000
                  items: { $ref: "#/components/schemas/ContactInput" }
      responses:
        "200": { description: The import breakdown for this batch. }

  /contacts/{contact_id}:
    get:
      tags: [Web dialer]
      summary: One prospect in full
      parameters:
        - { name: contact_id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Fields, notes, labeled phone numbers, origin list, 10 most recent calls. }
    patch:
      tags: [Web dialer]
      summary: Update a prospect
      description: Only fields present in the body change; explicit null clears a field. `do_not_call:true` suppresses the prospect from ALL dialing (web and agent runs).
      parameters:
        - { name: contact_id, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                first_name: { type: [string, "null"] }
                last_name: { type: [string, "null"] }
                company: { type: [string, "null"] }
                title: { type: [string, "null"] }
                email: { type: [string, "null"] }
                notes: { type: [string, "null"] }
                do_not_call: { type: boolean }
      responses:
        "200": { description: The updated contact. }

  /campaigns:
    get:
      tags: [Web dialer]
      summary: Web campaigns with queue stats
      description: Rep-driven parallel-dial campaigns (NOT this API's runs), each with prospects / dialed / removed / do-not-call / remaining.
      parameters:
        - { name: limit, in: query, schema: { type: integer, default: 20, maximum: 50 } }
      responses:
        "200": { description: Campaigns newest first. }
    post:
      tags: [Web dialer]
      summary: Create a DRAFT campaign
      description: |
        Attaches the given lists and leaves the campaign in `draft` - it appears on the web Home
        page ready for a rep to press Start. There is deliberately no API start/stop: parallel
        dialing needs a rep's browser connected to answer calls.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, list_ids]
              properties:
                name: { type: string }
                list_ids: { type: array, items: { type: string }, maxItems: 20 }
                caller_id: { type: string, description: Must be a workspace number (see /caller-ids) }
                parallel: { type: integer, minimum: 1, maximum: 5 }
      responses:
        "200": { description: campaign_id, status "draft", and the attached prospect count. }
        "400": { description: caller_id is not a workspace number. }

  /web-analytics:
    get:
      tags: [Web dialer]
      summary: Web-dialer performance
      description: |
        The web Analytics page's numbers over the API: dials, connects, connect rate, talk time and
        meetings booked - as totals, a zero-filled daily series ready to chart, per-rep and
        per-campaign breakdowns, and the outcome/disposition mix. Distinct from /analytics
        (agent-dialer runs) and /usage (spend).
      parameters:
        - { name: days, in: query, schema: { type: integer, default: 30, maximum: 365 } }
      responses:
        "200": { description: Totals + by_day + by_rep + by_campaign + outcomes + dispositions. }

  /companies:
    get:
      tags: [Web dialer]
      summary: The account view
      description: |
        Contacts grouped by normalized company name ("Acme", "Acme Inc" and "ACME, LLC" are one
        account), with people counts and the most recent booked meeting per account.
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: booked_only, in: query, schema: { type: string, enum: ["1"] } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
      responses:
        "200": { description: Companies largest first. }

  /companies/{company}:
    get:
      tags: [Web dialer]
      summary: One account in full
      description: Accepts a raw company name ("Acme Inc") or the key from /companies. Returns everyone at the account (with removed-from-campaigns state), the grouped spelling variants, and the booked-meeting history.
      parameters:
        - { name: company, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: The account. }
    post:
      tags: [Web dialer]
      summary: Remove or restore the whole account from active campaigns
      description: |
        `remove` pulls every contact at the company out of every active web campaign's queue -
        reversible, no call history touched. `restore` puts them back. CallCloud performs `remove`
        automatically when a rep logs a booked-meeting outcome.
      parameters:
        - { name: company, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [action]
              properties:
                action: { type: string, enum: [remove, restore] }
      responses:
        "200": { description: How many queue spots were removed or restored. }
