> For complete lifecycle-event payload schemas and examples, use the canonical overview: Session /api/sessions; Room lifecycle, members, and room playback /api/rooms; Session playback /api/playback; Recording /api/recording; DTMF /api/keypad-input; Messages /api/messages; WebSocket Session /api/web-socket. Use /api/event-delivery for transport behavior. Endpoint pages name relevant events, but these overviews are the canonical references.

# API changelog

Check this page for public API changes that can affect your integration and the
actions you need to take.

## How changes are published

* **Added** identifies a new, backward-compatible capability.
* **Changed** identifies a contract or behavior change and includes migration
  guidance when needed.
* **Fixed** identifies an implementation or documentation correction.
* **Deprecated** identifies a supported capability that you should no longer
  use and includes its replacement and planned removal timeline.

Use the endpoint reference for the current contract. Use this changelog to
understand how that contract differs from an earlier integration.

## August 31, 2026

### Added

* **Secure card payments.** Use
  `POST /v1/sessions/{uuid}/payment/card/collect` and
  `POST /v1/sessions/{uuid}/payment/cvv/collect` to receive call-scoped opaque
  references without receiving card or CVV digits. A successful
  `payment.card.collected` webhook includes a `card_uuid` reference in the
  `card_<lowercase-uuid-v4>` format and safe display values under
  `payment_method_details.card.last4` and `.brand`. A successful
  `payment.cvv.collected` webhook includes a `cvv_uuid` reference in the
  `cvv_<lowercase-uuid-v4>` format. Invalid input produces a sanitized outcome,
  such as `reason: "luhn_check_failed"`, without disclosing the rejected digits.

  Secure collection and Room membership are mutually exclusive. Starting
  collection for a Session that is a Room member, or adding a Session to a Room
  while collection is active, returns `409 Conflict`. While collection is
  active, the platform also rejects new Recordings and `unmask` requests. After
  the terminal collection webhook arrives, each Recording that the platform
  masked or paused remains in that state; call the Recording's `unmask`
  endpoint when the application is ready to resume writing sensitive-free
  audio.

  Submit the opaque references to
  `POST /v1/sessions/{uuid}/payment/charges` while the Session is connected.
  Include a required `Idempotency-Key`, `exp_month`, `exp_year`, a positive
  `amount` in the currency's smallest unit, lowercase `currency: "ils"`,
  `provider: "nedarim"`, and the customer's seven-digit `terminal_number`.
  Nedarim accepts only whole-ILS amounts, so an ILS amount in agorot must be
  divisible by 100.

  A charge's first provider attempt atomically consumes its `card_uuid` and
  `cvv_uuid`. An exact replay with the same `Idempotency-Key` returns the
  original sanitized HTTP result and terminal outcome. A new operation cannot
  reuse either reference, so collect new card and CVV references before another
  charge attempt. An unused reference expires no later than one hour after
  collection, and Session cleanup normally removes it sooner.

  The terminal `payment.charge.completed` webhook reports the trusted amount,
  currency, provider, terminal number, status, and retryability. A successful
  result can also include the transaction creation time, `receipt_created`,
  validated card details, installment count, and normalized transaction type,
  card classification, issuer, and acquirer. Use `status` and the originating
  `operation_uuid` to correlate the outcome. The webhook excludes provider
  transaction identifiers, authorization codes, voucher numbers, UIDs, raw
  responses, provider-authored prose, PAN, CVV, expiry, identity numbers,
  customer PII, receipt URLs, and bearer capabilities.

  The platform durably stages each accepted collection result. A service
  interruption or Session end completes the staged result, or emits a terminal
  failed result if collection cannot be recovered. A charge timeout is reported
  as `indeterminate` with `retryable: false` and is never automatically retried.

### Changed

* **Nedarim terminals must be assigned to the calling application.** Continue
  sending the customer's seven-digit `terminal_number` with every charge; the
  value is not a secret and is not replaced by a server-side default. The
  platform now verifies that the terminal is assigned to the authenticated
  application before accepting the charge. An unassigned terminal returns
  `403 Forbidden` without creating a charge operation. Ask the platform
  operator to assign each customer's terminal before sending its first charge.

* **Every accepted charge retains a terminal outcome across Session end.** If
  the Session hangs up or a service restarts before provider processing begins,
  the platform emits a terminal failed outcome. If provider submission already
  began and the result cannot be proven, it emits `indeterminate` with
  `retryable: false`; a stored sanitized completion remains replayable. Do not
  create a replacement charge merely because the Session ended. Wait for and
  deduplicate `payment.charge.completed` by `operation_uuid`.

### Fixed

* **Standalone WebSocket lifecycle webhook delivery.** The platform again
  delivers `websocket.connected`, `websocket.disconnected`,
  `websocket.failed`, and `websocket.audio_playback_completed` for standalone
  WebSocket Sessions. The last confirmed working configuration was August 18,
  2026 at 01:31 Israel time; the first affected configuration was August 21,
  2026 at 04:30 Israel time. During that interval, these events could be
  omitted before webhook delivery, so there was no webhook HTTP response or
  stable error code. For example, an expected redacted event such as
  `{"event":"websocket.disconnected","session_uuid":"sess_..."}` could be
  absent. Missed historical events are not replayed. No request or webhook
  schema changed, and no migration is required; continue normal webhook
  handling and at-least-once deduplication.

## August 30, 2026

### Added

