# Compatibility API (Twilio and Telnyx)

New code should use the native API ([README.md](/guides/overview.md)). This section documents the Twilio- and
Telnyx-compatible APIs, kept so that existing code migrates by changing its SDK base URL:

| Migrating from | SDK base URL | Guide |
|---|---|---|
| Twilio REST (`/2010-04-01/Accounts/{AccountSid}/...`) | `https://api.contactivity.io/compat/twilio` | [compat-migration.md](/guides/compat-migration.md) |
| Telnyx Call Control v2 and TeXML | `https://api.contactivity.io/compat/telnyx/v2` | [compat-migration.md](/guides/compat-migration.md) |

The interactive reference is the [Compatibility](/compatibility/) tab (spec
`https://api.contactivity.io/docs/compat/openapi.yaml`). Its
paths map to the compatible base URLs: `/v1/voice/X` is what `/compat/twilio/2010-04-01/Accounts/{AccountSid}/X.json`
reaches, and `/v1/voice/call-control/X` is what `/compat/telnyx/v2/X` reaches. Call them through the `/compat`
base URLs. The SDKs `contactivity` (Twilio) and `contactivity-callcontrol` (Telnyx), for Node and
Python, default to those base URLs.

Native equivalents:

| Compatibility | Native |
|---|---|
| `POST /Calls` (form, PascalCase) | `POST /v1/projects/{project_id}/calls` (JSON) |
| `POST /Calls/{CallSid}` (Url / Twiml / Status) | `POST /calls/{call_id}/actions/redirect` or `/actions/hangup` |
| Call Control `POST /calls/{id}/actions/{command}` | `POST /calls/{call_id}/actions/{action}`; renamed: `coxml_redirect` → `redirect`, `playback_start/stop` → `play`/`stop_play`, `gather_stop` → `stop_gather`, `streaming_start/stop` → `stream_start/stop`, `suppression_start/stop` → `noise_suppression_start/stop`, `client_state_update` → `update_client_state`; `command_id` → `Idempotency-Key` header |
| `Recordings`, `Transcriptions` | `/recordings`, `/transcriptions` |
| `Conferences`, `Participants` | `/conferences`, `/conferences/{id}/participants` |
| `Queues`, `Members` | `/queues`, `/queues/{id}/members` |
| `IncomingPhoneNumbers`, `AvailablePhoneNumbers`, `/api/v1/numbers` | `/numbers`, `/available-numbers` |
| `OutgoingCallerIds` | `/verified-numbers` |
| Twilio error body `{code, message, more_info, status}`; Telnyx `{errors: [...]}` | `{error: {type, code, message, param, request_id, doc_url}}` |
| Basic auth (AccountSid + token) | `Authorization: Bearer ck_…` |
| Page tokens / `page[number]` | `limit` + `starting_after` cursor |

The rest of this page is the compatibility reference.

## Authentication

| API | Method |
|---|---|
| Compatibility | HTTP Basic: `AccountSid` (project id) as the username, the project's auth token or an API key secret as the password |
| Call Control | `Authorization: Bearer <API key>` |
| PBX apps (`/v1/projects/{project_id}/pbx-apps…`) | HTTP Basic as for the compatibility API (the username must be the `{project_id}` in the path), or the portal's user token (`Authorization: Bearer <JWT>`) |

API keys are created per project in the portal, can be limited to voice, and are stored as hashes. Requests over plain HTTP are refused.

## Compatibility API

Requests are `application/x-www-form-urlencoded`, with Twilio's parameter names. Responses are JSON. The project comes from the credentials, so paths don't repeat the AccountSid. Moving from Twilio means changing the base URL from `/2010-04-01/Accounts/{AccountSid}` to `/v1/voice` and dropping the `.json` suffix. Code written against Twilio's REST URLs can instead keep them: migrating from Twilio, set your SDK base URL to `https://api.contactivity.io/compat/twilio` and the same handlers serve `/compat/twilio/2010-04-01/Accounts/{AccountSid}/...` (Telnyx: `https://api.contactivity.io/compat/telnyx/v2`). See [compat-migration.md](/guides/compat-migration.md).

### Calls

