openapi: 3.1.0

info:
  title: Telebroad Public API
  version: "1.0.0"
  summary: Manage SMS, users, call recordings and webhook subscriptions on a Telebroad account.
  description: |
    The Telebroad public REST API. Every endpoint lives under
    `/api/public/v1` and takes a machine credential — an **API key** or an
    **OAuth 2.1 access token** — as `Authorization: Bearer …`.

    A browser session cannot reach this surface. That is deliberate: there is no
    ambient authority to borrow, so a page a user happens to have open cannot be
    made to call the API on their behalf.

    ### Two things gate every request

    1. **The scope** on the credential (listed on each operation).
    2. **The role permission** of the person behind the credential, in the admin
       center. A scope can only narrow what that person could already do — never
       widen it.

    A `403` with `"type": "insufficient_scope"` is the first gate. A `403` with
    `"type": "permission_denied"` is the second, and the fix is in the portal, not
    in your code.

    ### Envelope

    Success is `{"data": …}`. Errors are
    `{"error": {"type": …, "message": …}}`. Branch on `error.type` — it is stable.
    Never parse `error.message`.
  contact:
    name: Telebroad
    url: https://telebroad.com
  x-logo:
    altText: Telebroad

servers:
  - url: https://api.account.telebroad.com/api/public/v1
    description: Production