* **Optional inclusion of recording gaps.**
  `POST /v1/sessions/{uuid}/recordings` now accepts the optional boolean
  `include_gaps`, which defaults to `false`. Omit it or set it to `false` to
  remove redacted periods and periods without recordable audio from the
  artifact. Set `"include_gaps": true` to include those periods as silence.
  The setting applies only to that Recording and is returned by the Recording
  list and inspection endpoints. Existing requests do not need to change.

* **Session hold-state webhooks.** Applications now receive `session.held`
  when a Session enters hold and `session.resumed` when it returns from hold.
  Both events carry `session_uuid` in addition to the standard webhook fields.
  Existing integrations do not need to change. Add either event to your
  webhook handler when your application needs to track whether a Session is
  on hold.

* **MP3 file playback.** Session Playback, Room Playback, and optional DTMF
  collection prompts now accept finite HTTP or HTTPS MP3 URLs in addition to
  WAV URLs. Submit the original `.mp3` URL in `urls` or `prompt_files`; no
  client-side conversion or alternate filename is required. Existing WAV
  integrations are unchanged.

* **Opt-in Room voice-activity events.** `POST /api/v1/rooms` now accepts the
  optional boolean `voice_activity_events`. Set it to `true` when the Room must
  emit `room.member.voice_activity_changed` as members start and stop talking.
  It defaults to `false`, so existing Room creation requests continue to work
  without receiving these high-frequency events. The Room resource reports the
  selected value. The event was previously documented without an activation
  control, although Rooms did not emit it. If your integration was built from
  that earlier documentation and expects voice-activity events, add
  `"voice_activity_events": true` when creating each Room; existing Rooms must
  be replaced with Rooms created with the option enabled.

* **Room-member volume results.** `POST /v1/sessions/{uuid}/room/volume`
  now reports its asynchronous result through exactly one terminal webhook.
  `room.member.volume_changed` confirms that every requested direction was
  applied. `room.member.volume_change_failed` reports an execution failure and
  includes `applied_directions` and `failed_directions`, so a `direction: both`
  request can report a partial result without implying an atomic two-direction
  change. Both events include the submitted `volume` and `direction`, plus the
  `session_uuid`, `room_id`, and originating `operation_uuid`.

* **Reject inbound calls or cancel outbound dialing before answer.** Use
  `POST /v1/sessions/{uuid}/reject` with a required `reason` of
  `number_not_found`, `invalid_number`, `timeout`, `busy`, or `rejected` for an
  unanswered inbound phone call. Use
  `POST /v1/sessions/{uuid}/cancel` to stop an outbound phone call before the
  callee answers. Both operations return `202 Accepted`, followed by the
  existing `session.ended` webhook. For a rejection, that webhook reports the
  selected `reason` unchanged as `hangup_cause`. Both operations return
  `409 Conflict` for an incompatible Session or lifecycle state. Use
  `DELETE /v1/sessions/{uuid}` when you want to disconnect regardless of answer
  state.

### Changed

* **Session Playback request variants are now named separately in OpenAPI.**
  The `POST /v1/sessions/{uuid}/playback/play` request remains one `oneOf`
  contract, with named schemas for file playback, pushed audio, and silence.
  Generated-audio uploads use `SessionPlaybackPushRequest`.
  This schema-organization change does not alter endpoint URLs or wire payloads
  beyond the changes documented separately below, and requires no additional
  migration.

* **Breaking: generated-audio Session Playback uses `type: push` and an HTTPS
  streaming upload.** `POST /v1/sessions/{uuid}/playback/play` now distinguishes
  audio pushed by the application from live HTTP/HTTPS URL sources. Send
  `type: push` with the raw audio format. The `202 Accepted` response includes
  an `ingest` object with the short-lived URL, required HTTP method and headers,
  and expiry. Follow that object to stream raw mono audio in one request body.
  Authenticate the ingest request with the same application Bearer credentials
  that you use for other Voice API requests. The response does not repeat those
  credentials in `ingest.headers`.
  There is no compatibility alias: a Session request that uses `type: stream`
  with `media_format` now returns `400 Bad Request`.

  | Previous Session request                                                        | Replacement Session request                                                   |
  | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
  | `{"type":"stream","url":"https://media.example.com/live"}`                      | `{"type":"push","media_format":{"encoding":"pcm_s16le","sample_rate":24000}}` |
  | `{"type":"stream","media_format":{"encoding":"pcm_s16le","sample_rate":24000}}` | `{"type":"push","media_format":{"encoding":"pcm_s16le","sample_rate":24000}}` |

  Replace flat upload metadata with the nested ingest contract:

  | Previous `202` fields                                            | Replacement `202` field                                                                                                                    |
  | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
  | `"upload_url":"https://...","expires_at":"2026-08-30T19:00:00Z"` | `"ingest":{"url":"https://...","method":"POST","headers":{"Content-Type":"application/octet-stream"},"expires_at":"2026-08-30T19:00:00Z"}` |

  The initial documentation described the upload as a WebSocket and later as a
  streaming `PUT`. Replace either transport with the returned ingest contract:

  | Previous upload transport                                 | Replacement                                                                                                                     |
  | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
  | Connect to `wss://...` and send binary WebSocket messages | Send the returned streaming `POST` with application Bearer authentication and every returned header                             |
  | Send a streaming `PUT` to `upload_url`                    | Send the returned streaming `POST` to `ingest.url` with application Bearer authentication and every entry from `ingest.headers` |

  Supported formats are little-endian PCM16 at 8, 16, 24, or 48 kHz and G.711
  mu-law at 8 kHz. The body is unframed raw audio. Close it after the final
  audio bytes. A 2xx ingest response confirms that the platform accepted the
  complete request body; it does not confirm that the caller heard all audio.
  The Voice API verifies that the authenticated application owns the Playback
  operation before it accepts the upload. Do not send Voice API credentials to
  any URL other than the Voice API origin that your integration is configured
  to use.

  `type: stream` remains the discriminator for live HTTP/HTTPS URL playback,
  such as radio. Room Playback keeps its
  `{"type":"stream","url":"https://..."}` request shape; live Room streams
  remain unsupported and return `502`. This entry supersedes the earlier
  August 22 statement that live streaming upload was not part of the contract.