| Method and path | Purpose |
|---|---|
| `POST /Calls` | Create an outbound call |
| `GET /Calls` | List calls (filters: `To`, `From`, `Status`, `StartTime>`, `StartTime<`, `ParentCallSid`; paging `PageSize`, `PageToken`) |
| `GET /Calls/{CallSid}` | Fetch one call |
| `POST /Calls/{CallSid}` | Change a live call: new `Url` or `Twiml`, or `Status=completed` / `canceled` |
| `DELETE /Calls/{CallSid}` | Archive the call record (completed calls only); it then answers `404` and leaves lists |

`POST /Calls` parameters:

| Parameter | Required | Default | Notes |
|---|---|---|---|
| `To` | yes | — | E.164 number, `sip:` URI, or `client:<name>` |
| `From` | yes | — | A number the project owns or a verified number |
| `Url` or `Twiml` | one of them | — | Where to fetch CoXML on answer, or the CoXML itself (up to 4,000 characters). Plivo XML is accepted too, per the voice app's Markup setting (`coxml`, `plivo`, `auto`) |
| `Markup` | no | the app's setting | `coxml`, `plivo` or `auto`, for calls not tied to a voice app |
| `Method` | no | `POST` | For `Url` |
| `FallbackUrl`, `FallbackMethod` | no | — | Used if `Url` fails |
| `StatusCallback` | no | — | Progress URL |
| `StatusCallbackEvent` | no | `completed` | Space-separated: `initiated ringing answered completed` |
| `StatusCallbackMethod` | no | `POST` | |
| `Timeout` | no | `60` | Seconds to ring, 5–600 |
| `TimeLimit` | no | `14400` | Maximum call length; the credit limit also applies |
| `Record` | no | `false` | Record the whole call |
| `RecordingChannels` | no | `mono` | `mono`, `dual` |
| `RecordingStatusCallback` | no | — | |
| `MachineDetection` | no | — | `Enable` or `DetectMessageEnd`. Detection runs on answer, before the first document is requested; `AnsweredBy` (`human`, `machine_start`, `machine_end_beep`, `machine_end_silence`, `machine_end_other`, `fax`, `unknown`) is sent with that request and the status callbacks, and returned in the call resource. `Enable` decides within about 4 s; `DetectMessageEnd` waits for the greeting's beep or silence (up to 30 s), so a message played next starts after the beep. See [Answering machine detection](#answering-machine-detection). |
| `MachineDetectionTimeout` | no | `30` | Seconds of analysis, 3–59; then `machine_end_other` or `unknown` |
| `MachineDetectionSpeechThreshold` | no | `2400` | ms of speech that make a greeting a machine, 1000–6000 |
| `MachineDetectionSpeechEndThreshold` | no | `1200` | ms of silence that end a greeting, 500–5000 |
| `MachineDetectionSilenceTimeout` | no | `5000` | ms of initial silence before `unknown`, 2000–10000 |
| `AsyncAmd` | no | `false` | `true`: the first document is requested at once (no `AnsweredBy`); the result is POSTed to `AsyncAmdStatusCallback` |
| `AsyncAmdStatusCallback` | no | — | Receives `CallSid`, `AccountSid`, `AnsweredBy`, `MachineDetectionDuration` (ms) |
| `AsyncAmdStatusCallbackMethod` | no | `POST` | |
| `MachineDetectionEngine` | no | `standard` | Contactivity extension: `premium` adds transcript-based classification (billed per call) |
| `SendDigits` | no | — | DTMF after answer |
| `CallerId` | no | — | Alias of `From`, for Twilio compatibility |

The call resource returned:

```json
{
  "sid": "CA5f2c8a1e7d4b4e0f9a1c2d3e4f5a6b7c",
  "account_sid": "AC91ab…",
  "parent_call_sid": null,
  "to": "+13105550000",
  "from": "+15551234567",
  "status": "queued",
  "direction": "outbound-api",
  "start_time": null,
  "end_time": null,
  "duration": null,
  "price": null,
  "price_unit": "USD",
  "answered_by": null,
  "quality": null,
  "date_created": "Thu, 09 Oct 2026 10:15:00 +0000",
  "uri": "/v1/voice/Calls/CA5f2c…",
  "subresource_uris": {
    "recordings": "/v1/voice/Calls/CA5f2c…/Recordings"
  }
}
```

