Contactivity
Compatibility.md

Compatibility API (Twilio and Telnyx)

New code should use the native API (README.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
Telnyx Call Control v2 and TeXML https://api.contactivity.io/compat/telnyx/v2 compat-migration.md

The interactive reference is the 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.

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.
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