* **Breaking: Session recordings are addressable resources.** A Session can now
  own multiple concurrent recordings, including recordings with identical
  settings. Each accepted create request returns a new `recording_uuid`, and
  subsequent inspection and control requests address that exact Recording.
  There is no singleton Recording, implicit “current recording,” or stop-all
  operation. Update endpoint usage as follows:

  | Previous endpoint                           | Replacement                                                   |
  | ------------------------------------------- | ------------------------------------------------------------- |
  | `POST /v1/sessions/{uuid}/recording/start`  | `POST /v1/sessions/{uuid}/recordings`                         |
  | `POST /v1/sessions/{uuid}/recording/stop`   | `POST /v1/sessions/{uuid}/recordings/{recording_uuid}/stop`   |
  | `POST /v1/sessions/{uuid}/recording/mask`   | `POST /v1/sessions/{uuid}/recordings/{recording_uuid}/mask`   |
  | `POST /v1/sessions/{uuid}/recording/unmask` | `POST /v1/sessions/{uuid}/recordings/{recording_uuid}/unmask` |

  The old endpoints have been removed without compatibility aliases. Persist
  the `recording_uuid` returned by the create request and use it for later
  commands. Use `GET /v1/sessions/{uuid}/recordings` to reconcile active or
  stopping recordings, or
  `GET /v1/sessions/{uuid}/recordings/{recording_uuid}` to inspect one live
  Recording. These endpoints do not provide recording history.

  Create requests now require `direction` (`both`, `session_in`, or
  `session_out`) and `channels` (`mono` or `stereo`). The initial release
  accepts only `{"direction":"both","channels":"mono"}`; other valid
  combinations return HTTP `400` as unsupported.

  Voice-activity events remain optional on each create request and default to
  disabled. Each Recording configures voice-activity detection independently,
  so multiple concurrent Recordings may enable events with different
  `voice_activity_config` values. Each Recording receives its own events,
  correlated by `recording_uuid` and the originating `operation_uuid`.
  Stopping one Recording stops only its events and does not affect the other
  active Recordings.

* **Breaking: WebSocket failure reason renamed.** The `websocket.failed`
  webhook now reports `reason: connection_precondition_failed` instead of
  `relay_precondition_failed` when the platform cannot satisfy a prerequisite
  for opening the WebSocket Session connection. Update enum validation and
  failure handling to use the new value; there is no compatibility alias. The
  event name, `error` bucket, HTTP status semantics, and other reason values are
  unchanged.

* **Room-wide webhook ordering.** The platform now delivers all `room.*` events
  for one Room in production order. Failures block only that Room. Payloads are
  unchanged, and no migration is required.

* **Breaking: Room member volume directions.** The `direction` field in
  `POST /v1/sessions/{uuid}/room/volume` requests and the corresponding
  `direction`, `applied_directions`, and `failed_directions` webhook fields now
  use names relative to the Room member. Rename `in` to `from_member` and `out`
  to `to_member`; `both` is unchanged. `from_member` is the member's audio sent
  into the Room, while `to_member` is the Room mix sent to the member. WebSocket media-format
  fields remain `from_session` and `to_session` because those directions are
  relative to the Session.

* **Silence playback admission is fully asynchronous.**
  `POST /v1/sessions/{uuid}/playback/play` with `type: silence` now returns
  `202 Accepted` after durable workflow admission, without waiting for the
  earlier playback stop to complete. The platform still stops existing
  playback before starting indefinite silence, but performs that sequence in
  the background and reports progress or failure through the existing playback
  webhooks. Treat `202` as admission only. A silence request no longer returns
  `503` solely because confirmation of the preceding playback stop was not
  received during the HTTP request.

* **Playback start webhook timing.** For file playback, the platform now
  publishes `playback.started` at a later point in startup, using a best-effort
  estimate that playback of the first source has begun. Previously, the event
  followed an earlier source-preparation signal.

### Fixed

* **One logical terminal Session event per call.** The platform now creates one
  `session.ended` event when multiple termination signals or recovery checks
  observe the same call ending. The event uses the public `session_uuid`; an
  internal call identifier is no longer emitted as a separate Session event.
  The event schema is unchanged, and no migration is required. Webhook delivery
  remains at least once, so continue to deduplicate deliveries by
  `(aud, Webhook-Id)`.

* **Terminal event after unrecovered playback media failure.** If playback
  media fails after a request is accepted, the platform first attempts to
  recover it. A successful recovery remains transparent. If recovery is
  exhausted, the platform now emits the operation's existing terminal event:
  `playback.failed` with `reason: media_unavailable` for Session playback,
  `room.playback.failed` for Room playback, or `digits.collected` with
  `status: failure` for prompt-and-collect. Failures before acceptance remain
  synchronous HTTP errors and do not emit these webhooks. Treat webhook
  delivery as at least once and deduplicate by `(aud, Webhook-Id)`.

## August 29, 2026

### Changed

