> ## Documentation Index
> Fetch the complete documentation index at: https://apidocs.vodex.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Send an SMS

> `Idempotency-Key` is **mandatory** — retrying a timeout must never
double-text anyone. Only a `201` is replayed; a validation failure or a
provider rejection **releases** the key so a corrected retry can reuse
it. The same key with a different body is `409`, and a second request
while the first is in flight is also `409`.

Every attempt that reaches the provider is logged, sent or failed — the
row *is* the audit trail. A provider rejection is therefore a `502` that
still carries the logged row's `messageId`.

A successful send also enqueues an `interaction.completed` webhook with
`channel: "sms"`, on the same stream as voice.




## OpenAPI

````yaml /openapi/api-public-v1.yaml post /sms
openapi: 3.0.3
info:
  title: Vodex API (v1)
  description: >
    The **public Vodex `/v1` REST API** — everything a customer's own systems

    integrate with: assistants, telephony (numbers, trunks, carrier

    integrations), calls and recordings, batch dialing, the SMS channel, and

    outbound webhooks.


    This is deliberately a **subset**. Account and console-only routes

    (`/v1/auth/*`, `/v1/api-keys`, `/v1/usage`), platform provisioning

    (`/v1/platform/*`), Vodex support operations, and endpoints other systems

    call inward (carrier delivery receipts, the agent worker, cron) are not

    part of the published surface and are not documented here.


    The **data plane** (LiveKit SFU + SIP + the agent worker) is not an HTTP

    API and is not described here: a call reaches the worker over LiveKit, with

    its config carried in participant metadata or read from the config snapshot

    this API publishes.


    ## Authentication


    | Credential | Sent as | Scope |

    |---|---|---|

    | **Tenant API key** | `Authorization: Bearer vdx_live_…` | One customer,
    full access — the credential for everything in this document |


    Mint an API key in the console (**Settings → API keys**). The plaintext is

    shown **once**, by the call that creates it, and no endpoint reads one back

    — a lost key is replaced, not recovered.


    Every route here also answers a signed-in console session, because the

    console is built on this same API. That is **not** an integration

    credential and is deliberately not described as one: it is the console's

    own cookie, it carries a role an API key never has, and it is free to

    change. Integrate with `vdx_live_`.


    ## Customers and tenants


    One thing, two words, and they are not interchangeable:


    - **Customer** is the product word — what a human calls the account, and
      what the console shows you.
    - **Tenant** is the same thing's identity on the wire and in storage:
      `tenantId` on every response.

    **A *workspace* is a third thing, and not a synonym for either.** Tenancy

    is mapped at the customer level deliberately: integrations (carrier, SMS,

    provider credentials) live above the workspaces that use them, so mapping

    workspaces to tenants would fragment one customer's telephony across

    tenants that then have to share credentials. A workspace is therefore a

    **label inside the tenant** (`externalWorkspaceRef`) — for filtering and

    attribution, never isolation. Anything that must not be shared between one

    customer's workspaces needs its own tenant.


    ## Tenancy


    There is **no tenant parameter**. Every tenant-scoped route derives its

    tenant from the credential and binds it for the whole request, so a caller

    cannot ask for another tenant's data by changing a query string. An id that

    belongs to somebody else answers `404`, never `403` — "no such call" is the

    only thing worth confirming.


    ## Response shapes


    Resources are returned **flat**, at the top level (`{ "id": "…", … }`) —

    there is no envelope object. Collections vary by age of the endpoint:


    - Newer lists are `{ "<plural>": [ … ], "total": n, "limit": n, "offset": n
    }`
      (`calls`, `batches`, `rows`, `attempts`, `messages`).
    - Older lists return a **bare JSON array** (`/v1/phone-numbers`,
      `/v1/sip-trunks`, `/v1/integrations`, `/v1/carriers`,
      `/v1/console/assistants`, `/v1/sms/configurations`).

    Deletes and side-effecting no-content operations return `{ "ok": true }`.


    ## Errors


    ```json

    { "error": "human-readable message" }

    ```


    A flat string, not a coded object — HTTP status is the machine signal.

    The one exception is quota exhaustion (`402`), which adds a stable

    `code: "quota_exhausted"` plus the counters the console renders.


    ## Pagination


    `?limit=` and `?offset=`, with `total` in the body. Defaults and ceilings

    differ per endpoint (calls 25/100, batch rows 50/500, attempts 100/500) and

    are stated on each operation.


    ## Conventions


    - **Phone numbers** are E.164 strings (`+14155551234`); `+91…` numbers are
      served from Mumbai, `+1…` from us-central1.
    - **Money** is a JSON number of **USD** (`costUsd`), not a decimal string.

    - **Timestamps** are ISO‑8601 UTC strings, except the calls list, which
      reports `mtime` / `startedAt` as epoch **milliseconds** and durations as
      **milliseconds**.
    - **Ids** are unprefixed: batches, rows, attempts and SMS rows are UUIDs;
      a call id is its LiveKit room name (`call-…`, or `console-…` for web
      calls); a phone number's id is `num-<digits>`; a trunk's is `trunk-…`.
    - **Secrets** (`vdx_live_…`, `whsec_…`) are returned exactly once, by the
      call that mints them. No endpoint reads one back — carrier and provider
      credentials are stored in Secret Manager and echoed only as a
      non-reversible fingerprint.
    - **Idempotency**: `POST /v1/sms` and `POST /v1/batches` **require** an
      `Idempotency-Key` header. Only successful (2xx) responses are replayed;
      a failed attempt releases the key so a corrected retry can reuse it.
  version: 1.0.0
  contact:
    name: Vodex Platform
    url: https://vodex.ai
  license:
    name: Proprietary
    url: https://vodex.ai/terms