tags:
  - name: SMS
    description: |
      Send a text message from one of the account's numbers, and discover which
      numbers those are. Sending is **billed** — see the SMS billing concept page.
  - name: Conversations
    description: |
      A text thread's state. A conversation has no id: it is the pair
      (one of your numbers, the other party's number).
  - name: Users
    description: The account's users — who works here, and how to reach them.
  - name: Calls
    description: |
      Call history, one call's journey, its recordings and its transcripts. A
      call with transfers is ONE call with several legs — and several
      recordings and transcripts.
  - name: Webhooks
    description: |
      Subscriptions to account events. This is the configuration API; the payloads
      that get delivered to your endpoint are documented in the Webhooks guide.

security:
  - apiKey: []

paths:
  # ---------------------------------------------------------------- SMS -----
  /sms/lines:
    get:
      operationId: listSMSLines
      summary: List the numbers you can send from
      tags: [SMS]
      description: |
        Every number in this list is one `POST /sms/messages` will accept as
        `from`; a number missing from it is one it would reject. Both read the
        same setting, so this is the authoritative answer to "what can I put in
        `from`".

        | | |
        | --- | --- |
        | **Scope** | `sms:read` |
        | **Role permission** | Phone numbers |
        | **Cost** | Free. Sends nothing. |

        An **empty `userIds`** means the line is unrestricted — available to
        every user — **not** that nobody has it.
      security:
        - apiKey: []
        - oauth2: [sms:read]
      responses:
        "200":
          description: The numbers the credential may send from.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/SMSLine" }
              examples:
                default:
                  summary: Two lines, one shared
                  value:
                    data:
                      - number: "12125550188"
                        name: Acme Support
                        userIds: [481920, 481925]
                        bulkEnabled: false
                      - number: "12125550199"
                        name: Acme Sales
                        userIds: []
                        bulkEnabled: true
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/messages:
    post:
      operationId: sendSMS
      summary: Send a text message
      tags: [SMS]
      description: |
        **This spends money.** Every message is priced from the account's rate
        card and lands on its invoice. The charge is in the response — you never
        have to wait for an invoice to find out what a call cost.

        | | |
        | --- | --- |
        | **Scope** | `sms:send` |
        | **Role permission** | Phone numbers — `from` must be one of theirs |
        | **Cost** | Per segment (`sms`) or once (`mms`) |

        **Two things make a message an MMS**, and either alone is enough: more
        than one recipient, or any attachment. An MMS is charged **once**,
        whatever the body length or participant count.

        **One recipient or many changes the product and the price.** One number
        in `to` sends a direct SMS, billed per segment. Two or more creates a
        **group thread** — one conversation all participants see and can reply
        into — which is an MMS, billed **once** regardless of body length or
        participant count.

        If you want N independent one-to-one messages, make **N requests**.
        Putting them in one `to` array puts those people in a room together.

        Unknown fields are rejected, so a typo like `"mesage"` gets a `400`
        instead of silently sending nothing.
      security:
        - apiKey: []
        - oauth2: [sms:send]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SendSMSInput" }
            examples:
              direct:
                summary: One recipient — SMS, billed per segment
                value:
                  from: "12125550188"
                  to: ["13475550123"]
                  message: Your appointment is confirmed for Tuesday at 10am.
              group:
                summary: Three recipients — group thread, billed once as MMS
                value:
                  from: "12125550188"
                  to: ["13475550123", "13475550124", "13475550125"]
                  message: Crew, the Tuesday job moved to 9am.
              mms:
                summary: One recipient with a picture — MMS, billed once
                value:
                  from: "12125550188"
                  to: ["13475550123"]
                  message: Here's the signed copy.
                  attachments:
                    - filename: receipt.png
                      contentType: image/png
                      content: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
      responses:
        "201":
          description: Sent, and charged.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SendResult" }
              examples:
                default:
                  value:
                    data:
                      from: "12125550188"
                      billing:
                        billed: true
                        currency: USD
                        type: sms
                        units: 1
                        totalPrice: 0.0025
                        recipientCount: 1
                        skippedCount: 0
                      messages:
                        - id: 88214417
                          to: ["13475550123"]
                          type: sms
                          status: sent
                          units: 1
                          unitPrice: 0.0025
                          price: 0.0025
                      skipped: []
        "200":
          description: |
            Nothing was sent because every recipient was skipped (opted out,
            duplicate, or unparseable). **Nothing was charged.**
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/SendResult" }
              examples:
                allSkipped:
                  value:
                    data:
                      from: "12125550188"
                      billing:
                        billed: true
                        currency: USD
                        type: sms
                        units: 0
                        totalPrice: 0
                        recipientCount: 0
                        skippedCount: 1
                      messages: []
                      skipped:
                        - to: "13475550123"
                          reason: opted_out
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/Unpriced" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ------------------------------------------------------ Conversations -----
  /sms/conversations/{line}/{number}/resolve:
    post:
      operationId: resolveConversation
      summary: Mark a conversation handled
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free. Cannot reach the other party. |

        Two behaviours worth knowing:

        - **It creates the thread's state if there is none.** Most conversations
          have never been actioned, so an endpoint that `404`'d on those would
          fail on exactly the common case.
        - **It is idempotent.** Re-resolving returns `200` with the stored state
          and does not re-stamp who closed it or when, so a retry after a
          timeout cannot rewrite history.

        The body is entirely optional — a bare `POST` resolves the thread.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResolveInput" }
            examples:
              bare:
                summary: Just resolve it
                value: {}
      responses:
        "200":
          description: The thread's state after the call.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Conversation" }
              examples:
                default:
                  value:
                    data:
                      line: "12125550188"
                      number: "13475550123"
                      resolved: true
                      resolvedBy: 481920
                      resolvedAt: "2026-08-11T14:02:11Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ------------------------------------------------------------ Reading -----
  /sms/conversations:
    get:
      operationId: listConversations
      summary: List conversations (the inbox)
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:read` |
        | **Role permission** | Phone numbers — only lines the credential is authorized for |
        | **Cost** | Free |

        One entry per thread, newest first, with the thread's queue state
        attached — so a queue view needs one call, not one per conversation.

        **Filters combine as AND.** `unresolved=true&assignedToMe=true` is "my
        open threads", which is the message center's default view.

        Omit `lines` and every texting number on the account is listed. A fresh
        integration therefore does not need to call `GET /sms/lines` first, and
        keeps working when the customer buys a number.
      security:
        - apiKey: []
        - oauth2: [messages:read]
      parameters:
        - name: lines
          in: query
          required: false
          description: Comma-separated account numbers to list. Omit for all of them.
          x-tb-suggest: sms-lines
          schema: { type: string }
          example: "12125550188,12125550199"
        - name: unread
          in: query
          required: false
          description: Only threads you have not read. Read state is per user, so a thread a colleague has read is still unread for you.
          schema: { type: boolean }
        - name: unresolved
          in: query
          required: false
          description: Only threads not marked handled.
          schema: { type: boolean }
        - name: assignedToMe
          in: query
          required: false
          description: Only threads assigned to the calling user.
          schema: { type: boolean }
        - name: unassigned
          in: query
          required: false
          description: Only threads nobody owns.
          schema: { type: boolean }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: The conversations, newest first.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ConversationSummary" }
              examples:
                default:
                  value:
                    data:
                      - line: "12125550188"
                        number: "13475550123"
                        lastMessage: Can we move it to Thursday?
                        lastMessageId: 90218841
                        lastMessageAt: "2026-08-19T13:41:02Z"
                        direction: in
                        unread: true
                        unreadCount: 2
                        resolved: false
                        assignedTo: 481920
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/conversations/{line}/{number}:
    get:
      operationId: getConversation
      summary: Read a conversation's messages
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:read` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        Reading does NOT mark anything read. `markRead` defaults to **false**,
        which differs from the portal, where opening a thread clears it. An
        integration polling for new messages would otherwise silently empty the
        unread badges your staff work from, with nothing to explain it. Pass
        `markRead=true` when a **person** has actually seen the messages.

        Media URLs in the response are publicly readable — the URL is the only
        thing protecting the file. Treat them as secrets and do not log them.
      security:
        - apiKey: []
        - oauth2: [messages:read]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
        - name: markRead
          in: query
          required: false
          description: Also mark the returned messages read for you. Default false.
          schema: { type: boolean }
        - name: groupParticipants
          in: query
          required: false
          description: Comma-separated participants, to address a group thread rather than a 1:1.
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: The conversation's messages.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Thread" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteConversation
      summary: Delete a conversation
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        **PERMANENT.** Every message in the thread is destroyed and there is no
        recovery through this API.

        Additionally gated by the account's **restrict deleting SMS** setting.
        Holding `messages:write` does **not** override it — that setting exists
        precisely so an account owner can stop their own staff and integrations
        from destroying message history. A restricted account gets `403`.
      security:
        - apiKey: []
        - oauth2: [messages:write]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/conversations/{line}/{number}/messages/{id}:
    get:
      operationId: getConversationPage
      summary: Read the page containing a message
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:read` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        "Open this thread at this message." You hold a message id from a webhook,
        a search result or a notification and do not know its position — this
        returns the surrounding page directly instead of making you walk the
        thread with `limit`/`offset` until you find it.
      security:
        - apiKey: []
        - oauth2: [messages:read]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
        - $ref: "#/components/parameters/MessageID"
        - $ref: "#/components/parameters/Limit"
      responses:
        "200":
          description: The page of the conversation containing that message.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Thread" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteMessage
      summary: Delete one message
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        **PERMANENT** — no recovery through this API. Also gated by the account's
        **restrict deleting SMS** setting, which `messages:write` does not
        override.
      security:
        - apiKey: []
        - oauth2: [messages:write]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
        - $ref: "#/components/parameters/MessageID"
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/conversations/{line}/{number}/read:
    post:
      operationId: markMessagesRead
      summary: Mark messages read or unread
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        **Read state is per user.** This edits only *your* membership of each
        message's reader list, so it cannot clear a colleague's unread badges.

        To clear a whole line at once, use `POST /sms/lines/{line}/read` — there
        is deliberately no "mark the entire thread" here, because two spellings
        of the same bulk action invite a client to loop the wrong one.
      security:
        - apiKey: []
        - oauth2: [messages:write]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MarkReadInput" }
            examples:
              read:
                summary: Mark two messages read
                value:
                  messageIds: [90218841, 90218842]
              unread:
                summary: Put one back to unread
                value:
                  messageIds: [90218841]
                  read: false
      responses:
        "204": { description: Applied. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/conversations/{line}/{number}/reopen:
    post:
      operationId: reopenConversation
      summary: Reopen a resolved conversation
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free. Cannot reach the other party. |

        The missing half of resolve: a customer replies to a closed thread and it
        has to come back into the queue.

        Idempotent, and a thread nobody ever resolved is already open — so this
        succeeds there too rather than `404`ing.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
      responses:
        "200":
          description: The thread's state after the call.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Conversation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/conversations/{line}/{number}/assign:
    post:
      operationId: assignConversation
      summary: Assign a conversation to a user
      tags: [Conversations]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free. Cannot reach the other party. |

        **A null or absent `userId` unassigns**, returning the thread to the
        unassigned queue — which is why there is no separate unassign endpoint
        for one nullable field.

        Assigning does **not** resolve, and unassigning does not reopen. They are
        independent axes: a resolved thread can stay assigned to whoever closed
        it, and an open thread can sit unassigned.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/Line"
        - $ref: "#/components/parameters/OtherNumber"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssignInput" }
            examples:
              assign:
                summary: Hand it to a user
                value:
                  userId: 481920
              unassign:
                summary: Return it to the queue
                value:
                  userId: null
      responses:
        "200":
          description: The thread's state after the call.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Conversation" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/unread:
    get:
      operationId: getUnreadCounts
      summary: Unread counts
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:read` |
        | **Role permission** | Phone numbers — only lines the credential is authorized for |
        | **Cost** | Free |

        How many messages **you** have not read, per line and in total. Both are
        returned so a client rendering one badge does not have to add them up and
        cannot disagree with the per-line numbers.

        An API key has no user behind it and therefore no personal unread state;
        it gets the line-level counts, which is the only honest answer available
        to a credential that is not a person.
      security:
        - apiKey: []
        - oauth2: [messages:read]
      parameters:
        - name: lines
          in: query
          required: false
          description: Comma-separated account numbers. Omit for all of them.
          x-tb-suggest: sms-lines
          schema: { type: string }
        - name: since
          in: query
          required: false
          description: Only count messages after this instant, RFC 3339.
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Unread counts.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/UnreadSummary" }
              examples:
                default:
                  value:
                    data:
                      total: 7
                      lines:
                        - line: "12125550188"
                          count: 5
                        - line: "12125550199"
                          count: 2
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/lines/{line}/read:
    post:
      operationId: markLineRead
      summary: Mark a whole line read
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `messages:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        Clears a line's unread state **for you**, without walking every thread.

        Needs a USER credential. Read state is keyed on a user id; an API key
        authenticates the account, not a person, so it has no inbox of its own to
        clear and gets a `400`. Authorize with OAuth for this one.
      security:
        - apiKey: []
        - oauth2: [messages:write]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MarkLineReadInput" }
            examples:
              all:
                summary: Everything
                value: {}
              since:
                summary: Only recent messages
                value:
                  since: "2026-08-01T00:00:00Z"
      responses:
        "204": { description: Applied. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ----------------------------------------------------- Blocked senders -----
  /sms/lines/{line}/blocked:
    get:
      operationId: listBlockedSenders
      summary: List blocked senders
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:read` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        **This is not the opt-out list.** This is *your* inbound block list, which
        you control. A recipient who texted STOP is on a different list that this
        API deliberately cannot edit — you must not be able to delete someone's
        opt-out. The only way to observe that one is the `skipped: "opted_out"`
        result of a send.
      security:
        - apiKey: []
        - oauth2: [sms:read]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
      responses:
        "200":
          description: The blocked senders on this line.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/BlockedSender" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: blockSender
      summary: Block a sender
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        Stops a number texting this line. Idempotent — blocking an
        already-blocked number succeeds, so a retry after a timeout is safe.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BlockInput" }
            examples:
              default:
                value:
                  number: "13475550123"
                  reason: Repeated spam
      responses:
        "201":
          description: Blocked.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/BlockedSender" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/lines/{line}/blocked/{number}:
    get:
      operationId: getBlockedSender
      summary: Check whether a number is blocked
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:read` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        `404` when the number is not blocked — a distinct answer rather than an
        empty body, because "is this blocked" is the question, and a caller
        distinguishing null-from-error will eventually get it backwards.
      security:
        - apiKey: []
        - oauth2: [sms:read]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
        - $ref: "#/components/parameters/OtherNumber"
      responses:
        "200":
          description: The blocked sender.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/BlockedSender" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: unblockSender
      summary: Unblock a sender
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        Lets the number text this line again.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
        - $ref: "#/components/parameters/OtherNumber"
      responses:
        "204": { description: Unblocked. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ---------------------------------------------------------- Schedules ------
  /sms/lines/{line}/scheduled:
    get:
      operationId: listScheduledMessages
      summary: List scheduled messages
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:read` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free |

        What is queued to go out from this line, with the price each will cost.
      security:
        - apiKey: []
        - oauth2: [sms:read]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
      responses:
        "200":
          description: The queued messages.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/ScheduledMessage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: scheduleMessage
      summary: Schedule a message
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:send` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | **THIS SPENDS MONEY** — later. |

        **Why this needs `sms:send`, not `sms:write`:** a scheduled message is a
        send that has not happened yet. It is priced now and charged when it goes
        out, so it requires the same money-spending consent a live send does —
        otherwise `sms:write` would be a way to bill the account tomorrow for
        something it cannot bill today.

        **The price is fixed at the moment you schedule.** A later rate-card
        change does not re-price a message you already committed to.

        The STOP list is checked at **dispatch**, not now: a recipient can opt out
        between scheduling and sending, and clearing them now would let the
        message reach someone who has since said stop.
      security:
        - apiKey: []
        - oauth2: [sms:send]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ScheduleInput" }
            examples:
              default:
                value:
                  to: ["13475550123"]
                  message: "Reminder: your appointment is tomorrow at 2pm."
                  sendAt: "2026-08-20T14:30:00Z"
      responses:
        "201":
          description: Queued.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/ScheduledMessage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422":
          description: A destination is not on the account's rate card, so it cannot be priced. Nothing was queued.
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/lines/{line}/scheduled/{id}:
    delete:
      operationId: cancelScheduledMessage
      summary: Cancel a scheduled message
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Role permission** | Phone numbers — `line` must be one of theirs |
        | **Cost** | Free — and it prevents the future charge. |

        Cancelling requires only `sms:write`: stopping a charge is not a
        money-spending action, and requiring `sms:send` to undo one would be the
        wrong way round.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/SMSLinePath"
        - $ref: "#/components/parameters/ScheduleID"
      responses:
        "204": { description: Cancelled. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ------------------------------------------------ Templates and drafts -----
  /sms/templates:
    get:
      operationId: listTemplates
      summary: List saved replies
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:read` |
        | **Cost** | Free |

        Canned replies — opening hours, a booking link, "we'll call you back".

        **Templates belong to a user, not the account.** Two agents on one account
        do not see each other's saved replies. An API key authenticates the
        account rather than a person, so it has none and gets an empty list —
        which is true, not an error.
      security:
        - apiKey: []
        - oauth2: [sms:read]
      responses:
        "200":
          description: The caller's saved replies.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Template" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      operationId: createTemplate
      summary: Save a reply
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Cost** | Free |

        Needs a user credential — see the note on `GET /sms/templates`.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateInput" }
            examples:
              default:
                value:
                  title: Callback promise
                  body: Thanks for reaching out — someone will call you back within the hour.
      responses:
        "201":
          description: Saved.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/templates/{id}:
    put:
      operationId: updateTemplate
      summary: Edit a saved reply
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Cost** | Free |

        Both fields are required — this replaces the template rather than
        patching it. A template belonging to another user is `404`.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/TemplateID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TemplateInput" }
      responses:
        "200":
          description: The updated reply.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Template" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteTemplate
      summary: Delete a saved reply
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Cost** | Free |
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/TemplateID"
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /sms/drafts/{draftId}:
    get:
      operationId: getDraft
      summary: Read a draft
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:read` |
        | **Cost** | Free |

        Unsent text saved against a key **you** choose — in the message center it
        is the conversation. That is what makes a draft addressable before it
        exists: you can read the draft for a thread without having created one.

        **A missing draft is `204`, not `404`.** "Nobody has typed anything here"
        is the normal state of a conversation, and a `404` would make every client
        log the happy path as an error.
      security:
        - apiKey: []
        - oauth2: [sms:read]
      parameters:
        - $ref: "#/components/parameters/DraftID"
      responses:
        "200":
          description: The draft.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Draft" }
        "204": { description: No draft saved under that key. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    put:
      operationId: saveDraft
      summary: Save a draft
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Cost** | Free |

        `PUT`, because you name the resource: the request is idempotent and safe
        to send on a keystroke timer, where two saves racing is the normal case
        rather than an edge case.

        An empty `message` is legal — it is how you store "the user cleared the
        box" without deleting the draft.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/DraftID"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/DraftInput" }
            examples:
              default:
                value:
                  message: "Hi Dana — checking in about"
      responses:
        "200":
          description: The saved draft.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Draft" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      operationId: deleteDraft
      summary: Discard a draft
      tags: [SMS]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `sms:write` |
        | **Cost** | Free |

        Deleting a draft that is not there **succeeds** — your intent is "there
        should be no draft here", which is already true. The usual caller is a
        send handler tidying up after itself, where a `404` would surface as a
        spurious error after a message that did send.
      security:
        - apiKey: []
        - oauth2: [sms:write]
      parameters:
        - $ref: "#/components/parameters/DraftID"
      responses:
        "204": { description: Discarded. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  # -------------------------------------------------------------- Users -----
  /users:
    get:
      operationId: listUsers
      summary: List the account's users
      tags: [Users]
      description: |
        The company's users — this is what turns a user id or an extension into a
        person.

        | | |
        | --- | --- |
        | **Scope** | `users:read` |
        | **Role permission** | Users |
        | **Cost** | Free |

        **Personal data.** Names, emails and mobile numbers.

        Sorted by last name, then first name, then id — stable between calls, so
        diffing two responses is meaningful.

        This is **not** an address book. It returns the company's users only,
        never anyone's personal contacts.
      security:
        - apiKey: []
        - oauth2: [users:read]
      responses:
        "200":
          description: The users the credential may see.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/User" }
              examples:
                default:
                  value:
                    data:
                      - id: 481920
                        username: dspencer
                        firstName: Dana
                        lastName: Spencer
                        title: Support Lead
                        email: dana@acme.example
                        mobile: "13475550188"
                        company: Acme Inc
                        timeZone: America/New_York
                        extension: "204"
                        extensions: ["204"]
                        lines: ["acme-204"]
                        numbers: ["12125550188"]
                        voicemails: ["204"]
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ------------------------------------------------------------- Calls -----
  /calls/search:
    post:
      operationId: searchCalls
      summary: Search call history
      tags: [Calls]
      description: |
        A page of calls, newest first, for a time window you specify.

        | | |
        | --- | --- |
        | **Scope** | `reports:read` |
        | **Role permission** | Call reports |
        | **Cost** | Free |

        **A `POST` that reads.** The filter is a nested object — include and
        exclude sets, four ranges, the AI fields — which does not survive a query
        string, and query strings are logged by every proxy in the path. This
        request creates nothing, changes nothing and is safe to retry.

        **`start` and `end` are required**, unix seconds, at most 366 days apart.
        There is deliberately no default window: a client that forgot the field
        would otherwise receive one day of data and report it as the month.
      security:
        - apiKey: []
        - oauth2: [reports:read]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CallSearchInput" }
            examples:
              window:
                summary: Everything in a time window
                value:
                  start: 1754006400
                  end: 1754092800
                  timeZone: America/New_York
                  limit: 50
              missedInbound:
                summary: Inbound calls nobody answered
                value:
                  start: 1754006400
                  end: 1754092800
                  timeZone: America/New_York
                  direction: inbound
                  filters:
                    statuses: [missed]
              queueByPurpose:
                summary: One queue, filtered on the AI purpose field
                value:
                  start: 1754006400
                  end: 1754092800
                  filters:
                    queues: ["501"]
                    callPurpose: [billing]
                  sort:
                    field: startTime
                    direction: desc
      responses:
        "200":
          description: A page of matching calls.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallPage" }
              examples:
                default:
                  value:
                    data:
                      total: 812
                      limit: 50
                      offset: 0
                      calls:
                        - id: "1754489201.884213"
                          direction: inbound
                          status: answered
                          statusCode: 1
                          callerType: external
                          callerName: ACME ROOFING
                          callerNumber: "13475550123"
                          calledType: user
                          calledName: Dana Spencer
                          calledNumber: "204"
                          dialledNumber: "12125550188"
                          startTime: "2026-08-11T13:20:01Z"
                          endTime: "2026-08-11T13:24:00Z"
                          durationSeconds: 239
                          talkTimeSeconds: 220
                          transferred: 0
                          abandoned: false
                          recorded: true
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /calls/{callid}:
    get:
      operationId: getCall
      summary: Get one call with its legs
      tags: [Calls]
      description: |
        One call's full journey: the summary fields, plus a `legs` array
        describing what happened inside it.

        | | |
        | --- | --- |
        | **Scope** | `reports:read` |
        | **Role permission** | Call reports |
        | **Cost** | Free |

        A call that rang a queue, was answered and was transferred is **one call
        and several legs**. Follow a transfer by chaining on each leg's
        `bridgedFromCallId`.
      security:
        - apiKey: []
        - oauth2: [reports:read]
      parameters:
        - $ref: "#/components/parameters/CallID"
      responses:
        "200":
          description: The call.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/CallDetail" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /calls/{callid}/transcripts:
    get:
      operationId: getCallTranscripts
      summary: Get a call's transcripts
      tags: [Calls]
      description: |
        Every transcript for a call — **one per recorded leg**, not one per call.

        | | |
        | --- | --- |
        | **Scope** | `recordings:read` |
        | **Role permission** | Call reports → allow recordings |
        | **Cost** | Free |

        **Why `recordings:read` and not `reports:read`:** a transcript is the
        conversation written down, so it carries the same privacy weight as the
        audio. The platform gates both on the same role permission, so they share
        one scope — a consent screen offering "transcripts but not recordings"
        would be describing a separation that does not exist.

        A transferred call produces several recordings and therefore several
        transcripts, all sharing this call id. Pair them to legs with
        `recording`, which embeds the leg's `uniqueId`.

        **`200` with `[]` is normal** — the call was not recorded, was too short,
        or transcription has not finished. It does not mean the call is missing.
      security:
        - apiKey: []
        - oauth2: [recordings:read]
      parameters:
        - $ref: "#/components/parameters/CallID"
      responses:
        "200":
          description: The call's transcripts. Empty when there are none.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/CallTranscript" }
              examples:
                default:
                  value:
                    data:
                      - id: 44219
                        callId: "1754489201.884213"
                        recording: record_1754489201884213_93458.wav
                        shortSummary: Caller asked to reschedule Tuesday's roof inspection to 9am.
                        bulletPoints:
                          - Reschedule requested
                          - Agent confirmed the new slot
                        callPurpose: scheduling
                        callSentiment: positive
                        customerSatisfactionScore: 9
                        agentPerformanceScore: 8
                        actionRequired: true
                        actionRequiredNote: Update the crew calendar.
                        urgentActionRequired: false
                        agentName: Dana Spencer
                        agentSpeaker: 1
                        segments:
                          - start: 0.42
                            end: 3.10
                            speaker: 0
                            text: Hi, can we move Tuesday to nine?
                          - start: 3.40
                            end: 6.85
                            speaker: 1
                            text: Sure, nine works. I will update the crew.
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ------------------------------------------------- Calls & recordings -----
  /calls/{callid}/recordings:
    get:
      operationId: listCallRecordings
      summary: List a call's recordings
      tags: [Calls]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `recordings:read` |
        | **Role permission** | Call reports → allow recordings (OAuth only) |
        | **Cost** | Free |

        **The most sensitive capability on this surface** — a recording is the
        contents of a conversation, not metadata about it.

        **A call is not one recording.** A call that rang a queue, was answered
        and then transferred has several legs, and each answered leg is recorded
        separately. Picking "the" recording of a transferred call is a judgement
        only you can make, so every leg's timings and endpoints are here.

        `200` with an **empty array** means the call exists but nothing was
        recorded — recording is per-number and per-user opt-in. That is different
        from `404`, which means the call id isn't this account's. A client
        polling for a recording still being written should treat `[]` as
        "not yet" and retry; recordings usually appear within a minute of hangup.

        One gate no credential opens: if the account has **sensitive-content
        re-verification** on, recordings are unavailable over the API entirely.
      security:
        - apiKey: []
        - oauth2: [recordings:read]
      parameters:
        - $ref: "#/components/parameters/CallID"
      responses:
        "200":
          description: Every recorded leg of the call. Empty if nothing was recorded.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Recording" }
              examples:
                transferred:
                  summary: A transferred call — two recorded legs
                  value:
                    data:
                      - uniqueId: "1754489201.884213"
                        callId: "1754489201.884213"
                        url: https://api.account.telebroad.com/recordings/play/9f2c…
                        startTime: "2026-08-11T13:20:01Z"
                        durationSeconds: 96
                        talkTimeSeconds: 81
                        from: "13475550123"
                        to: "204"
                      - uniqueId: "1754489288.884219"
                        callId: "1754489288.884219"
                        url: https://api.account.telebroad.com/recordings/play/1ab4…
                        startTime: "2026-08-11T13:21:28Z"
                        durationSeconds: 142
                        talkTimeSeconds: 139
                        from: "204"
                        to: "301"
                notRecorded:
                  summary: Call exists, nothing recorded
                  value: { data: [] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /calls/{callid}/recordings/{uniqueid}:
    get:
      operationId: getCallRecording
      summary: Get one recorded leg
      tags: [Calls]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `recordings:read` |
        | **Role permission** | Call reports → allow recordings (OAuth only) |
        | **Cost** | Free |

        `404` for a leg that isn't recorded on this call, whatever the reason —
        saying more would let a caller probe which leg ids exist.
      security:
        - apiKey: []
        - oauth2: [recordings:read]
      parameters:
        - $ref: "#/components/parameters/CallID"
        - name: uniqueid
          in: path
          required: true
          description: The leg identifier, from `uniqueId` in the list response.
          schema: { type: string }
          example: "1754489201.884213"
      responses:
        "200":
          description: The recorded leg.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Recording" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  # ----------------------------------------------------------- Webhooks -----
  /webhooks:
    get:
      operationId: listWebhooks
      summary: List webhook subscriptions
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:read` |
        | **Cost** | Free |

        An absent filter really is "no filter".
      security:
        - apiKey: []
        - oauth2: [webhooks:read]
      parameters:
        - name: type
          in: query
          required: false
          description: Only subscriptions for this event type. An unknown type is a `400`.
          schema: { $ref: "#/components/schemas/WebhookType" }
        - name: enabled
          in: query
          required: false
          description: |
            Only enabled subscriptions. **`enabled=false` is not supported** —
            omit the parameter and filter the result client-side.
          schema: { type: boolean, enum: [true] }
      responses:
        "200":
          description: The account's subscriptions.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Webhook" }
              examples:
                default:
                  value:
                    data:
                      - id: 2074
                        name: real time
                        description: ""
                        url: https://example.com/hook
                        type: AccountRealTimeCalls
                        enabled: true
                        createdAt: "2026-08-05T12:00:00Z"
                        updatedAt: "2026-08-05T12:00:00Z"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

    post:
      operationId: createWebhook
      summary: Create a webhook subscription
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:write` |
        | **Cost** | Free |

        `enabled` defaults to **`false`**, so a half-configured integration
        cannot start firing at an endpoint that is not ready.

        Returns `201` with a `Location` header pointing at the new subscription.
      security:
        - apiKey: []
        - oauth2: [webhooks:write]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreateInput" }
            examples:
              default:
                value:
                  name: My hook
                  url: https://example.com/hook
                  type: AccountSMS
                  enabled: true
      responses:
        "201":
          description: Created.
          headers:
            Location:
              description: Path of the new subscription.
              schema: { type: string }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/types:
    get:
      operationId: listWebhookTypes
      summary: List the valid event types
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:read` |
        | **Cost** | Free |

        Discover the vocabulary here rather than copying it out of
        documentation that may have drifted. An unknown `type` on create is
        rejected — a typo'd subscription that saves, shows as enabled and then
        silently never delivers is the worst failure mode this API could hand
        you.
      security:
        - apiKey: []
        - oauth2: [webhooks:read]
      responses:
        "200":
          description: The event types a subscription may register for.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookType" }
              examples:
                default:
                  value:
                    data:
                      - AccountRealTimeCalls
                      - AccountEndedCalls
                      - UserEndedCalls
                      - AccountSMS
                      - AccountAITranscription
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{id}:
    get:
      operationId: getWebhook
      summary: Get one subscription
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:read` |
        | **Cost** | Free |
      security:
        - apiKey: []
        - oauth2: [webhooks:read]
      parameters: [{ $ref: "#/components/parameters/WebhookID" }]
      responses:
        "200":
          description: The subscription.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

    patch:
      operationId: updateWebhook
      summary: Update a subscription (merge)
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:write` |
        | **Cost** | Free |

        **`PUT` and `PATCH` both merge** — fields you omit are left unchanged.
        There is no replace-everything variant, and rather than have `PUT`
        quietly behave like `PATCH`, both are documented as a merge.

        A body with no updatable field is a `400`, not a silent no-op.
      security:
        - apiKey: []
        - oauth2: [webhooks:write]
      parameters: [{ $ref: "#/components/parameters/WebhookID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookUpdateInput" }
            examples:
              retarget:
                summary: Point it at a new URL
                value: { url: https://example.com/hook/v2 }
      responses:
        "200":
          description: The stored subscription, re-read after the write.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

    put:
      operationId: replaceWebhook
      summary: Update a subscription (merge — identical to PATCH)
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:write` |
        | **Cost** | Free |

        Identical to `PATCH`: this **merges**. It is offered only because
        clients expect `PUT` to exist.
      security:
        - apiKey: []
        - oauth2: [webhooks:write]
      parameters: [{ $ref: "#/components/parameters/WebhookID" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookUpdateInput" }
      responses:
        "200":
          description: The stored subscription, re-read after the write.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

    delete:
      operationId: deleteWebhook
      summary: Delete a subscription
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:write` |
        | **Cost** | Free |

        Returns `204` with no body.
      security:
        - apiKey: []
        - oauth2: [webhooks:write]
      parameters: [{ $ref: "#/components/parameters/WebhookID" }]
      responses:
        "204": { description: Deleted. No body. }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{id}/enable:
    post:
      operationId: enableWebhook
      summary: Start delivering to a subscription
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:write` |
        | **Cost** | Free |

        `POST`, not `GET`: a `GET` that changes state is cacheable and
        prefetchable, so a proxy or a link-preview bot could flip your
        subscription.
      security:
        - apiKey: []
        - oauth2: [webhooks:write]
      parameters: [{ $ref: "#/components/parameters/WebhookID" }]
      responses:
        "200":
          description: The subscription, now enabled.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

  /webhooks/{id}/disable:
    post:
      operationId: disableWebhook
      summary: Stop delivering to a subscription
      tags: [Webhooks]
      description: |
        | | |
        | --- | --- |
        | **Scope** | `webhooks:write` |
        | **Cost** | Free |

        The subscription is kept, so you can re-enable it without
        reconfiguring. Disable before deleting if you want a pause rather than
        a removal.
      security:
        - apiKey: []
        - oauth2: [webhooks:write]
      parameters: [{ $ref: "#/components/parameters/WebhookID" }]
      responses:
        "200":
          description: The subscription, now disabled.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/Webhook" }
        "401": { $ref: "#/components/responses/Unauthenticated" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/InternalError" }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        A customer API key — `tbk_live_…` — created in the admin center under
        **Integrations → API Keys**. Sent as `Authorization: Bearer tbk_live_…`.

        A key authenticates the account, not a browser session, so it needs no
        consent flow. Its capabilities are the scopes selected when it was
        issued. Treat it like a password: it is shown once, and anyone holding
        it can act as the account within those scopes.

        A key reaches an endpoint only if it carries that endpoint's scope. A key
        with no scopes selected reaches nothing — it does not default to full
        access.
    oauth2:
      type: oauth2
      description: |
        OAuth 2.1 authorization code + **PKCE (S256, required)** for public
        clients — no client secret is issued. Use this when your application
        acts on behalf of a specific user; the token can never exceed what that
        user could do themselves.

        Read the live endpoints from the discovery document at
        `/.well-known/oauth-authorization-server` rather than hardcoding them.
      flows:
        authorizationCode:
          authorizationUrl: https://api.account.telebroad.com/api/v1/oauth/authorize
          tokenUrl: https://api.account.telebroad.com/api/v1/oauth/token
          refreshUrl: https://api.account.telebroad.com/api/v1/oauth/token
          scopes:
            webhooks:read: List and view webhook subscriptions
            webhooks:write: Create, update, delete, enable and disable subscriptions
            reports:read: Read call history and analytics
            users:read: List the account's users — names, emails, extensions. Personal data.
            sms:read: Texting configuration — which numbers can text, blocked senders, templates, schedules. Not message content.
            sms:write: Resolve or assign a conversation, and manage blocked senders, templates and drafts. Cannot send to anyone.
            messages:read: Read message content — the inbox, a thread's text and media, delivery receipts, unread counts.
            messages:write: Mark messages read or unread, and permanently delete messages or conversations.
            sms:send: Send SMS from the account's numbers. THIS SPENDS MONEY.
            recordings:read: Fetch links to call-recording audio. The contents of conversations.

  parameters:
    Line:
      name: line
      in: path
      required: true
      description: One of your account's numbers — the line the thread is on. Digits.
      # Populates the playground's picker from GET /sms/lines, so nobody has to
      # know one of their own numbers by heart to try an endpoint.
      x-tb-suggest: sms-lines
      schema: { type: string }
      example: "12125550188"
    OtherNumber:
      name: number
      in: path
      required: true
      description: The other party's number. Digits.
      schema: { type: string }
      example: "13475550123"
    MessageID:
      name: id
      in: path
      required: true
      description: The message id, from a thread read or an `AccountSMS` webhook.
      schema: { type: integer, format: int64 }
      example: 90218841
    ScheduleID:
      name: id
      in: path
      required: true
      description: The scheduled-message id.
      schema: { type: integer, format: int64 }
      example: 55120
    TemplateID:
      name: id
      in: path
      required: true
      description: The saved reply's id.
      schema: { type: integer, format: int64 }
      example: 3312
    DraftID:
      name: draftId
      in: path
      required: true
      description: The key you chose for this draft. At most 128 characters.
      schema: { type: string, maxLength: 128 }
      example: "12125550188:13475550123"
    SMSLinePath:
      name: line
      in: path
      required: true
      description: One of your account's numbers. Digits.
      x-tb-suggest: sms-lines
      schema: { type: string }
      example: "12125550188"
    Limit:
      name: limit
      in: query
      required: false
      description: |
        Page size. An out-of-range value is a `400` rather than a silent clamp —
        a client asking for 5000 and receiving 200 with no explanation concludes
        the account only has 200 messages.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Offset:
      name: offset
      in: query
      required: false
      schema: { type: integer, minimum: 0, default: 0 }
    CallID:
      name: callid
      in: path
      required: true
      description: |
        The call id from call history, reports or a call webhook — a unix
        timestamp, a dot, and a sequence number. A malformed id is a `400` that
        names the expected shape, not a `404`.
      schema:
        type: string
        pattern: '^\d{10,14}\.\d{1,10}$'
      example: "1754489201.884213"
    WebhookID:
      name: id
      in: path
      required: true
      description: The subscription id.
      schema: { type: integer, format: int64 }
      example: 2074

  schemas:
    SMSLine:
      type: object
      title: SMS line
      description: One number on the account that can text.
      required: [number, name, userIds, bulkEnabled]
      properties:
        number:
          type: string
          description: |
            The sending number, already in the exact form `from` wants. Copy it
            straight through — no reformatting.
          example: "12125550188"
        name:
          type: string
          description: The line's caller-ID name, as shown in the portal. Empty if none is set.
          example: Acme Support
        userIds:
          type: array
          description: |
            The users this line is assigned to in the message center, matching
            `id` in `GET /users`. **An empty array means the line is
            unrestricted — available to everyone — not that nobody has it.**
          items: { type: integer, format: int64 }
        bulkEnabled:
          type: boolean
          description: |
            Whether the number is registered for 10DLC bulk/campaign traffic.
            Reported, never filtered on: campaign registration is **not**
            required to send through this API. A line with `false` sends fine.

    SendSMSInput:
      type: object
      title: Send input
      required: [from, to]
      properties:
        from:
          type: string
          description: |
            A number on your account with SMS enabled — one of the entries from
            `GET /sms/lines`. Digits only; formatting is stripped. Required and
            never guessed: it is what the recipient sees and what the carrier
            attributes your traffic to.
          example: "12125550188"
        to:
          type: array
          description: |
            1–20 **distinct** recipients. **More than one creates a group
            thread**, billed once as MMS — not N separate texts.
          minItems: 1
          maxItems: 20
          items: { type: string }
          example: ["13475550123"]
        message:
          type: string
          description: |
            The text. Required **unless** you send `attachments` — a picture with
            no caption is an ordinary MMS. Without attachments, a whitespace-only
            body is rejected rather than sent, because it would be charged for.
          example: Your appointment is confirmed for Tuesday at 10am.
        attachments:
          type: array
          description: |
            Files to send as **MMS**. Up to 10, each at most 5 MB decoded.

            **Attachments change the price.** A message carrying any file is an
            MMS at the carrier, so it is charged **once at the MMS rate** rather
            than per segment — whether it goes to one recipient or twenty.

            You send the **bytes**, not a URL. We host the file and return its
            URL in the response.
          maxItems: 10
          items: { $ref: "#/components/schemas/SendAttachment" }

    SendAttachment:
      type: object
      title: Attachment
      required: [filename, contentType, content]
      properties:
        filename:
          type: string
          description: |
            The filename. Its **extension is load-bearing** — it decides the
            content type the recipient's handset is told, so a name without one
            is rejected rather than delivered as a file nothing can open.
          example: receipt.png
        contentType:
          type: string
          enum: [image/jpeg, image/png, image/bmp, image/gif]
          description: |
            The file's MIME type. Only these four are deliverable; anything else
            is rejected here rather than accepted, charged at the MMS rate, and
            then silently not rendered by the handset.
          example: image/png
        content:
          type: string
          format: byte
          description: |
            The file itself, standard base64, **at most 5 MB decoded**.

            Note that 5 MB is what this API accepts, not what arrives: carriers
            routinely transcode or drop large MMS. **1 MB or under** is what
            delivers reliably.
          example: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==

    SendResult:
      type: object
      title: Send result
      required: [from, billing, messages, skipped]
      properties:
        from:
          type: string
          description: The number the message was sent from.
        billing: { $ref: "#/components/schemas/SMSBilling" }
        messages:
          type: array
          description: What was dispatched. Empty when every recipient was skipped.
          items: { $ref: "#/components/schemas/SentMessage" }
        skipped:
          type: array
          description: Recipients dropped before sending, and never charged for.
          items: { $ref: "#/components/schemas/SkippedMessage" }

    SentMessage:
      type: object
      title: Sent message
      required: [to, type, status, units, unitPrice, price]
      properties:
        id:
          type: integer
          format: int64
          description: The message id. Absent when the gateway returned none.
        to:
          type: array
          description: |
            The **normalized** destinations — what was actually dialled and what
            the charge is against. Always an array, including for one recipient,
            so you never branch on the shape.
          items: { type: string }
        type:
          type: string
          enum: [sms, mms]
          description: |
            Which billing model applied. `mms` means the message was **either** a
            group thread **or** carried attachments — either one alone is enough.
        status:
          type: string
          description: |
            `sent` means the carrier gateway **accepted** the message — not that
            a handset received it. Subscribe to the `AccountSMS` webhook for
            delivery events.
          example: sent
        media:
          type: array
          description: |
            The hosted URLs of the attachments you sent, in the order supplied.
            Absent when the message had none.

            **These URLs are publicly fetchable** — unguessable, but nothing else
            protects them. Treat one as a secret, and don't forward it to anyone
            who shouldn't see the message.
          items: { type: string, format: uri }
        units:
          type: integer
          description: What you were charged for — segments for `sms`, always 1 for `mms`.
        unitPrice:
          type: number
          format: double
          description: |
            Rate per unit. On a group thread whose participants sit on different
            rates, this is the highest that applied.
        price:
          type: number
          format: double
          description: "`unitPrice` × `units`, always."

    SkippedMessage:
      type: object
      title: Skipped recipient
      required: [to, reason]
      properties:
        to:
          type: string
          description: |
            The recipient **as you supplied it**, so you can match it back to
            your own input — a skipped recipient may not have normalized at all.
        reason:
          type: string
          enum: [opted_out, duplicate, invalid]
          description: |
            A stable token, safe to branch on. `opted_out` — the recipient sent
            STOP to this number. `duplicate` — the number appeared twice in
            `to`. `invalid` — not a dialable number.

    SMSBilling:
      type: object
      title: Billing
      description: |
        Present on every send response, always — including when nothing was
        charged.
      required: [billed, currency, type, units, totalPrice, recipientCount, skippedCount]
      properties:
        billed:
          type: boolean
          description: |
            Always `true`. It describes the **endpoint**, not the individual
            call, so it stays `true` even when a particular call cost nothing.
            Unlike the portal, API traffic never consumes the rate card's
            monthly free allowance.
        currency:
          type: string
          description: The currency of every price in this response. Always `USD` today.
          example: USD
        type:
          type: string
          enum: [sms, mms]
          description: |
            `sms` — one recipient and no attachments, charged **per segment**,
            so a long body costs a multiple of a short one. `mms` — a group
            thread **or** a message carrying attachments, charged **once**,
            regardless of body length or participant count.
        units:
          type: integer
          description: Segments for `sms`, 1 for `mms`.
        totalPrice:
          type: number
          format: double
          description: What this request cost. `0` if nothing was sent.
        recipientCount:
          type: integer
          description: How many destinations it went to. For `mms` this does **not** multiply the price.
        skippedCount:
          type: integer
          description: How many supplied recipients were dropped, and so never charged for.

    ResolveInput:
      type: object
      title: Resolve input
      description: Entirely optional — a bare POST resolves the thread.
      properties:
        resolvedBy:
          type: integer
          format: int64
          description: |
            Credit a specific user, matching `id` in `GET /users`. **Honoured
            for API keys only** — an OAuth token always credits its own owner,
            because inventing a different one would corrupt "who closed this".

    Conversation:
      type: object
      title: Conversation
      description: The state of one thread after the action.
      required: [line, number, resolved]
      properties:
        line:
          type: string
          description: Your account number the thread is on, **normalized**.
        number:
          type: string
          description: The other party's number, **normalized**.
        resolved:
          type: boolean
          description: The thread's state after this call.
        resolvedBy:
          type: integer
          format: int64
          description: |
            The user credited, matching `id` in `GET /users`. Absent when an
            integration resolved it with no person named.
        resolvedAt:
          type: string
          format: date-time
          description: When, RFC 3339 UTC. Absent when not resolved.

    ConversationSummary:
      type: object
      title: Conversation summary
      description: One thread in the inbox — its newest message plus its queue state.
      required: [line, number, lastMessage, lastMessageAt, direction, unread, resolved]
      properties:
        line:
          type: string
          description: Your account number the thread is on, normalized.
        number:
          type: string
          description: The other party's number, normalized.
        groupParticipants:
          type: array
          description: Every number on a group thread. Absent for a 1:1.
          items: { type: string }
        lastMessage:
          type: string
          description: Preview text of the newest message.
        lastMessageId:
          type: integer
          format: int64
          description: Its id, so you can open the thread at the right place.
        lastMessageAt:
          type: string
          format: date-time
        direction:
          type: string
          enum: [in, out]
          description: Direction of that newest message, so a client can render the "you" prefix without reading the thread.
        hasMedia:
          type: boolean
        unread:
          type: boolean
          description: Whether YOU have unread messages here. Per user — a thread a colleague read is still unread for you.
        unreadCount:
          type: integer
        resolved:
          type: boolean
        resolvedBy:
          type: integer
          format: int64
        resolvedAt:
          type: string
          format: date-time
        assignedTo:
          type: integer
          format: int64
          description: |
            The user who owns the thread, matching `id` in `GET /users`. Absent
            when nobody does — which is a real state the filters use, not missing
            data.

    Message:
      type: object
      title: Message
      description: One SMS or MMS in a conversation.
      required: [id, direction, from, to, text, sentAt, read]
      properties:
        id:
          type: integer
          format: int64
        direction:
          type: string
          enum: [in, out]
          description: |
            `in` = they texted you, `out` = you texted them. Spelled this way
            rather than the carrier's MO/MT, which reads backwards to everyone
            the first time.
        from: { type: string }
        to: { type: string }
        text:
          type: string
          description: The body. Empty is legal and normal for an MMS that carries only an image.
        media:
          type: array
          description: |
            Attachment URLs. **Publicly readable** — the URL is the only thing
            protecting the file. Treat them as secrets and do not log them.
          items: { type: string }
        sentAt:
          type: string
          format: date-time
        read:
          type: boolean
          description: Whether YOU have read it.
        readBy:
          type: array
          description: Every user who has read it, matching `id` in `GET /users`.
          items: { type: integer, format: int64 }
        sentBy:
          type: integer
          format: int64
          description: The user who sent it. Absent when an integration sent it rather than a person.
        delivery:
          $ref: "#/components/schemas/MessageDelivery"
        groupParticipants:
          type: array
          items: { type: string }
        scheduled:
          type: boolean
          description: Queued for future delivery — in the thread, but not sent yet.
        scheduledAt:
          type: string
          format: date-time
        blocked:
          type: boolean
          description: |
            Not delivered because the other party is on this line's blocked-sender
            list. The message exists and was charged for.
        blockReason:
          type: string

    MessageDelivery:
      type: object
      title: Delivery receipt
      description: |
        The carrier's receipt. **Absent means the carrier has not reported yet** —
        normal for a message sent seconds ago, and permanent for an inbound one.
        Absent is NOT "failed"; a client that treats it that way shows a false
        failure on every fresh send.
      properties:
        status:
          type: string
          description: The carrier's own word for the outcome, passed through unmapped so the detail that makes a failure diagnosable survives.
        error:
          type: string
          description: The carrier's failure detail. Empty on success.
        at:
          type: string
          format: date-time

    Thread:
      type: object
      title: Thread
      description: One conversation's messages.
      required: [line, number, messages]
      properties:
        line: { type: string }
        number: { type: string }
        messages:
          type: array
          items: { $ref: "#/components/schemas/Message" }
        markedRead:
          type: boolean
          description: Whether this request also marked the messages read. Only ever true when you passed `markRead=true`.

    UnreadSummary:
      type: object
      title: Unread summary
      required: [total, lines]
      properties:
        total:
          type: integer
          description: The sum across every line reported, so one badge cannot disagree with the per-line numbers.
        lines:
          type: array
          items:
            type: object
            required: [line, count]
            properties:
              line: { type: string }
              count: { type: integer }

    BlockedSender:
      type: object
      title: Blocked sender
      description: |
        One number blocked from texting a line. **Not** the STOP list — see
        `GET /sms/lines/{line}/blocked`.
      required: [line, number]
      properties:
        line: { type: string }
        number: { type: string }
        reason:
          type: string
          description: Free text for your own records. Never shown to the blocked party.
        blockedAt:
          type: string
          format: date-time

    ScheduledMessage:
      type: object
      title: Scheduled message
      required: [id, line, recipients, sendAt, price, currency]
      properties:
        id: { type: integer, format: int64 }
        line: { type: string }
        recipients:
          type: array
          items: { type: string }
        subject: { type: string }
        text: { type: string }
        media:
          type: array
          items: { type: string }
        sendAt:
          type: string
          format: date-time
        price:
          type: number
          format: double
          description: |
            What it will cost, **fixed at the moment it was scheduled**. A later
            rate-card change does not re-price a message you already committed
            to; the charge lands when it goes out.
        currency:
          type: string
          enum: [USD]

    Template:
      type: object
      title: Saved reply
      required: [id, title, body]
      properties:
        id: { type: integer, format: int64 }
        title: { type: string }
        body:
          type: string
          description: The message text.

    Draft:
      type: object
      title: Draft
      required: [draftId, message]
      properties:
        draftId:
          type: string
          description: The key YOU chose, not one this API minted.
        message: { type: string }

    MarkReadInput:
      type: object
      title: Mark read input
      required: [messageIds]
      properties:
        messageIds:
          type: array
          maxItems: 50
          description: The messages to mark. At most 50 per request.
          items: { type: integer, format: int64 }
        read:
          type: boolean
          default: true
          description: "`true` marks read, `false` marks unread. Defaults to true."

    MarkLineReadInput:
      type: object
      title: Mark line read input
      properties:
        since:
          type: string
          format: date-time
          description: Only mark messages after this instant. Omit for everything.

    AssignInput:
      type: object
      title: Assign input
      properties:
        userId:
          type: integer
          format: int64
          nullable: true
          description: |
            The user to hand the thread to, matching `id` in `GET /users`. **Null
            or absent unassigns it.** A user who is not on your account is a
            `400`.

    BlockInput:
      type: object
      title: Block input
      required: [number]
      properties:
        number:
          type: string
          description: The sender to block, in digits.
        reason:
          type: string
          description: Free text for your own records.

    ScheduleInput:
      type: object
      title: Schedule input
      required: [to, sendAt]
      properties:
        to:
          type: array
          minItems: 1
          items: { type: string }
        message:
          type: string
          description: Required unless `media` is set.
        subject:
          type: string
        media:
          type: array
          description: |
            Hosted URLs, not inline file content — a scheduled MMS references
            files that already exist rather than carrying megabytes that must be
            stored until the send time.
          items: { type: string }
        sendAt:
          type: string
          format: date-time
          description: When to send it, RFC 3339. Must be in the future — a past time is almost always a timezone bug in the caller, so it is rejected rather than sent immediately.

    TemplateInput:
      type: object
      title: Template input
      required: [title, body]
      properties:
        title: { type: string, minLength: 1 }
        body: { type: string, minLength: 1 }

    DraftInput:
      type: object
      title: Draft input
      required: [message]
      properties:
        message:
          type: string
          description: The unsent text. An empty string is legal — it stores "the user cleared the box" without deleting the draft.

    User:
      type: object
      title: User
      description: One user of the company.
      required: [id, username, firstName, lastName, title, email, mobile, company, timeZone, extension, extensions, lines, numbers, voicemails]
      properties:
        id:
          type: integer
          format: int64
          description: |
            The user's id on the phone system. This is the value every other
            endpoint means by a user id — the entries in `SMSLine.userIds` are
            these ids.
        username:
          type: string
          description: The login this person signs in with. Empty for a user created without portal access.
        firstName: { type: string }
        lastName: { type: string }
        title:
          type: string
          description: Job title as entered in the portal. Free text.
        email: { type: string }
        mobile:
          type: string
          description: |
            The personal/cell number on the user's profile — **not** a number on
            the account. May be blank.
        company:
          type: string
          description: |
            The per-user company override, falling back to the account's own
            name — so it is always populated and always a name a caller would
            recognize.
        timeZone:
          type: string
          description: |
            The user's IANA zone. Every timestamp on this surface is UTC; this
            is what turns one into the wall-clock time this person saw.
          example: America/New_York
        extension:
          type: string
          description: |
            The **dial-by-name** extension — the digits the auto-attendant
            matches for this person. Not necessarily a phone extension they can
            be reached on; see `extensions`.
        extensions:
          type: array
          description: The PBX extensions assigned to this user — what a colleague dials internally.
          items: { type: string }
        lines:
          type: array
          description: The SIP lines (devices/registrations) the user owns.
          items: { type: string }
        numbers:
          type: array
          description: The account DIDs owned by this user. A number with no owner appears on nobody.
          items: { type: string }
        voicemails:
          type: array
          description: The mailbox names the user owns.
          items: { type: string }

    CallSearchInput:
      type: object
      title: Call search
      required: [start, end]
      properties:
        start:
          type: integer
          format: int64
          description: Start of the window, **unix seconds**. Required.
          example: 1754006400
        end:
          type: integer
          format: int64
          description: |
            End of the window, unix seconds. Required, must be after `start`, and
            at most **366 days** later.
          example: 1754092800
        timeZone:
          type: string
          description: |
            IANA zone, e.g. `America/New_York`. Decides where a day boundary
            falls for the day-of-week and day-of-month filters. **Defaults to
            UTC, which shifts every daily figure** — send it.
          example: America/New_York
        limit:
          type: integer
          description: Page size. Defaults to 50, capped at 500.
          default: 50
          maximum: 500
        offset:
          type: integer
          description: Rows to skip.
          default: 0
        direction:
          type: string
          enum: [inbound, outbound]
          description: Omit for both.
        filters: { $ref: "#/components/schemas/CallFilters" }
        sort: { $ref: "#/components/schemas/CallSort" }

    CallFilters:
      type: object
      title: Call filters
      description: Every field is optional. Excludes are applied after includes.
      properties:
        numbers:
          type: array
          items: { type: string }
          description: Match these account numbers.
        numbersExclude:
          type: array
          items: { type: string }
        extensions:
          type: array
          items: { type: string }
        extensionsExclude:
          type: array
          items: { type: string }
        queues:
          type: array
          items: { type: string }
        queuesExclude:
          type: array
          items: { type: string }
        huntgroups:
          type: array
          items: { type: string }
        huntgroupsExclude:
          type: array
          items: { type: string }
        users:
          type: array
          items: { type: string }
        directions:
          type: array
          items: { type: string, enum: [inbound, outbound] }
        statuses:
          type: array
          items: { type: string, enum: [answered, missed, voicemail] }
          description: The words, not the numeric codes.
        search:
          type: string
          description: Matches caller and called names and numbers.
        duration: { $ref: "#/components/schemas/CallRange" }
        talkTime: { $ref: "#/components/schemas/CallRange" }
        waitTime: { $ref: "#/components/schemas/CallRange" }
        timeOfDay: { $ref: "#/components/schemas/CallRange" }
        callPurpose:
          type: array
          items: { type: string }
          description: |
            **Per-account vocabulary**, not a fixed enum. A value your account
            does not use matches nothing — that is not an error, and not a typo
            we can catch for you.
        callSentiment:
          type: array
          items: { type: string }
          description: Per-account vocabulary, as `callPurpose`.

    CallRange:
      type: object
      title: Range
      description: |
        Either end may be omitted for an open bound. Durations are **seconds**;
        `timeOfDay` takes `HH:MM`.
      properties:
        start: { type: string }
        end: { type: string }

    CallSort:
      type: object
      title: Sort
      properties:
        field:
          type: string
          description: A response field name, e.g. `startTime`.
          example: startTime
        direction:
          type: string
          enum: [asc, desc]
          default: desc

    CallPage:
      type: object
      title: Call page
      required: [calls, total, limit, offset]
      properties:
        calls:
          type: array
          items: { $ref: "#/components/schemas/Call" }
        total:
          type: integer
          format: int64
          description: |
            How many calls match the filter across **all** pages — what a
            "1–50 of 812" control needs. Not the size of this page.
        limit:
          type: integer
          description: |
            The page size actually applied, after defaulting and capping. Send
            `limit: 10000` and this comes back `500`, so a short page is
            explained rather than mysterious.
        offset:
          type: integer

    Call:
      type: object
      title: Call
      required: [id, direction, status, statusCode, startTime, durationSeconds, talkTimeSeconds]
      properties:
        id:
          type: string
          description: |
            The call id — the same value in call webhooks and the recordings
            endpoints. **Keep it a string:** it looks like a decimal number and
            is not one, and parsing it as a float destroys the low digits.
          example: "1754489201.884213"
        direction:
          type: string
          enum: [inbound, outbound]
        status:
          type: string
          enum: [answered, missed, voicemail, unknown]
          description: The word rather than the switch's integer.
        statusCode:
          type: integer
          format: int64
          description: |
            The raw code, because `status` flattens a distinction some clients
            need: `-1` missed, `0` voicemail, `1` answered, and **anything above
            1 means answered by that many people** — a call that rang a group and
            was picked up after a transfer. All of those report `answered`.
        callerType:
          type: string
          description: |
            `external`, `user`, `queue`, `huntgroup`. **Read this before assuming
            a number field holds a phone number** — on an internal leg it is an
            extension.
        callerName: { type: string }
        callerNumber: { type: string }
        calledType: { type: string }
        calledName: { type: string }
        calledNumber: { type: string }
        dialledNumber:
          type: string
          description: What was actually dialled — for an inbound call, your DID.
        callerIdInternal: { type: string }
        callerIdExternal: { type: string }
        startTime:
          type: string
          format: date-time
          description: RFC 3339 UTC.
        endTime:
          type: string
          format: date-time
          description: Omitted for a call with no recorded end.
        durationSeconds:
          type: integer
          format: int64
          description: Includes ringing.
        talkTimeSeconds:
          type: integer
          format: int64
          description: |
            Connected time only. On a missed call this is `0` and
            `durationSeconds` is how long it rang.
        transferred:
          type: integer
          description: A **count**, not a boolean — a call can be transferred more than once.
        abandoned:
          type: boolean
          description: |
            The **caller** hung up before anyone answered. This is what separates
            "we missed it" from "they gave up" — reporting on missed calls
            without it blames agents for calls nobody could have taken.
        recorded:
          type: boolean
          description: |
            Audio was captured. Fetch it with `GET /calls/{callid}/recordings` —
            this stays `true` for a call whose recording was since deleted, so it
            means "was recorded", not "can be played".
        calledBack:
          type: string
          description: The id of the call that returned this one, when an abandoned call was called back.
        repeated:
          type: integer
          format: int64
          description: Earlier calls from the same caller within the window.

    CallDetail:
      allOf:
        - $ref: "#/components/schemas/Call"
        - type: object
          title: Call detail
          required: [legs]
          properties:
            legs:
              type: array
              description: |
                The per-leg breakdown, in the order the switch recorded them. A
                call that rang a queue, was answered and was transferred is one
                call and several legs.
              items: { $ref: "#/components/schemas/CallLeg" }

    CallLeg:
      type: object
      title: Call leg
      required: [uniqueId, callId, startTime, durationSeconds, talkTimeSeconds]
      properties:
        uniqueId:
          type: string
          description: Identifies this leg, and is what `GET /calls/{callid}/recordings/{uniqueid}` takes.
        callId:
          type: string
          description: The switch's id for **this leg**, which on a transferred call differs from the call id you asked for.
        bridgedFromCallId:
          type: string
          description: The leg this one was bridged from. **Chain on it to follow a transfer.**
        startTime: { type: string, format: date-time }
        endTime: { type: string, format: date-time }
        durationSeconds: { type: integer }
        talkTimeSeconds: { type: integer }
        fromType: { type: string }
        from:
          type: string
          description: On an internal leg this is an extension, not a phone number.
        fromName: { type: string }
        toType: { type: string }
        to: { type: string }
        hasRecording:
          type: boolean
          description: |
            Whether **playable** audio still exists for this leg — checked
            against the recording stores, not inferred from the CDR, which keeps
            its recording path after a deletion. Only populated for a credential
            allowed to hear recordings, so `false` means "no, or you may not
            know".

    CallTranscript:
      type: object
      title: Transcript
      required: [id, callId, segments]
      properties:
        id: { type: integer, format: int64 }
        callId:
          type: string
          description: Shared by every transcript of the call.
        recording:
          type: string
          description: |
            The audio file this was transcribed from, and **the reliable way to
            tell which leg it is**: the name embeds the leg's `uniqueId` with the
            dot removed — `1785979165.372492` becomes
            `record_1785979165372492_93458.wav`. Without it the only option is
            pairing transcripts to legs by position and hoping the orders agree.
          example: record_1754489201884213_93458.wav
        segments:
          type: array
          description: The diarized utterances in time order. Empty when the audio produced no transcript.
          items: { $ref: "#/components/schemas/TranscriptSegment" }
        shortSummary:
          type: string
          description: Absent when summarization did not run, which is normal rather than an error.
        bulletPoints:
          type: array
          items: { type: string }
        callPurpose:
          type: string
          description: Per-account vocabulary, not a fixed enum.
        callSentiment:
          type: string
          description: Per-account vocabulary, not a fixed enum.
        customerSatisfactionScore:
          type: integer
          description: |
            **Absent is not zero.** A missing score means the call was never
            analyzed; zero would mean it scored nothing. Averaging nulls as zeros
            is the most common way these numbers get misreported.
        customerSatisfactionScoreNote: { type: string }
        agentPerformanceScore:
          type: integer
          description: Absent when not scored — see `customerSatisfactionScore`.
        agentPerformanceScoreNote: { type: string }
        actionRequired: { type: boolean }
        actionRequiredNote: { type: string }
        urgentActionRequired: { type: boolean }
        urgentActionRequiredNote: { type: string }
        agentName: { type: string }
        agentSpeaker:
          type: integer
          description: |
            Which diarized speaker is the agent. **Absent when the analysis could
            not tell them apart** — do not fall back to assuming speaker `0`.
        customKpis:
          type: array
          items: { $ref: "#/components/schemas/TranscriptKPI" }

    TranscriptSegment:
      type: object
      title: Transcript segment
      required: [start, end, speaker, text]
      properties:
        start:
          type: number
          description: Seconds from the start of the audio. Fractional.
        end:
          type: number
          description: Seconds from the start of the audio. Fractional.
        speaker:
          type: integer
          description: |
            A **diarization index, not an identity**. `0` and `1` distinguish
            voices, and the mapping is not stable between calls. Use the
            transcript's `agentSpeaker` to tell which one is the agent.
        text: { type: string }

    TranscriptKPI:
      type: object
      title: KPI
      required: [name]
      properties:
        name:
          type: string
          description: The KPI as your account defined it — these are per-account.
        value:
          type: integer
          description: Absent means the KPI was not scored on this call, which is not the same as scoring zero.
        note:
          type: string
          description: The model's reasoning, when it gave one.

    Recording:
      type: object
      title: Recording
      description: One recorded leg of a call.
      required: [uniqueId, callId, url, startTime, durationSeconds, talkTimeSeconds, from, to]
      properties:
        uniqueId:
          type: string
          description: Identifies this leg. Pass it back to the per-leg endpoint to re-fetch just this one.
        callId:
          type: string
          description: |
            The switch's call id **for the leg**. On a transferred call it can
            differ from the id in the request path — the request id names the
            whole journey, this one names the leg.
        url:
          type: string
          format: uri
          description: |
            Plays the audio. A stable **signed** link on this platform, not the
            storage location:

            - Requires **no** `Authorization` header, so it can go straight into
              an `<audio>` element or a transcription service.
            - The account id is sealed inside the signature, so it cannot be
              edited to reach another account's recording.
            - **It does not expire. Treat it as a secret** — anyone holding it
              can play the conversation.
        startTime:
          type: string
          format: date-time
          description: When this leg started, RFC 3339 UTC.
        durationSeconds:
          type: integer
          description: The leg's full length **including ringing**.
        talkTimeSeconds:
          type: integer
          description: Only the connected portion — the closer match to the length of the audio.
        from:
          type: string
          description: The leg's originating endpoint. On an internal leg this is an extension, not a phone number.
        to:
          type: string
          description: The leg's destination endpoint. On an internal leg this is an extension.

    WebhookType:
      type: string
      title: Event type
      description: |
        The event a subscription fires on. Fetch the live list from
        `GET /webhooks/types`.
      enum:
        - AccountRealTimeCalls
        - AccountEndedCalls
        - UserEndedCalls
        - AccountSMS
        - AccountAITranscription

    Webhook:
      type: object
      title: Webhook subscription
      required: [id, name, description, url, type, enabled, createdAt, updatedAt]
      properties:
        id: { type: integer, format: int64 }
        name: { type: string }
        description: { type: string }
        url:
          type: string
          format: uri
          description: Where events are delivered. `http` or `https`, max 1024 characters.
        type: { $ref: "#/components/schemas/WebhookType" }
        enabled:
          type: boolean
          description: Whether events are being delivered.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
      description: |
        There is no `customerId`: the credential already determines the account,
        so echoing it back would invite a client to think it can send a
        different one.

    WebhookCreateInput:
      type: object
      title: Create input
      required: [name, url, type]
      properties:
        name:
          type: string
          maxLength: 255
          description: Required, non-empty.
        description: { type: string }
        url:
          type: string
          format: uri
          maxLength: 1024
          description: Required. Must use `http` or `https` and include a host.
        type: { $ref: "#/components/schemas/WebhookType" }
        enabled:
          type: boolean
          default: false
          description: Defaults to `false` so nothing fires at an endpoint that is not ready.

    WebhookUpdateInput:
      type: object
      title: Update input
      description: |
        A **merge** — omit a field to leave it unchanged. An explicit `""`
        clears `description`. A body with no updatable field is a `400`.
      properties:
        name: { type: string, maxLength: 255 }
        description: { type: string }
        url: { type: string, format: uri, maxLength: 1024 }
        type: { $ref: "#/components/schemas/WebhookType" }
        enabled: { type: boolean }

    ErrorEnvelope:
      type: object
      title: Error
      required: [error]
      properties:
        error:
          type: object
          required: [type, message]
          properties:
            type:
              type: string
              description: |
                The stable, machine-readable classifier. **Branch on this.**
                New values may be added; an existing one is never repurposed.
              enum:
                - invalid_request
                - unauthenticated
                - insufficient_scope
                - permission_denied
                - not_found
                - conflict
                - unsupported
                - rate_limited
                - unavailable
                - internal_error
            message:
              type: string
              description: Human-readable, and **may change at any time**. Never parse it.
            scope:
              type: string
              description: |
                Set on `insufficient_scope` — exactly which capability to
                request at your next authorization.

  responses:
    BadRequest:
      description: |
        `invalid_request` — a missing or malformed field, an unknown enum value,
        or an unknown field in the body. **Nothing was charged.**
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            missingField:
              value:
                error:
                  type: invalid_request
                  message: "from is required: the account number to send from"
    Unauthenticated:
      description: |
        `unauthenticated` — no credential, or one that is invalid, revoked or
        expired. The `WWW-Authenticate` header names the scope the endpoint
        wanted.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            missing:
              value:
                error:
                  type: unauthenticated
                  message: 'authentication required: send an OAuth access token or an API key as "Authorization: Bearer …"'
    Forbidden:
      description: |
        Two different failures share this status, and the `type` tells them
        apart:

        - `insufficient_scope` — the credential is valid but lacks the scope.
          The `scope` field names what to request. **Fix: re-authorize.**
        - `permission_denied` — the scope is fine, but the person behind the
          credential lacks the role permission. **Fix: change their role in the
          admin center.**
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            scope:
              summary: Missing scope — fix in your code
              value:
                error:
                  type: insufficient_scope
                  message: credential is not authorized for sms:send
                  scope: sms:send
            role:
              summary: Missing role permission — fix in the portal
              value:
                error:
                  type: permission_denied
                  message: the user who authorized this application does not have access to call recordings
    Unpriced:
      description: |
        `invalid_request` — **no active rate applies to one of the destinations.**
        This API will not send a message it cannot price.

        Not a malformed request, and **retrying will never succeed**: the
        destination is not on the account's rate card, which only support can
        change. Nothing was sent and nothing was charged.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            default:
              value:
                error:
                  type: invalid_request
                  message: destination is not priced
    NotFound:
      description: |
        `not_found` — no such object, or it is not this account's. The two are
        deliberately indistinguishable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            default:
              value: { error: { type: not_found, message: not found } }
    InternalError:
      description: |
        `internal_error` — something failed on our side. For a send, **nothing
        was charged**, guaranteed, which is what makes a retry safe.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorEnvelope" }
          examples:
            default:
              value: { error: { type: internal_error, message: internal error } }