* **Breaking: recording media URL renamed.** Recording API responses and
  webhook payloads now expose `media_url` instead of `pull_url`. Update your
  response and webhook models to read `media_url`; there is no compatibility
  alias. The URL behavior and expiry are unchanged: a GET starts at byte zero
  and follows the growing recording until EOF. After finalization, the same URL
  serves a finite, range-capable WAV. `live_url` is unchanged and continues to
  join near the current position.

## August 28, 2026

### Added

* **SMS Messages API.** Send outbound SMS with `POST /v1/messages`, retrieve a
  message's latest status with `GET /v1/messages/{message_uuid}`, and list
  messages with `GET /v1/messages`. Use Message webhooks for status updates.

### Changed

* **Breaking: retry timing moved to the standard response header.** Public HTTP
  error response bodies no longer include the top-level `retry_after` field.
  Retry timing remains available in the standard
  `Retry-After` response header for `429` rate-limit and `529` overload
  responses. Those JSON bodies now keep the uniform `code` and `message` shape,
  and their human-readable messages end with `See Retry-After header.`

  Update your response model and retry handling as follows:

  | Response                | Previous body                                                                     | New body                                                                                   | Migration                                                                                                                                         |
  | ----------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `429 Too Many Requests` | `{"code":"rate_limited","message":"Command rate limit exceeded","retry_after":1}` | `{"code":"rate_limited","message":"Command rate limit exceeded. See Retry-After header."}` | Remove `retry_after` from the body model. Read the delay from the `Retry-After` response header, wait at least that many seconds, and then retry. |
  | `529 Site Overloaded`   | `{"code":"unavailable","message":"overloaded","retry_after":5}`                   | `{"code":"unavailable","message":"overloaded. See Retry-After header."}`                   | Remove `retry_after` from the body model. Read the delay from the `Retry-After` response header, wait at least that many seconds, and then retry. |

  Continue to branch on the stable `code`, not the human-readable `message`.
  This change does not remove unrelated error fields: playback source failures
  retain `detail`, and unexpected `500` responses retain `support_id`.

### Fixed

* **Session command bodies with idempotency keys.** Session commands that send
  a nonempty JSON request body with `Idempotency-Key` no longer reject the valid
  body as invalid JSON.

## August 27, 2026

### Added

* **Read application configuration.** Authenticated applications can use
  `GET /api/v1/app` to read their assigned inbound phone numbers, permitted
  outbound caller IDs, webhook URL, blocked webhook events, and effective Room
  limits. Use `inbound_phone_numbers` and `outbound_caller_ids` to populate your
  own number-assignment interface. The `GET` operation is read-only: it does not
  assign numbers or expose unassigned numbers or another application's
  configuration. Existing integrations do not need to change.

### Changed

* **Breaking: application configuration replaces webhook-specific routes.**
  Replace `GET /api/v1/app/webhook` with `GET /api/v1/app`, and replace
  `PUT /api/v1/app/webhook` with `PATCH /api/v1/app`. Use the PATCH request to
  set `webhook_url`. A successful update returns `204` without a response body.
  Use the generic GET operation to verify the saved setting. The removed routes
  have no compatibility aliases.

* **Breaking: HTTP response fields standardized.** All Voice Platform HTTP API
  responses, including successful and error responses, no longer include the
  transport-level `success` field. Use the HTTP status code to determine
  whether a request succeeded or failed, and remove `success` from your
  response models and checks.

  Error responses now use top-level `code` and `message` fields. Rename the
  previous `error` string to `message`; for example, change
  `{"success":false,"error":"...","code":"invalid_request"}` to
  `{"code":"invalid_request","message":"..."}`. Unexpected-error fields are
  also top-level, so change
  `{"error":{"code":"internal_error","message":"...","support_id":"..."}}`
  to `{"code":"internal_error","message":"...","support_id":"..."}`.
  At the time of this change, `detail` and `retry_after` remained available
  when applicable. Genuine domain lifecycle `status` fields are unchanged.

* **Breaking: specific Authorization header error code.** A missing or malformed
  application `Authorization` header now returns HTTP `400` with code
  `invalid_authorization_header` instead of `invalid_request`. Update any error
  handling that matches these header failures by code. The `message` identifies
  whether the header is missing or invalid and shows the expected format:
  `Bearer <app_uuid>:<api_key>`. Use the app UUID and API key issued to your
  application. A credential with that structure but a rejected API key remains
  HTTP `401 unauthorized`; other invalid requests
  remain `invalid_request`.

* **Breaking: one request-validation error per response.** Request-schema
  validation failures now return exactly one issue with top-level `code` and
  `message` fields. The message identifies the relevant request path and
  explains how to correct it. For example:
  `{"code":"invalid_request","message":"The \"to\" phone number must match E.164 format"}`.

  Validation responses no longer include `success`, `error`, body `status`,
  `detail`, `errors`, `param`, `location`, or `value`. Read `code` and `message`
  and stop parsing `detail.errors`. When a request has multiple issues, fix the
  returned issue and retry to reveal a later issue. The platform selects the
  returned issue deterministically according to the product contract, not the
  validator's raw finding order.

* **JSON request header compatibility.** Continue to send
  `Content-Type: application/json` with JSON request bodies. For compatibility,
  the platform now tolerates a missing or blank `Content-Type` header and
  attempts JSON parsing. This does not add another supported request media
  type. An explicitly supplied unsupported or malformed media type is still
  rejected with HTTP `415`.

* **Clearer Dial validation errors.** Invalid request bodies now return
  `422 application/json` with one readable `message`. Runtime phone-policy
  errors remain `400 invalid_request`.

## August 26, 2026

### Changed