servers:
  - url: https://apiv2.vodex.ai/v1
    description: Production
security:
  - ApiKeyBearer: []
tags:
  - name: Assistants
    description: Assistant configurations (the agent's engine, prompt, slots, tools).
  - name: Console
    description: Reference data (model catalog, voices) and the web-call token.
  - name: Usage
    description: Minute usage against the plan.
  - name: Calls
    description: Place calls, read the call log, fetch traces and recordings.
  - name: Phone Numbers
    description: Number inventory and assistant mapping.
  - name: SIP Trunks
    description: Customer-owned (BYO) SIP trunks.
  - name: Carriers
    description: Carrier registry — what can be connected, and how.
  - name: Integrations
    description: Connected carrier accounts and their numbers.
  - name: Batches
    description: Batch dialing — lists in, calls out.
  - name: Number Rotation
    description: Per-number spacing, daily caps, and cooldown.
  - name: Callbacks
    description: >-
      "Call me Tuesday afternoon" — appointments booked mid-call, and dialed
      later.
  - name: SMS
    description: SMS configurations, credentials, preview, send, and message log.
  - name: Webhooks
    description: Outbound webhook configuration and delivery records.
  - name: Tenants
    description: Recording retention.
paths:
  /sms:
    post:
      tags:
        - SMS
      summary: Send an SMS
      description: |
        `Idempotency-Key` is **mandatory** — retrying a timeout must never
        double-text anyone. Only a `201` is replayed; a validation failure or a
        provider rejection **releases** the key so a corrected retry can reuse
        it. The same key with a different body is `409`, and a second request
        while the first is in flight is also `409`.

        Every attempt that reaches the provider is logged, sent or failed — the
        row *is* the audit trail. A provider rejection is therefore a `502` that
        still carries the logged row's `messageId`.

        A successful send also enqueues an `interaction.completed` webhook with
        `channel: "sms"`, on the same stream as voice.
      operationId: sendSms
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyRequired'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendSmsInput'
      responses:
        '201':
          description: Sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SmsMessage'
        '400':
          description: Missing or malformed `Idempotency-Key`, or a bad `to`/`from`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unknown SMS configuration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Idempotency-Key conflict, or a request with that key is in flight.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Nothing to send, or the configuration cannot send this message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: The provider rejected the message — logged, with its row id.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                  messageId:
                    type: string
                    format: uuid
      callbacks:
        interactionCompleted:
          '{$request.body#/webhookUrl}':
            post:
              summary: interaction.completed (channel = sms)
              description: >-
                The same envelope voice calls use — one stream for every
                channel, so a consumer switches on `channel` rather than
                integrating a second webhook.
              requestBody:
                content:
                  application/json:
                    schema:
                      $ref: '#/components/schemas/InteractionCompleted'
              responses:
                '408':
                  description: Retried — a timeout is transient.
                '429':
                  description: Retried, with the backoff below.
                2XX:
                  description: Acknowledged — the delivery is settled.
                5XX:
                  description: Retried, with the backoff below.
                4XX:
                  description: >-
                    NOT retried. Any other 4xx is the consumer saying the
                    request itself is wrong, and repeating it only burns the
                    attempt budget a real outage needs.
              method: post
              type: path
            path: '{$request.body#/webhookUrl}'
components:
  parameters:
    IdempotencyKeyRequired:
      name: Idempotency-Key
      in: header
      required: true
      schema:
        type: string
        minLength: 8
        maxLength: 255
      description: >-
        Required. A retry with the same key and body replays the stored 2xx
        response; the same key with a different body is `409`. A failed attempt
        releases the key.
  schemas:
    SendSmsInput:
      type: object
      required:
        - to
      properties:
        to:
          type: string
          description: E.164.
        message:
          type: string
          description: Free text; wins over the configuration's body.
        smsConfigurationId:
          type: string
          format: uuid
          description: Defaults to the tenant's default configuration.
        templateId:
          type: string
          description: Provider template; the provider renders it.
        from:
          type: string
          description: E.164 override.
        variables:
          type: object
        contactRef:
          type: string
          description: Opaque consumer reference, echoed on the webhook.
        callId:
          type: string
          description: Links this message to a call.
    SmsMessage:
      type: object
      properties:
        id:
          type: string
          format: uuid
        configurationId:
          type: string
          format: uuid
          nullable: true
        direction:
          type: string
          enum:
            - outbound
            - inbound
        toNumber:
          type: string
        fromNumber:
          type: string
          nullable: true
        body:
          type: string
        status:
          type: string
          enum:
            - queued
            - sent
            - delivered
            - failed
        provider:
          type: string
        providerMessageId:
          type: string
          nullable: true
        error:
          type: string
          nullable: true
        contactRef:
          type: string
          nullable: true
        callId:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time
    Error:
      type: object
      properties:
        error:
          type: string
          example: not found
    InteractionCompleted:
      type: object
      description: >
        **The payload Vodex POSTs to your endpoint** — not something this API

        serves. Documented here because it is the contract configured through

        `PUT /v1/webhook-config`.


        `interaction.completed`, not `call.completed`: one stream for every

        channel, so a consumer writes one handler and switches on `channel`

        rather than integrating a new webhook when SMS or WhatsApp ships.


        It is deliberately **complete** — a consumer that receives this needs no

        follow-up GET. `analysis` is an open envelope: summary and sentiment

        land there as additive keys, so a handler written today keeps parsing

        tomorrow's payloads.


        ### Headers


        | Header | Meaning |

        |---|---|

        | `x-vodex-signature` | `t=<unix>,v1=<hex>` where `v1 =
        HMAC-SHA256(secret, "<t>.<raw body>")` |

        | `x-vodex-event` | The event type |

        | `x-vodex-delivery` | Delivery id — the same event retried keeps its
        `id`, so use that for idempotency |


        The timestamp is **inside** the signed string, not merely alongside it:

        signing the body alone leaves a captured delivery replayable forever,

        because nothing in what was signed says when it was sent. Reject

        anything more than **300 seconds** off in either direction. During a

        rotation both secrets sign, as repeated `v1=` values, so consumers can

        move without a coordinated deploy.


        ### Retries


        8 attempts at 10s, 30s, 2m, 10m, 30m, 2h, 6h, each with **full jitter**

        (a random point in `[0, delay]`, so a consumer coming back from an

        outage is not hit by every queued delivery at the same instant). 408,

        429, 5xx and transport failures retry; every other 4xx does not —

        repeating a request the consumer called wrong just burns the budget a

        real outage needs. A delivery that exhausts its attempts is the dead

        letter — `status: failed` with no `nextAttemptAt` — and stays visible at

        `GET /v1/webhook-deliveries`.
      properties:
        type:
          type: string
          enum:
            - interaction.completed
        id:
          type: string
          description: Idempotency key — stable across retries.
        tenantId:
          type: string
          format: uuid
        channel:
          type: string
          enum:
            - voice-ai
            - sms
        contactRef:
          type: string
          nullable: true
          description: Echoed from the dial request, never interpreted.
        rawAddress:
          type: string
          nullable: true
          description: The counterparty address, for when no `contactRef` was passed.
        direction:
          type: string
        from:
          type: string
          nullable: true
        to:
          type: string
          nullable: true
        assistantId:
          type: string
          nullable: true
        engine:
          type: string
          nullable: true
        startedAt:
          type: string
          format: date-time
          nullable: true
        endedAt:
          type: string
          format: date-time
          nullable: true
        outcome:
          type: object
          description: >-
            Orthogonal facts, never a prose string — a consumer should never
            have to sniff a string for what happened.
          properties:
            answered:
              type: boolean
            connectedMs:
              type: integer
            endedBy:
              type: string
              nullable: true
              enum:
                - caller
                - agent
                - system
                - null
              description: >-
                `system` is one of our own limits firing (max duration, silence
                timeout). Null when the call was never answered and nothing in
                the trace said why.
            endReason:
              type: string
              nullable: true
              enum:
                - agent_ended
                - caller_hangup
                - carrier_dropped
                - transferred
                - voicemail_left
                - no_answer
                - busy
                - rejected
                - invalid_number
                - blocked_dnd
                - carrier_failed
                - max_duration
                - silence_timeout
                - pipeline_error
                - null
              description: >-
                A fixed wire vocabulary, deliberately separate from the internal
                enum so a consumer's histogram does not shift when the internals
                are tidied. Two things that might be expected here are not:
                whether anyone picked up is `outcome.answered`, and a voicemail
                *detection* rides in `outcome.voicemail` — the call still ended
                for one of the reasons above. Null when nothing said why.
            error:
              type: string
              nullable: true
            voicemail:
              type: object
              description: >-
                What the detector decided, not what one signal saw: the acoustic
                vote alone fires on humans too (a chatty callee's opening
                monologue trips it), so `detected` means the votes reached a
                decision.
              properties:
                detected:
                  type: boolean
                confidence:
                  type: number
                  example: 0.67
                  description: >-
                    **Not a probability.** The detector votes, and this is the
                    share of its three signals that agreed: `0.67` is the
                    ordinary two-vote decision, `1.0` is all three, and `0.33` a
                    confident LLM verdict deciding alone. `0` when nothing was
                    detected.
                signals:
                  type: array
                  items:
                    type: string
                    enum:
                      - acoustic
                      - keyword
                      - llm
                  description: >-
                    Which independent signals agreed — the words behind
                    `confidence`. Empty when nothing was detected.
                action:
                  type: string
                  nullable: true
                  enum:
                    - hangup
                    - leaveMessage
                    - null
                  description: >-
                    What the assistant was configured to do about a machine.
                    Null when nothing was detected. `leaveMessage` here says the
                    action ran; whether the message actually played is what
                    separates `endReason: voicemail_left` from `agent_ended`.
            transfer:
              type: object
              nullable: true
              description: >-
                The transfer that actually happened, or null if none was
                attempted. A transfer is a sequence — requested, then resolved —
                and this reports the last one, so a warm attempt that failed and
                fell back to a cold one that completed reports the cold one.
              properties:
                kind:
                  type: string
                  enum:
                    - cold
                    - warm
                  description: Cold (a SIP REFER) unless the warm path ran.
                destination:
                  type: string
                  nullable: true
                result:
                  type: string
                  nullable: true
                  enum:
                    - requested
                    - completed
                    - failed
                    - null
                  description: '`requested` means the attempt never resolved.'
            callback:
              type: object
              nullable: true
              description: >-
                The callback the assistant promised on this call, or `null` if
                it promised none — branch on presence, not on a boolean. Already
                booked in Vodex when this fires, so no follow-up GET is needed.
              properties:
                id:
                  type: string
                  format: uuid
                  nullable: true
                  description: The `scheduled_callbacks` row, for `GET /v1/callbacks/{id}`.
                at:
                  type: string
                  format: date-time
                  description: When the callback is due, as an instant.
                timezone:
                  type: string
                  nullable: true
                  description: IANA zone the caller was speaking in, when one was known.
                note:
                  type: string
                  nullable: true
                  description: What the caller asked to be called about.
                assistantId:
                  type: string
                  nullable: true
                  description: Assistant that will make the callback.
                rescheduled:
                  type: boolean
                  description: >-
                    True when this moved an existing promise rather than making
                    a new one.
            direction:
              type: string
        transcript:
          type: array
          description: Turn-level, with offsets in seconds from the first traced event.
          items:
            type: object
            properties:
              role:
                type: string
                enum:
                  - user
                  - agent
              text:
                type: string
              at:
                type: number
              interrupted:
                type: boolean
        analysis:
          type: object
          description: Open envelope — additive keys only.
        quality:
          type: object
          properties:
            turns:
              type: integer
            ttfaP50Ms:
              type: number
              nullable: true
            ttfaMaxMs:
              type: number
              nullable: true
            interruptions:
              type: integer
            fallbacks:
              type: integer
        cost:
          type: object
          properties:
            totalUsd:
              type: number
              nullable: true
            breakdown:
              type: object
              nullable: true
              description: >-
                Absent rather than zeroed — a consumer must be able to tell "not
                computed" from "cost nothing".
        artifacts:
          type: object
          description: >-
            References, never bytes — audio is resolved through the authed API
            when a user presses play, so it never leaves its region unbidden.
          properties:
            recording:
              type: string
              nullable: true
              example: vodex://recordings/call-…
            trace:
              type: string
              example: vodex://traces/call-…
            region:
              type: string
              nullable: true
        variables:
          type: object
          additionalProperties:
            type: string
          description: >-
            Exactly what the agent knew, frozen at call time — the assistant's
            config may have changed since.
  securitySchemes:
    ApiKeyBearer:
      type: http
      scheme: bearer
      description: 'Tenant API key (`vdx_live_…`) as `Authorization: Bearer`.'

````