`price` is the call's price, filled in when the call ends.

#### Call quality

`quality` is our extension to the Twilio call resource (Twilio keeps this in Voice Insights): the media quality of this leg, `null` until the leg has ended or when it carried no audio. Each leg is its own call resource, so the quality of a `<Dial>` leg is on the child call (`GET /Calls?ParentCallSid=…`).

```json
"quality": { "mos": 4.41, "jitter_ms": 1.5, "packet_loss_pct": 0.2, "packets": 1500, "source": "engine" }
```

| Field | Meaning |
|---|---|
| `mos` | Mean opinion score, 1–5, of the audio Contactivity received from the far end of this leg. Shown as good (≥ 4), fair (≥ 3.6) or poor |
| `jitter_ms` | Inter-arrival jitter in ms (engine legs: the peak over the call) |
| `packet_loss_pct` | Packets missing from the RTP sequence, in percent of those expected |
| `rtt_ms` | Round-trip time in ms, when known (relayed calls with RTCP); absent for engine legs |
| `packets` | RTP packets received |
| `source` | `engine` (calls the voice engine handles) or `relay` (SIP calls relayed without the engine) |

Portal call logs show the quality of each leg.

### Recordings

| Method and path | Purpose |
|---|---|
| `GET /Recordings` | List recordings (filters: `CallSid`, `DateCreated>`, `DateCreated<`) |
| `GET /Recordings/{RecordingSid}` | Recording details |
| `GET /Recordings/{RecordingSid}.wav` or `.mp3` | Download: redirects to a signed API link (`/v1/recording-files/…`) valid for 10 minutes, which streams the decrypted audio |
| `DELETE /Recordings/{RecordingSid}` | Archive the recording (the file is kept); it then answers `404` and leaves lists |
| `GET /Calls/{CallSid}/Recordings` | Recordings of one call |
| `POST /Calls/{CallSid}/Recordings` | Start recording a live call (`RecordingChannels`, `RecordingStatusCallback`) |
| `POST /Calls/{CallSid}/Recordings/{RecordingSid}` | `Status=paused`, `in-progress` or `stopped`; with `paused`, `PauseBehavior=skip` (default: the paused audio is left out of the recording) or `silence` (silence is recorded) |
| `GET /Recordings/{RecordingSid}/Transcriptions` | Transcriptions of one recording (from `<Record transcribe>`) |
| `GET /Recordings/{RecordingSid}/Transcriptions/{TranscriptionSid}`, `DELETE` same | One transcription; archive it |
| `GET /Transcriptions`, `GET /Transcriptions/{TranscriptionSid}`, `DELETE /Transcriptions/{TranscriptionSid}` | All transcriptions of the project, newest first |