* **Choose when recording starts.**
  `POST /v1/sessions/{uuid}/recording/start` now accepts the optional boolean
  `start_only_after_answer`. Omit it or set it to `false` to start as early as
  media is available; the recording may include ringing or early media. Set it
  to `true` to wait for canonical answer.

## August 24, 2026

### Changed

* **Breaking: standalone WebSocket event names.** Standalone WebSocket Session
  lifecycle events now use the public `websocket.*` namespace instead of the
  removed sidecar relay's internal name. Update webhook filters as follows:
  * `websocket_relay.connected` → `websocket.connected`
  * `websocket_relay.disconnected` → `websocket.disconnected`
  * `websocket_relay.failed` → `websocket.failed`
  * `websocket_relay.audio_playback_completed` →
    `websocket.audio_playback_completed`
    This pre-launch change has no compatibility aliases. Payload fields and
    delivery behavior are unchanged.

## August 23, 2026

### Added

* **G.711 mu-law relay audio.** WebSocket relay
  `media_format.from_session` and `media_format.to_session` now independently
  accept `encoding: "mulaw"` with the required `sample_rate: 8000`. Binary
  messages contain raw mono G.711 mu-law bytes without a WAV header, JSON, or
  base64 wrapping. Existing `pcm_s16le` integrations do not need to change.
  This adds the audio encoding only; it does not implement the Twilio Media
  Streams message protocol.
* **Custom WebSocket ports.** External WebSocket URLs now accept any TCP port
  from `1` through `65535` for both `ws://` and `wss://`. Ports other than the
  scheme defaults must be explicit. You can omit the port: `ws://` defaults to
  `80`, and `wss://` defaults to `443`. This applies to the `websocket` object
  accepted by `POST /v1/sessions:connectWebsocket`. You can connect a standalone
  WebSocket Session directly to a public server on a custom port, such as
  `wss://media.example.com:8443/audio`. This change supersedes the August 21
  requirement to include an explicit default port.

### Changed

* **Breaking: sidecar WebSocket relay removed.** The API no longer registers
  `POST /v1/sessions/{uuid}/websocket-relay`,
  `DELETE /v1/sessions/{uuid}/websocket-relay`, or the `POST` routes ending in
  `/pause`, `/resume`, `/mute`, `/unmute`, and `/flush`. Requests to those paths
  return `404`, and the operations are absent from the OpenAPI endpoint
  reference. This pre-launch removal has no compatibility alias or deprecation
  period. For passive call audio, use the Recording API and its signed
  `pull_url` or `live_url`. For a bidirectional agent, create a standalone
  WebSocket Session and add it with the phone Sessions to a Room. For one-way
  audio, use Session or Room Playback. Live streaming upload is not part of the
  current contract.

* **Breaking:** Finite-file requests to
  [`POST /api/v1/rooms/{room_id}/playback/play`](api:voice-api:POST/api/v1/rooms/\{room_id}/playback/play)
  now require `type: "files"`. The endpoint no longer treats an omitted `type`
  as `files`; requests without `type` return HTTP `400` with code
  `invalid_request`. Change `{"urls":[...]}` to
  `{"type":"files","urls":[...]}`. Requests for a live stream continue to
  require `type: "stream"` and `url`.

* **Breaking: explicit request choices.** The API no longer supplies default
  values for the non-boolean request fields below. Requests that omit a
  required field return HTTP `400` with code `invalid_request`. To preserve the
  previous behavior, send the former value explicitly:

  | Request                                                | Previously omitted                     | Send explicitly                                                                                                                     |
  | ------------------------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
  | A `key` action in `POST /v1/sessions/{uuid}/dtmf/send` | `duration_ms`                          | `"duration_ms":250`, or your selected duration                                                                                      |
  | `POST /v1/sessions/{uuid}/dtmf/collect`                | Digit limits, timeouts, or terminators | `"min_digits":1`, `"max_digits":10`, `"first_digit_timeout_ms":5000`, `"between_digits_timeout_ms":2000`, and `"terminators":["#"]` |

  For DTMF collection, an explicit `"terminators":[]` now disables
  terminators. Send `"terminators":["#"]` to preserve the previous behavior.
  Boolean fields whose omitted behavior is `false` remain optional. This
  includes `enable_voice_activity_events`, `muted`, `loop`, and `start_muted`.
  Send `true` only when you want to enable the corresponding behavior.

## August 21, 2026

### Added

* **HTTP/2 support.** The public Voice API edge now negotiates HTTP/2 over TLS
  with ALPN and keeps HTTP/1.1 connections reusable. Existing HTTP/1.1
  integrations do not need to change requests or authentication. Clients can
  use HTTP/2 multiplexing or reuse one HTTP/1.1 connection instead of opening a
  connection per request. HTTP/3 is not supported by this edge.

### Changed

* **Breaking: playback source errors.** Synchronous `502` failures while
  admitting a playback or DTMF prompt source now return
  `code: playback_source_error` instead of the generic
  `code: unavailable`. The response includes bounded diagnostic `detail`:
  * `reason` is one of `unsupported`, `not_found`, `access_denied`, `timeout`,
    `unreachable`, `rejected`, `invalid_response`, or `preparation_failed`.
  * `source_index` identifies the failing zero-based item when the request
    contains multiple URLs.
  * `retryable` says whether the same source may succeed if retried later.
  * `upstream_status` contains the source origin's HTTP status when one was
    received.
    Update integrations that branch on `code: unavailable` for these `502`
    responses to branch on `playback_source_error` and use `detail.reason` for
    handling. The API does not return source URLs, credentials, raw upstream
    response bodies, filesystem paths, or media-engine details.

### Fixed

* **Plaintext webhook and WebSocket URLs.** Webhook delivery URLs accept both
  `http://` and `https://`, and WebSocket relay URLs accept both `ws://` and
  `wss://`. Plaintext schemes do not require an operator opt-in flag. Relay
  URLs must include a nonempty path. At the time of this change, relay URLs
  also required an explicit default port. The August 23 change supersedes that
  port requirement. Existing `https://` webhook and `wss://` relay
  configurations do not need to change. Use `https://` and `wss://` unless you
  explicitly accept exposing webhook payloads and signature headers, or call
  audio and relay connection headers, without transport encryption. Both URL
  types remain subject to address-safety checks; the host must resolve to a
  permitted public address.

## August 20, 2026

### Added

* **WebSocket relay failure reasons.** The `websocket_relay.failed` webhook
  event now carries a `reason` field: a
  closed, stable enum that classifies why the relay could not be established. It
  is delivered alongside the existing `error` and `http_status` fields, which
  are unchanged. Existing consumers that only read `error` and `http_status` do
  not need to change. The `reason` values are:
  * `session_codec_unsupported` — the active call is not carried in an audio
    codec the relay supports. This is a platform-side precondition, not a
    problem reaching your WebSocket server.
  * `destination_unreachable` — the target WebSocket server could not be
    reached (DNS resolution, TCP connection, or routing failed).
  * `tls_failed` — the TLS handshake to the target WebSocket server failed.
  * `handshake_rejected` — the target returned a non-success HTTP status to the
    WebSocket upgrade request.
  * `relay_precondition_failed` — a precondition for starting the relay was not
    met.
    Switch on `reason` for actionable handling rather than parsing `error`.
    Treat `session_codec_unsupported` as a platform-side precondition failure, not
    a network problem on your side.

### Fixed

* **Faster finite WAV playback start.** Session Playback for finite PCM WAV
  files now starts without first reading the complete file. Existing playback
  requests do not need to change;
  `playback.started` may arrive sooner, especially for larger files or files
  hosted on slower origins.

## August 19, 2026

### Changed

* **Breaking: caller-supplied operation IDs.** For applicable asynchronous
  commands, move caller-supplied correlation from body or query
  `operation_uuid` to the optional
  `Operation-Id` header. Responses and webhooks continue to use
  `operation_uuid`; `Idempotency-Key` remains separate for retry deduplication.

### Fixed

* **Sidecar WebSocket playback limitation.** The API reference now documents a
  playback limitation: you cannot play a file while a **sidecar** WebSocket is
  connected to the same Session. We are working to remove this limitation.
  Disconnect the sidecar WebSocket before starting
  Session Playback. To play both sources at once, add a
  [standalone WebSocket Session](api:voice-api:POST/v1/sessions:connectWebsocket)
  and the phone Session to the same Room, then use Room Playback.
* **JSON route errors.** Unknown public paths and unsupported HTTP methods on
  known public paths now return the standard JSON error envelope with
  `Content-Type: application/json`
  instead of plain text. A route-level `404` uses `code: not_found`; a `405`
  uses `code: invalid_request`. Integrations that special-cased these plain-text
  responses can now parse them like other API errors and branch on `code`.

## August 15, 2026

### Changed

* **Breaking: Standard Webhooks signing.** Webhook delivery now follows
  Standard Webhooks with Ed25519.
  Update delivery headers and verification as follows; the former headers and
  signing input are no longer sent or accepted:
  * `Idempotency-Key` (delivery) → `Webhook-Id`
  * signature timestamp field `t=` → `Webhook-Timestamp`
  * `X-Webhook-Signature: t=...,akid=...,v1a=...` →
    `Webhook-Signature: v1a,...`
  * `X-Webhook-App-UUID` → required signed body claim `aud`
  * `v1a\n<t>\n<app_uuid>\n<idempotency_key>\n<raw_body>` →
    `<webhook-id>.<webhook-timestamp>.<raw-body>`
    Require `aud` to equal the configured application UUID after signature
    verification and before side effects. During key rotation,
    `Webhook-Signature` can contain two space-delimited `v1a` signatures; accept
    when at least one verifies against the active public-key set. The incoming
    API request header `Idempotency-Key` is unchanged.
* **Breaking: WebSocket relay resource.** The per-session WebSocket relay is
  now a singleton resource.
  Update request URLs and methods as follows; the former command routes are no
  longer accepted:
  * `POST /v1/sessions/{uuid}/websocket/start` →
    `POST /v1/sessions/{uuid}/websocket-relay`
  * `POST /v1/sessions/{uuid}/websocket/stop` →
    `DELETE /v1/sessions/{uuid}/websocket-relay`
  * `POST /v1/sessions/{uuid}/websocket/{pause|resume|mute|unmute|flush}` →
    `POST /v1/sessions/{uuid}/websocket-relay/{pause|resume|mute|unmute|flush}`
* **Relay deletion requests.** Send relay deletion without a request body. To
  supply your own correlation
  identifier, send the optional request header
  `Operation-Id: <lowercase-uuid-v4>`; otherwise, the platform generates one.
  The `202 Accepted` response returns the selected value in its
  `operation_uuid` field. `Operation-Id` is the only request input that selects
  this correlation value; unrelated query parameters receive no special
  handling. Continue to use `Idempotency-Key` separately when retrying the same
  deletion.