**Dual-channel.** `RecordingChannels=dual` (Calls API, `POST /Calls/{CallSid}/Recordings`), `<Dial record="record-from-answer-dual">` / `record-from-ringing-dual` and Call Control `record_start` `channels: "dual"` record a stereo WAV: the caller (the call's own leg) on the left channel, the other party on the right. The recording's `channels` is `2` (Call Control `channels: "dual"`), and so is the callback's `RecordingChannels`. SIP trunk recordings are always dual. Transcription mixes both channels down to one track.

**Encryption at rest.** Every recording file (and its MP3 copy) is encrypted before it is stored, with a random 256-bit key per project: AES-256-GCM in 64 KiB chunks, each with its own nonce and tag, so tampering or truncation is detected on read. The project keys are stored wrapped by a master key held only in the API's environment (`RECORDING_MASTER_KEY`). Downloads always go through the API, which decrypts while it streams; signed links work as before. Deleting a project deletes its key. Recordings stored before encryption was turned on are served as they are.

**Retention.** Recordings, transcriptions, AI agent transcripts and call inspector logs are never erased. `DELETE` archives a recording or transcription (hidden, the file kept). Project retention settings (`PUT /v1/projects/{id}/compliance`) are accepted and ignored; `GET` returns them as `null`.

**Transcription redaction.** With the project's `transcription_redaction`, PII in `transcription_text` is replaced by tokens like `[PHONE]` before it is stored and sent to the callback.

A transcription is Twilio's Transcription resource: `sid` (`TR…`), `recording_sid`, `status` (`in-progress`, `completed`, `failed`), `transcription_text`, `duration` (audio seconds), `price` (negative, USD, per audio minute), `price_unit`, `type` (`fast`), `date_created`, `date_updated`, `uri`. API key scopes: `recordings:read` and `recordings:write`. Deleting a recording deletes its transcription. The portal reads a recording's transcript at `GET /v1/projects/{project_id}/recordings/{RecordingSid}/transcription` (`{sid, recording_sid, status, text, language, provider, model, duration, billed_secs, price, error, created_at, updated_at}`, 404 when the recording was not transcribed).

### Conferences and participants

| Method and path | Purpose |
|---|---|
| `GET /Conferences` | List (filters: `FriendlyName`, `Status`) |
| `GET /Conferences/{ConferenceSid}` | Fetch |
| `POST /Conferences/{ConferenceSid}` | `Status=completed` ends it; `AnnounceUrl` plays CoXML to everyone |
| `GET /Conferences/{ConferenceSid}/Participants` | List participants |
| `POST /Conferences/{ConferenceSid}/Participants` | Dial a new participant into the conference (`From`, `To`, plus the `<Conference>` attributes; `Coaching` + `CallSidToCoach` join it as a coach) |
| `GET /Conferences/{ConferenceSid}/Participants/{CallSid}` | Fetch participant |
| `POST /Conferences/{ConferenceSid}/Participants/{CallSid}` | `Muted`, `Hold`, `HoldUrl`, `AnnounceUrl`, `Coaching` + `CallSidToCoach` (coach mode: only the coached participant hears this one; `Coaching=false` ends it) |
| `DELETE /Conferences/{ConferenceSid}/Participants/{CallSid}` | Remove participant |

`{CallSid}` in participant paths can also be the participant's label.

### Queues and members

| Method and path | Purpose |
|---|---|
| `POST /Queues` | Create (`FriendlyName`, `MaxSize`, default 100) |
| `GET /Queues`, `GET /Queues/{QueueSid}` | List, fetch (with `current_size`, `average_wait_time`) |
| `POST /Queues/{QueueSid}` | Update `MaxSize` |
| `DELETE /Queues/{QueueSid}` | Archive (must be empty); it then refuses callers |
| `GET /Queues/{QueueSid}/Members` | Callers waiting |
| `POST /Queues/{QueueSid}/Members/Front` or `/{CallSid}` | Take a caller off the queue: `Url` gives the CoXML to run (usually `<Dial>` to an agent) |

## Call Control API

Requests and responses are JSON. Every command accepts:
- `client_state`: base64 string, echoed in every later webhook for this call;
- `command_id`: a repeated id within 60 seconds is ignored, so retries are safe.

### Calls

| Method and path | Purpose |
|---|---|
| `POST /v1/voice/call-control/calls` | Dial a new call. Body: `connection_id` (the voice app), `to`, `from`, `timeout_secs`, `time_limit_secs`, `answering_machine_detection`, `record` (`record-from-answer`), `client_state`, `webhook_url` (overrides the app's) |
| `GET /v1/voice/call-control/calls/{call_control_id}` | Current state: `is_alive`, `call_duration` |

Response of `POST /v1/voice/call-control/calls`:

```json
{
  "data": {
    "record_type": "call",
    "call_control_id": "v3:…",
    "call_leg_id": "4f8c…",
    "call_session_id": "9a1d…",
    "is_alive": false
  }
}
```

### Actions: `POST /v1/voice/call-control/calls/{call_control_id}/actions/{command}`

| Command | Body fields | Resulting webhooks |
|---|---|---|
| `answer` | `client_state`, `command_id` | `call.answered` |
| `hangup` | — | `call.hangup` |
| `reject` | `cause`: `CALL_REJECTED` or `USER_BUSY` | `call.hangup` |
| `bridge` | `call_control_id` (the other call), `park_after_unbridge` (`self`: this call stays up, parked, when the bridge ends; by default it hangs up). The other call hangs up when this one does, unless its own `bridge` asked for `self` | `call.bridged` |
| `transfer` | `to`, `from`, `timeout_secs`, `answering_machine_detection`, `answering_machine_detection_config` (as for dial, below; detection runs on the new leg once it answers, while it is bridged, and its events carry that leg's `call_control_id` and this call's `call_session_id`) | `call.initiated`, `call.bridged` |

With `answering_machine_detection` (`detect` or `detect_words`), dialed calls send `call.machine.detection.ended` with `result`: `human`, `machine`, `fax` or `not_sure`. `detect_beep` and `greeting_end` also wait for the end of a machine's greeting and then send `call.machine.greeting.ended` (`beep_detected`, `ended` or `not_sure`). `premium` sends `call.machine.premium.detection.ended` (`human_residence`, `human_business`, `machine`, `silence`, `fax_detected`, `not_sure`, plus `confidence` 0–1) and, for machines, `call.machine.premium.greeting.ended` (`beep_detected` or `no_beep`). `answering_machine_detection_config` takes `total_analysis_time_millis`, `after_greeting_silence_millis`, `greeting_duration_millis` and `initial_silence_millis` (the Twilio thresholds above). Detection runs before the next command is accepted.
| `refer` | `sip_address` | `call.refer.started`, `call.refer.completed` or `.failed` |
| `speak` | `payload`, `payload_type` (`text` or `ssml`), `voice`, `language` | `call.speak.started`, `call.speak.ended` |
| `playback_start` | `audio_url`, `loop`, `overlay` (`true`: mixed over the audio already playing, at once, not queued behind the running command; WAV only, played once; `playback_stop` stops it) | `call.playback.started`, `call.playback.ended` |
| `playback_stop` | `stop` (`current` or `all`) | `call.playback.ended` |
| `gather` | `minimum_digits`, `maximum_digits`, `timeout_millis`, `terminating_digit`, `valid_digits`, `inter_digit_timeout_millis` | `call.gather.ended` |
| `gather_using_speak` | `gather` fields + `payload`, `voice`, `language`, `input` (`dtmf`, `speech`, `dtmf speech`), `hints` | `call.gather.ended` (with `digits` or `speech`) |
| `gather_using_audio` | `gather` fields + `audio_url` | `call.gather.ended` |
| `gather_stop` | — | `call.gather.ended` (status `cancelled`) |
| `record_start` | `format` (`wav`, `mp3`; recordings are WAV for now, `mp3` is accepted and recorded as WAV), `channels` (`single`, `dual`), `play_beep` (default `false`: a short tone as the recording starts), `max_length` (seconds, 0–14400, `0` = no limit: the recording is then stopped and saved), `transcription` (bool), `transcription_language` (default `en-US`), `transcription_engine` (accepted, ignored) | `call.recording.saved` (after stop and upload); with `transcription`, then `call.recording.transcription.saved` (`recording_id`, `recording_transcription_id`, `status`, `transcription_text`) |
| `record_stop`, `record_pause`, `record_resume` | — (a pause leaves the paused audio out of the recording) | — |
| `send_dtmf` | `digits`, `duration_millis` | — |
| `enqueue` | `queue_name`, `max_wait_time_secs`, `max_size` | `call.enqueued`, `call.dequeued` |
| `leave_queue` | — | `call.dequeued` |
| `streaming_start` | `stream_url`, `stream_track` (`inbound_track`, `outbound_track`, `both_tracks`), `stream_bidirectional_mode` (`rtp`: the server's `media` messages, PCMU, are played to the caller for as long as the stream runs, between and after other commands; `mp3` is refused with 400, like other invalid parameters) | `streaming.started`, `streaming.stopped`, `streaming.failed` |
| `streaming_stop` | — | `streaming.stopped` |
| `transcription_start` | `language` (default `en-US`), `transcription_tracks` (`inbound`, `outbound`, `both`), `interim_results` (bool), `transcription_engine` (accepted, ignored) | `call.transcription` with `transcription_data`: `transcript`, `confidence`, `is_final`, `transcription_track` |
| `transcription_stop` | — | — |
| `ai_agent_start` | `voice_app_id` (an `ai_agent` app), `parameters` (object of strings) | `call.ai_agent.started`, `call.ai_agent.ended` (`status`, `transfer_to`, `error`); the agent app's `agent.*` webhooks |
| `gather_using_ai` | `parameters` (JSON schema of the fields to collect: `properties`, `required`; 1–20 properties), `greeting`, `voice` (voice id), `language` (default `en-US`), `assistant` (`instructions`, `model`), `message_history` (`[{"role": "user" or "assistant", "content"}]`, up to 50: the conversation so far), `timeout_secs` (30–1800, default 120; ours); `send_partial_results` is accepted and ignored | `call.ai_gather.ended` with `status` (`completed`, `partial`, `timeout`, `caller_hangup`, `error`), `result` (the collected fields), `message_history` and `error`. Same agent as CoXML `<AIGather>`, billed as an AI agent |
| `suppression_start` | `direction` (`inbound` (default, the caller's audio), `outbound` (audio sent to the caller), `both`); `noise_suppression_engine` is ignored (one engine: RNNoise) | — |
| `suppression_stop` | — | — |
| `coxml_redirect` | `url` or `coxml` | (hands the call to CoXML; Call Control webhooks stop) |

Responses: `200 {"data": {"result": "ok"}}`. A command for a call that has ended returns `422` with error `call_has_ended`.

Noise suppression (`suppression_start`) runs RNNoise in the media server on the chosen direction; everything downstream of it (the bridged party, recordings, streams, transcription, AI agents) gets the cleaned audio. A second `suppression_start` changes the direction; it ends with the call. Price: $0.002 per minute while it runs (placeholder; ai_rates part `noise_suppression`), rounded up to the billing increment, debited from the prepaid balance once per call at hangup (ledger kind `noise_suppression`, external id `ns:<call sid>`). It is billed the same way when started by CoXML `<Start><NoiseSuppression>` or an AI agent with `noise_suppression` set.

### Conferences

| Method and path | Purpose |
|---|---|
| `POST /v1/voice/call-control/conferences` | Create from an existing call: `call_control_id`, `name`, `beep_enabled` (`always` (default), `never`, `on_enter`, `on_exit`: the tones when this call enters / leaves), `start_conference_on_create` (default `true`; `false`: the creating call hears hold music until a participant that starts the conference joins) |
| `GET /v1/voice/call-control/conferences`, `GET /v1/voice/call-control/conferences/{id}` | List, fetch |
| `GET /v1/voice/call-control/conferences/{id}/participants` | Participants |
| `POST /v1/voice/call-control/conferences/{id}/actions/{command}` | `join` (`call_control_id`, `beep_enabled`), `leave`, `mute`, `unmute`, `hold`, `unhold`, `speak`, `play`, `record_start`, `record_stop`, `end` |

Conference webhooks (Telnyx envelope and keys): `conference.created` (when the first participant enters) and `conference.ended` (`reason`: `all_left`, `ended_via_api`, `host_left`) go to the webhook of the conference's first Call Control participant, normally the call that created it, with that call's ids. `conference.participant.joined` / `left` go to each Call Control participant's own webhook, with its ids. All carry `conference_id`. A conference `record_start` sends `conference.recording.saved` (`conference_id`, `recording_id`, `recording_urls`, `channels`, `format`, `recording_started_at` / `recording_ended_at`; the call ids are null) to the conference's webhook after upload. They work the same when participants are on different engine nodes.

## Answering machine detection

The engine listens to the called party's audio from answer (voice/src/amd.rs), 20 ms at a time:

- **Greeting shape:** a short greeting followed by silence (`MachineDetectionSpeechEndThreshold`) is a person; speech reaching `MachineDetectionSpeechThreshold` is a machine, unless its cadence is a person's ("Hello? … Hello?": pauses most of the time). In between, many words in a row (a recording's cadence) mean a machine.
- **Beep:** a single tone between 350 and 2000 Hz holding most of the audio's energy for 160 ms, found by a Goertzel scan; it survives line noise down to about 0 dB SNR. A beep after any greeting, even a short "Leave a message", makes the call a machine (`machine_end_beep`, or `machine_start` with `Enable`).
- **Fax:** CNG (1100 Hz, before anyone speaks) or CED (2100 Hz).
- **Premium** (`MachineDetectionEngine=premium`, Call Control `premium`): the first 6 s of the greeting also go to speech-to-text. Voicemail phrases ("leave a message", "after the tone", "not available", "mailbox", "voicemail", "you've reached …") make a short greeting a machine; a business answering ("thank you for calling …, how can I help") keeps a long greeting a person (`human_business`). A decision waits at most 2.5 s for the transcript; without one the audio decision stands.

Price: standard detection is included in the call. Premium is **$0.0075 per call** that runs it (placeholder; Twilio prices AMD per call), debited from the prepaid balance once the detection ends (ai_rates part `amd_premium`, whose `rate_per_min` column holds the per-call price; ledger kind `amd`, external id `amd:<call sid>`).

## Webhooks

### Compatibility API

CoXML requests and status callbacks use form-encoded POSTs with Twilio's parameter names.

### Call Control API

JSON POSTs to the voice app's webhook URL:

```json
{
  "data": {
    "record_type": "event",
    "id": "0ccc7b54-4df3-4bca-a65a-3da1ecc777f0",
    "event_type": "call.answered",
    "occurred_at": "2026-10-09T10:15:04.123Z",
    "payload": {
      "call_control_id": "v3:…",
      "call_leg_id": "4f8c…",
      "call_session_id": "9a1d…",
      "connection_id": "app_42",
      "client_state": "eyJzdGVwIjoxfQ==",
      "from": "+15551234567",
      "to": "+13105550000",
      "direction": "outgoing",
      "state": "answered"
    }
  },
  "meta": { "attempt": 1, "delivered_to": "https://example.com/hooks" }
}
```

Event types:
- **Call:** `call.initiated`, `call.answered`, `call.hangup` (with `hangup_cause`, `hangup_source`, `sip_hangup_cause`), `call.bridged`, `call.dtmf.received`, `call.gather.ended`, `call.speak.started` / `ended`, `call.playback.started` / `ended`, `call.recording.saved`, `call.machine.detection.ended`, `call.machine.greeting.ended`, `call.machine.premium.detection.ended`, `call.machine.premium.greeting.ended`, `call.refer.*`, `call.enqueued`, `call.dequeued`, `call.ai_agent.started` / `ended`, `call.ai_gather.ended`, `call.error`.
- **Streaming:** `streaming.started`, `streaming.stopped`, `streaming.failed`.
- **Conference:** `conference.created`, `conference.ended`, `conference.participant.joined`, `conference.participant.left`, `conference.recording.saved`.

### Signing (both APIs)

| Header | Algorithm |
|---|---|
| `X-Twilio-Signature` | HMAC-SHA1 (compatibility API only, for existing validators) |
| `webhook-id`, `webhook-timestamp`, `webhook-signature` | Ed25519 over `{webhook-id}.{webhook-timestamp}.{raw body}` (Standard Webhooks). The public key is shown in the portal. Reject timestamps older than 5 minutes |

### Delivery

- Timeout 10 seconds per attempt. Any `2xx` counts as delivered.
- Retries for status callbacks and Call Control events: 1 s, 5 s, 30 s, 2 min, 10 min, then the failover URL if one is set.
- Events can arrive out of order or more than once. Use `data.id` (Call Control) or `SequenceNumber` (status callbacks) to sort and deduplicate.
- CoXML document fetches are not retried (the call can't wait); they go to the fallback URL instead.

## Errors

JSON error body for both APIs:

```json
{ "code": 21211, "message": "The 'To' number +1555 is not a valid phone number.", "more_info": "https://docs.contactivity.io/errors/21211", "status": 400 }
```

| HTTP | When |
|---|---|
| 400 | Invalid parameter |
| 401 | Missing or wrong credentials |
| 402 | Not enough credit to start the call |
| 403 | Caller ID not owned, number not in the project, feature not enabled |
| 404 | Unknown resource |
| 409 | Call already controlled by the other API |
| 422 | Command not valid in the call's current state |
| 429 | Rate limit or the account's calls-per-second limit exceeded (`Retry-After` header) |
| 503 | Platform temporarily unable to place calls |

## Rate limits

| Limit | Default |
|---|---|
| API requests per project | 100 per second |
| `POST /Calls` and `POST /v1/voice/call-control/calls` | The account's calls-per-second limit |
| Simultaneous calls | The account's session limit |