* **Breaking: WebSocket text messages.** Custom text messaging over the
  WebSocket relay is no longer
  supported in either direction. The removed `final_text` deletion property
  has no replacement, and `websocket.message_received` is removed without a
  replacement event. Exchange application media as binary audio frames only.
  A backend text frame that is not a platform-reserved relay control closes the
  WebSocket connection with code `1003`; reserved controls are internal and are
  not an application extension point.
* **WebSocket relay event names.** WebSocket relay lifecycle events moved from
  the generic `websocket` namespace to the relay resource namespace. Update
  webhook filters as follows:
  * `websocket.connected` → `websocket_relay.connected`
  * `websocket.disconnected` → `websocket_relay.disconnected`
  * `websocket.failed` → `websocket_relay.failed`
  * `websocket.audio_playback_completed` →
    `websocket_relay.audio_playback_completed`
* **Superseded: webhook delivery IDs.** This intermediate pre-launch contract
  moved the platform-generated delivery key from `X-Idempotency-Key` to
  `Idempotency-Key`. Under that contract, the key value and Ed25519 signing
  preimage were unchanged, and the fourth preimage line remained the unmodified
  delivery-key value. The delivery header was distinct from the
  client-generated `Idempotency-Key` on supported API mutation requests. The
  Standard Webhooks change above superseded this contract; use `Webhook-Id` for
  the final delivery header.

## August 12, 2026

### Added

* **Room Playback correlation.** Every Room Playback command now accepts an
  optional client-generated
  lowercase UUID v4 as `operation_uuid`. When supplied, the API echoes that
  exact value in the command's `202 Accepted` response; when omitted, the
  platform generates one. Use it for correlation, not request deduplication.
* **Idempotent Room Playback pause and stop.**
  [`POST /api/v1/rooms/{room_id}/playback/pause`](api:voice-api:POST/api/v1/rooms/\{room_id}/playback/pause)
  and
  [`POST /api/v1/rooms/{room_id}/playback/stop`](api:voice-api:POST/api/v1/rooms/\{room_id}/playback/stop)
  now support `Idempotency-Key`, completing idempotent retry support across
  Room Playback commands. Keep `operation_uuid` stable for correlation and
  reuse `Idempotency-Key` separately when retrying the same request.

### Changed

* **Breaking: WebSocket media format.** WebSocket relay requests now require a
  `media_format` object for
  each audio direction. This applies to
  `POST /v1/sessions/{uuid}/websocket/start`
  and the `websocket` object accepted by
  [`POST /v1/sessions:connectWebsocket`](api:voice-api:POST/v1/sessions:connectWebsocket).
  Replace the removed fields as follows:
  * `from_session_audio_rate` →
    `media_format.from_session.sample_rate`
  * `to_session_audio_rate` →
    `media_format.to_session.sample_rate`
* **Required PCM encoding.** Add `encoding: "pcm_s16le"` to both directions.
  Both directions, encodings,
  and sample rates are required, with no defaults. Sample rates must be
  multiples of `8000` from `8000` through `192000`. Old flat fields and other
  encodings are rejected; additional encodings are planned but not yet accepted.

### Fixed

* **Room Playback terminal correlation.** `room.playback.ended` and
  `room.playback.failed` now always carry a non-empty, lowercase UUID v4
  `operation_uuid` for the originating play request. The
  platform suppresses a malformed terminal event instead of delivering one
  whose correlation identifier is missing or invalid. Consumers may validate
  this required field as a UUID.
* **WebSocket audio wire format.** Both directions use raw signed 16-bit
  little-endian mono PCM in binary frames. Send samples
  without a WAV header and decode received frames at the configured sample rate.

## August 11, 2026

### Added

* **Promptless DTMF collection.** DTMF collection can now run without prompt
  playback. Omit `prompt_files` from
  [`POST /v1/sessions/{uuid}/dtmf/collect`](api:voice-api:POST/v1/sessions/\{uuid}/dtmf/collect)
  to collect input without playing audio first. Existing requests with
  `prompt_files` still play those prompts before collecting input.

### Changed

* **WebSocket connection headers.** The WebSocket relay request
  property `headers` is now `connection_headers` for
  `POST /v1/sessions/{uuid}/websocket/start`
  and the `websocket` object accepted by
  [`POST /v1/sessions:connectWebsocket`](api:voice-api:POST/v1/sessions:connectWebsocket).
  Update serialized request bodies; `headers` is no longer accepted.

* **Reserved WebSocket headers.** `connection_headers` accepts only
  customer-controlled HTTP headers. Requests
  are rejected when the map contains `User-Agent`, `Origin`, `Host`,
  `Connection`, `Upgrade`, `Sec-WebSocket-*`, `Content-Length`,
  `Transfer-Encoding`, `Proxy-*`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`,
  or `CF-Connecting-IP`, case-insensitively. The platform sends
  `User-Agent: Voice-Platform/1.0` and derives `Origin` from the target
  WebSocket URL.

* **Operation resource removed.** The API no longer models command
  acknowledgements as a separate `Operation` resource. `operation_uuid` is
  unchanged in requests, responses, and webhooks;
  it remains an opaque token for correlating lifecycle events.

## August 10, 2026

### Added

* **Send DTMF.** Applications can send ordered DTMF keypad presses and timed
  waits to the
  remote party on an answered phone session with
  [`POST /v1/sessions/{uuid}/dtmf/send`](api:voice-api:POST/v1/sessions/\{uuid}/dtmf/send).
  Only one sequence can run per session; ringing calls and standalone WebSocket
  sessions are unsupported. `202 Accepted` is the sole admission
  acknowledgement and does not confirm remote handling.
* **Cancel DTMF.** Applications can cancel the active outbound DTMF sequence
  with
  [`DELETE /v1/sessions/{uuid}/dtmf/send`](api:voice-api:DELETE/v1/sessions/\{uuid}/dtmf/send).
  A key already submitted for transmission may finish, but later keys and waits
  are discarded. `202 Accepted` is the sole cancellation acknowledgement; no
  terminal webhook is emitted.

### Changed

* **Command admission webhooks.** `command.*.accepted` admission
  webhooks are no longer emitted. Treat
  `202 Accepted` as the sole admission acknowledgement and use later domain
  lifecycle webhooks only for observed state changes or outcomes. Remove
  `command.*.accepted` filters; correlate lifecycle events through
  `operation_uuid` when present.
* **DTMF collection routes.** DTMF collection routes moved to the
  Keypad API namespace. Update request
  URLs as follows; the former routes are no longer accepted:
  * `POST /v1/sessions/{uuid}/play_and_get_digits` →
    [`POST /v1/sessions/{uuid}/dtmf/collect`](api:voice-api:POST/v1/sessions/\{uuid}/dtmf/collect)
  * `DELETE /v1/sessions/{uuid}/play_and_get_digits` →
    [`DELETE /v1/sessions/{uuid}/dtmf/collect`](api:voice-api:DELETE/v1/sessions/\{uuid}/dtmf/collect)
* **Recording silence duration.** Session recording voice-activity detection
  now accepts `silence_duration_ms`
  up to `3,600,000` milliseconds (one hour), increased from `10,000`. Existing
  values remain valid. Use the larger range to tolerate extended quiet periods
  before `recording.speech.ended`.

### Fixed

* **WebSocket relay restart.** Starting a WebSocket relay after
  `websocket.disconnected` or
  `websocket.failed` now reliably creates a fresh relay instead of failing on a
  remnant of the previous connection. Applications can retry
  `POST /v1/sessions/{uuid}/websocket/start`
  after either event without first sending a stop command.
* **WebSocket URL terminology.** WebSocket relay URLs now consistently document
  `ws://` and `wss://` as
  accepted schemes and call the remote endpoint the WebSocket server. This is a
  documentation correction; existing relay URLs and the `backend` mute-leg
  value are unchanged.

## August 7, 2026

### Added

* **Webhook delivery URL.** Applications could read and update their own
  webhook delivery URL with `GET /api/v1/app/webhook` and
  `PUT /api/v1/app/webhook`. Sending `null` or an empty string disabled
  delivery. These routes were replaced on August 27 by `GET /api/v1/app` and
  `PATCH /api/v1/app`.
* **Health endpoints.** Unauthenticated
  [`GET /health`](api:voice-api:GET/health) and
  [`GET /livez`](api:voice-api:GET/livez) are now part of the public contract.
  Use `/health` for dependency readiness and `/livez` only for process liveness.

### Changed

* **Webhook delivery window.** Webhook events more than five minutes old at
  delivery time go to the
  dead-letter queue without an HTTP attempt. After a prolonged endpoint outage
  or delivery backlog, reconcile current resource state instead of assuming
  every lifecycle event will arrive. See
  [Webhook delivery](/api/event-delivery#delivery-window).
* **Session base path.** The canonical API base URL is
  `https://api.telekesher.dev`. Session endpoints
  moved from `/api/v1/sessions...` to `/v1/sessions...`. Remove the `/api`
  segment from constructed Session request URLs.

## August 6, 2026

### Changed

* **Session event names.** Session webhook names now use the
  `session` resource namespace:
  `call.created`, `call.answered`, `call.ringing_started`,
  `call.early_media_started`, and `call.ended` became their corresponding
  `session.*` events. Update event filters and webhook dispatch logic to use the
  new names.
* **Room Playback terminal events.** Terminal room playback events
  are now `room.playback.ended` for natural
  completion or an API stop, and `room.playback.failed` for errors and
  timeouts. The former `room.playback.finished` is no longer used. Inspect the
  event's `reason` to distinguish the terminal outcome.
* **Session lifecycle fields.** Session lifecycle fields are endpoint-specific.
  Items returned by
  [`GET /sessions`](api:voice-api:GET/v1/sessions) use `state`, while
  [`GET /sessions/{session_uuid}`](api:voice-api:GET/v1/sessions/\{session_uuid})
  use `status`. Both fields have the same lifecycle values. Applications that
  deserialize both responses into one model should accept the field used by
  each endpoint.
* **Shared rate-limit bucket.** `playback/seek`, `recording/mask`, and
  `recording/unmask` draw from one shared
  `seek` rate-limit bucket. A burst of one of these commands can reduce the
  capacity available to the other two. See [Rate limits](/rate-limits).
* **Idempotent command `503` responses.** A `503` after an idempotent command
  may mean the command completed but its
  saved response could not be returned. Retry once with the same
  `Idempotency-Key`. If it remains unavailable, reconcile from the resulting
  resource or webhook events before issuing a new key.
* **WebSocket pause and resume.** WebSocket pause and resume affect audio in
  both directions. Pausing preserves
  buffered backend audio, and resuming does not change mute state.

### Fixed

* **Zero-value playback seeks.** Playback seek requests now reject
  `seek_ms: 0`. Use a positive value to seek
  forward or a negative value to rewind.
* **Authentication error responses.** Authentication and client-error
  responses now distinguish malformed
  credentials (`400`), missing or invalid credentials (`401`), forbidden
  operations (`403`), and resources hidden by application ownership (`404`).
  Error responses expose a stable `code` for programmatic handling.