# VoiceML (/docs/build/voiceml)

## 개요

한국AI통신 번호로 수신 전화가 들어오면, 플랫폼은 설정된 웹훅 URL로 HTTP 요청을 전송합니다.
서버는 VoiceML(XML)로 응답하여 통화 흐름을 제어합니다.

VoiceML은 TwiML 호환 형식으로, 기존 TwiML 기반 코드를 최소한의 수정으로 사용할 수 있습니다.

## 동사별 검증 상태

각 동사의 운영 환경 실통화 검증 상태입니다. 미검증 항목은 자체 테스트 후 사용 권장.

| 동사 | 상태 | 검증된 속성 / 동작 |
| --- | --- | --- |
| `<Say>` | ✅ 검증 완료 | `language="ko-KR"`, 본문 텍스트 TTS 재생, `voice`(생략 또는 `clawops` 시 무료 기본 음성 / `cartesia:<음성 ID>`·`google:<음성명>` 고품질 유료 음성) |
| `<Play>` | ✅ 검증 완료 | 본문 URL(https) 오디오 재생, WAV (PCM 8k mono) / MP3 (44.1k stereo → 8k mono 자동 변환) 재생, `<Gather>` 중첩 재생, `digits`(키 신호 송출, `w` 대기 포함). `loop` 는 미검증 |
| `<Gather>` | ✅ 검증 완료 | `numDigits` (1/4/6), `timeout` (inter-digit reset, 부분 입력 전달), `action` (POST redirect), nested `<Say>` / `<Play>`, 중첩 프롬프트 barge-in (재생 중 DTMF → 재생 즉시 중단 + 입력 수집), 기본 `finishOnKey="#"`, `Digits` 응답 파라미터 |
| `<Record>` | ✅ 검증 완료 | `maxLength`, `finishOnKey` (키 집합. 기본 `1234567890*#` = 아무 키), `playBeep="true"`, `action`, 응답 파라미터 `RecordingUrl` (24h GCS 서명 URL) / `RecordingDuration` / `Digits`. 무음 종료(`timeout`) 미지원 |
| `<Dial>` | ✅ 부분 검증 | `<Number>` noun, `timeout`, `action`, `referUrl`(연결된 SIP 단말의 REFER 호 전환을 웹훅으로 위임. attended/`Replaces` 는 미지원), `timeLimit`(상한 도달 시 연결된 상대만 종료 + 발신자 통화 유지 + `DialCallStatus=completed` 로 action redirect), `DialCallStatus` 분기 중 `no-answer` / `failed` / `completed` → action redirect 검증. `<Number statusCallback>`(answered/completed·ParentCallId·To/From, 두 통지가 같은 CallId) 검증. `busy` / `callerId` 표시는 미검증 |
| `<Dial><Sip>` | ✅ 검증 완료 | SIP URI(`sip:user@host`) 직접 연결. **SIP 트렁크 연결 부가서비스 필요**. TLS(`sips:`/`;transport=tls`) 지원, 사설 IP 는 거절 |
| `<Connect><Stream>` | ✅ 검증 완료 | `url` (wss\://), 기본 inbound track, WebSocket 프로토콜 (connected/start/media/stop/mark/clear/dtmf — dtmf 는 양방향), μ-law 8k 양방향 오디오, `<Connect action>` (스트림 종료 후 통화 유지 + next URL, `StreamCloseCode` / `StreamCloseReason` 전달) |
| `<Hangup>` | ✅ 검증 완료 | 통화 즉시 종료, 후속 동사 실행 중단 |
| `<Reject>` | ✅ 검증 완료 | `reason="busy\|rejected"`. 응답의 첫 verb 일 때 answer 전 거절 → SIP 486/403 + 통화료·AI 비용 0 |
| `<Redirect>` | ✅ 검증 완료 (간접) | Gather/Dial `action` 경로를 통한 동기적 next-URL fetch + 응답 VoiceML 실행 |
| `<Pause>` | ✅ 검증 완료 | `length` (양의 정수·기본 1·상한 3600), 무효값 → 1초 폴백, `<Gather>` 중첩 시 대기 중 barge |
| `<Message>` | ✅ 부분 검증 | `<Body>` 문자 발송(수신 확인), `to` / `from` 생략 시 통화 상대·보유 번호 자동, SMS 자동 선택, `<Say>` 사이 배치 시 음성 흐름 비차단 검증. `subject`(LMS 강제) / `action` 응답 파라미터 / 통화당 5건 상한은 미검증 |

## 요청 형식

**POST 요청** (기본) — `Content-Type: application/x-www-form-urlencoded` 파라미터가 요청 본문(body)으로 전송됩니다.

**GET 요청** — 파라미터가 쿼리 문자열(query string)으로 전송됩니다.

번호 설정에 서명 키가 등록되어 있으면 POST/GET 모두 `X-Signature` 헤더가 포함됩니다. [서명 검증](/docs/webhooks/signature-verification)을 통해 요청의 무결성을 확인할 수 있습니다.

### 응답 한도

- 요청마다 응답을 **10초** 기다리며, 실패해도 재시도하지 않습니다.
- 10초를 넘기거나, 2xx 가 아닌 응답(3xx 포함)을 돌려주거나, 응답 본문이 1MB 를 넘거나, 연결되지 않으면 실패입니다.
- 수신 통화의 첫 요청은 통화를 받기 전에 보냅니다. 실패하면 통화를 받아 오류 안내를 들려주고 끊습니다.
- 발신 통화의 첫 요청은 상대가 받은 뒤에 보냅니다. 실패하면 안내 없이 끊습니다.
- 요청이 실패한 통화는 `hangupCause=app_error` 로 기록됩니다. 받기 전에 실패한 통화는 `status=failed`, 받은 뒤에 실패한 통화는 `status=completed` 입니다. 상태 콜백의 `CallStatus` 도 같은 값입니다.
- `action`·`<Redirect>` 같은 후속 요청이 실패하면 안내 없이 끊습니다.
- 응답 본문이 비어 있으면 `failureCode=VOICEML_PARSE_ERROR` 로 기록됩니다.

### 요청 파라미터

통화가 시작될 때 보내는 **첫 요청**의 파라미터입니다. 수신 통화는 번호에 설정한 URL로,
발신 통화는 발신 API의 `Url`로 전송됩니다.

- `CallId` (string, 필수): 통화 고유 ID (예: CA1a2b3c...)
- `AccountId` (string, 필수): 계정 ID (예: AC...)
- `From` (string, 필수): 발신 번호 (예: 01012345678)
- `To` (string, 필수): 수신 번호 — 한국AI통신 번호 (예: 07012340001)
- `ForwardedFrom` (string, 선택): 착신전환으로 들어온 수신 통화에서, 발신자가 **원래 건 번호**. 여러 번호를 한국AI통신 번호 하나로 착신전환했을 때 어느 번호로 걸려온 전화인지 구분하는 값입니다. 착신전환을 실행한 통신사가 전환 정보를 함께 보내온 경우에만 실리며, 직접 걸려온 통화에는 파라미터 자체가 오지 않습니다
- `CallerIdRestricted` (string, 선택): 발신자가 번호 표시제한(`*23#` 등)을 걸고 건 통화면 `true`, 아니면 `false`. 번호는 `From` 에 그대로 전달되고, 표시제한 여부만 이 값으로 알려 드립니다. 통신사가 표시제한 여부를 보내지 않은 통화는 `false` 로 올 수 있습니다. 수신 통화에만 실리며 발신 통화에는 파라미터 자체가 오지 않습니다
- `CallStatus` (string, 필수): 통화 상태 (예: in-progress)
- `Direction` (string, 필수): 통화 방향 (inbound, outbound)
- `CallToken` (string, 선택): 걸려온 통화에만 실립니다. 이 통화의 발신자 번호가 상대에게 표시되는 통화를 이어서 걸 때 사용합니다

`CallToken` 사용법은 [걸려온 통화의 발신자 번호로 이어 걸기](/docs/call-token)를 참고하세요.

### action 콜백 파라미터

`<Gather>` · `<Record>` · `<Dial>` · `<Connect>` · `<Message>` 의 `action` 으로 되돌아오는
요청은 **파라미터 구성이 다릅니다.** 공통으로 실리는 값은 아래와 같고, 여기에 동사별
결과 파라미터가 붙습니다(각 동사 레퍼런스 참고). `<Redirect>` · `referUrl` 요청도 같은 공통
파라미터를 받습니다.

- `CallId` (string, 필수): 통화 고유 ID — 첫 요청과 같은 값입니다
- `From` (string, 필수): 발신 번호
- `To` (string, 필수): 수신 번호
- `Direction` (string, 필수): 통화 방향 (inbound, outbound) — 첫 요청과 같은 값입니다
- `CallerIdRestricted` (string, 선택): 발신자 번호 표시제한 여부(`true` / `false`) — 첫 요청과 같은 값입니다. 수신 통화에만 실립니다
- `CallToken` (string, 선택): 첫 요청에 `CallToken` 이 실린 통화에만, 같은 값으로 실립니다

`AccountId` · `CallStatus` · `ForwardedFrom` 은 **첫 요청에만** 실립니다. action 콜백에는 오지 않으므로,
이 값들이 필요하면 첫 요청에서 받아 `CallId` 를 키로 보관해 두거나 action URL의 쿼리 문자열에
직접 넣어 두세요.

요청 파라미터는 추가될 수 있습니다. 받는 쪽에서 모르는 파라미터는 무시하세요.

### POST vs GET

```http
POST https://your-server.com/voice HTTP/1.1
Content-Type: application/x-www-form-urlencoded
X-Signature: abc123...

CallId=CA...&AccountId=AC...&From=010...&To=070...&CallStatus=in-progress&Direction=inbound
```

```http
GET https://your-server.com/voice?CallId=CA...&AccountId=AC...&From=010...&To=070...&CallStatus=in-progress&Direction=inbound HTTP/1.1
X-Signature: abc123...
```

## 응답 형식

웹훅 서버는 `Content-Type: application/xml` 헤더와 함께 VoiceML XML을 반환해야 합니다. 모든 응답은 `<Response>` 루트 엘리먼트로 시작합니다.

```xml
<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요.</Say>
  <Gather timeout="5" action="https://your-server.com/gather">
    <Say language="ko">서비스를 선택하세요. 1번 상담, 2번 안내</Say>
  </Gather>
  <Say language="ko">응답이 없습니다.</Say>
  <Hangup/>
</Response>
```

### 문서가 끝나면 통화가 종료됩니다

마지막 동사까지 실행하고 더 실행할 동사가 없으면 한국AI통신이 통화를 종료합니다. 마지막에 `<Hangup/>` 을 두지 않아도 됩니다.

- `action`·`<Redirect>`·`referUrl` 로 다음 문서를 받아왔다면, **그 문서**가 끝날 때 종료합니다.
- `action` 없는 `<Gather>`·`<Dial>`·`<Record>` 는 끝나면 다음 동사로 넘어가고, 다음 동사가 없으면 통화가 종료됩니다. 예: `action` 없는 `<Dial>` 은 연결된 상대가 끊으면 발신자와의 통화도 종료됩니다.
- `action` 없는 `<Connect><Stream>` 은 예외입니다. WebSocket 이 닫힐 때 통화가 종료됩니다.

통화를 더 이어가려면 다음 문서를 돌려주는 `action` 을 지정하거나, `<Pause>` 로 필요한 만큼 기다리세요.

## 동사 레퍼런스

### `<Say>`

텍스트를 음성으로 읽어줍니다 (TTS).

- `language` (string, 선택): 음성 언어 (ko, en, ja). 기본값: ko
- `voice` (string, 선택): 음성 선택. 생략하거나 "clawops" 를 지정하면 무료 기본 음성으로 읽습니다(요금 없음). 고품질 음성을 쓰려면 "cartesia", "cartesia:<음성 ID>" 또는 "google:<음성명>" 을 지정합니다 — 이때만 글자수 요금이 발생합니다. Google 음성명은 Chirp 3 HD 음성(예: ko-KR-Chirp3-HD-Aoede)만 받습니다.

`language` 속성은 ko(한국어), en(영어), ja(일본어)를 지원합니다. 생략 시 한국어가 기본 적용됩니다.

`voice` 를 생략하거나 `clawops` 를 지정하면 **무료 기본 음성**으로 읽습니다 — 요금이 발생하지 않습니다. `cartesia`·`google` 을 지정한 `<Say>` 만 **읽은 글자 수만큼 과금**되며, 실제로 합성한 글자만 청구됩니다.

요금은 **1,000자당 약 ₩96**(`cartesia`)·**약 ₩58**(`google`) 이며, 대시보드 설정 → 결제 화면과 청구서에는 **「AI 음성·에이전트 사용료」** 항목으로 합산되어 표시됩니다(AI 에이전트를 쓰지 않고 `<Say>` 만 사용하셔도 같은 항목입니다). 환율에 연동되어 변동될 수 있으므로, 이번 주기의 실제 사용량과 금액은 결제 화면에서 확인하세요.

같은 문장은 저장되어 다음 통화부터는 다시 합성하지 않습니다. 다만 아래 경우는 **다른 문장으로 보아 새로 합성·청구**됩니다.

- 멘트에 통화마다 달라지는 값(발신번호·고객명 등)이 들어가 문장이 매번 달라지는 경우
- 같은 문장이라도 **음성을 바꾼** 경우 — `voice` 값이 다르면 음성별로 따로 저장됩니다
- 저장된 음성이 **만료된** 경우 — 오래 쓰이지 않은 음성은 정리되며, 자주 쓰는 문장도 약 90일에 한 번은 다시 합성됩니다


```xml
<!-- 무료 기본 음성 (voice 생략) -->
<Say language="ko">안녕하세요. 고객센터입니다.</Say>

<!-- 무료 기본 음성 (명시) -->
<Say voice="clawops" language="ko">안녕하세요. 고객센터입니다.</Say>

<!-- 고품질 음성 (글자수 과금) -->
<Say voice="cartesia">안녕하세요. 고객센터입니다.</Say>

<!-- 고품질 음성 + 음성 지정 -->
<Say voice="cartesia:4dd4630e-19e0-4243-bca0-676ff85119b7">안녕하세요. 고객센터입니다.</Say>

<!-- Google 음성 (글자수 과금) -->
<Say voice="google:ko-KR-Chirp3-HD-Aoede">안녕하세요. 고객센터입니다.</Say>
```

### `<Play>`

오디오 파일을 재생하거나 DTMF 톤을 송출합니다. **URL 은 본문 텍스트로 지정** (속성이 아님).

- `loop` (integer, 선택): 반복 재생 횟수. 0 이면 무한 반복. 기본값: 1
- `digits` (string, 선택): DTMF 톤 송출 모드. 본문 URL 대신 이 문자열의 각 키를 차례로 송출. 'w' = 500ms 대기, 'W' = 1000ms 대기


```xml
<Play>https://example.com/welcome.wav</Play>
<Play loop="2">https://example.com/beep.wav</Play>
<Play digits="1w2w3#"/>
```

### `<Gather>`

DTMF 입력(키패드)을 수집합니다. `<Say>`, `<Play>`를 중첩할 수 있습니다.

**중첩 여부가 barge-in 스위치입니다.**

- `<Gather>` **안에** 중첩한 `<Say>` / `<Play>` — 재생 도중 키를 누르면 **재생이 즉시 중단**되고 그 키가 입력으로 수집됩니다. 안내를 다 듣지 않고 미리 누르는 사용자를 위한 기본 동작입니다.
- `<Gather>` **바깥에** 둔 `<Say>` / `<Play>` — 끝까지 재생된 뒤에야 입력 대기가 시작됩니다. **끊기면 안 되는 안내**(약관 고지 등)는 이렇게 바깥에 두세요.

`timeout` 은 프롬프트 재생이 끝난 시점 또는 첫 키를 누른 시점부터 시작됩니다.

- `timeout` (integer, 선택): inter-digit 대기 시간 (초). 매 입력마다 리셋. 기본값: 5
- `numDigits` (integer, 선택): 수집할 자릿수. 도달 시 즉시 종료. 미설정 시 finishOnKey 또는 timeout 까지 무제한 수집
- `finishOnKey` (string, 선택): 입력 완료 키. 누르면 즉시 종료되며 Digits 에 포함되지 않음. 기본값: '#'. 빈 문자열이면 비활성화
- `action` (string, 선택): 입력 완료 후 요청할 콜백 URL (POST). Digits 가 비어있거나 timeout 시 호출되지 않음


```xml
<Gather timeout="5" numDigits="1" action="/handle-input">
  <Say language="ko">1번 상담, 2번 안내, 3번 기타</Say>
</Gather>
```

**action 요청 파라미터** ([공통 파라미터](#action-콜백-파라미터)에 추가)

- `Digits` (string, 필수): 사용자가 누른 값. finishOnKey 로 종료한 경우 그 키는 포함되지 않습니다

입력이 하나도 없거나 `timeout` 으로 끝나면 action 은 호출되지 않고 다음 동사로 진행합니다.

### `<Record>`

발신자 발화를 별도 파일로 녹음합니다. 통화 전체 녹음(MixMonitor)과 독립된 경로.

- `maxLength` (integer, 선택): 최대 녹음 시간 (초). 기본값: 60
- `finishOnKey` (string, 선택): 녹음을 종료하는 키의 집합. 0-9 / * / # 을 이어 붙여 지정하며, 그중 아무 키나 누르면 종료됩니다. 기본값: '1234567890*#' (= 아무 키). '#' 처럼 한 글자만 주면 그 키만 종료시킵니다. 빈 문자열이면 키로 종료되지 않습니다 (maxLength 또는 발신자 끊음까지 녹음)
- `playBeep` (boolean, 선택): 녹음 시작 전 비프음 재생. 기본값: false
- `action` (string, 선택): 녹음 완료 후 요청할 URL

**action 요청 파라미터** ([공통 파라미터](#action-콜백-파라미터)에 추가)

- `RecordingUrl` (string, 필수): 녹음 파일의 24시간 유효 서명 URL (GCS). 그대로 GET 으로 다운로드 가능
- `RecordingDuration` (string, 필수): 녹음 길이 (초). 0 이면 미녹음
- `Digits` (string, 선택): 녹음을 종료시킨 키 한 글자 (finishOnKey 집합 중 실제로 누른 키). maxLength 도달이나 발신자 끊음으로 종료되면 빈 문자열

`RecordingUrl` 은 **24시간 뒤 만료**됩니다. `action` 콜백을 받은 그 자리에서 내려받아 보관하세요.
[콜 플로우](/docs/webhooks/callflow) 의 녹음은 **90일간 보관**됩니다.


```xml
<!-- 기본값 그대로: 숫자·별표·샵 어느 키를 눌러도 녹음이 끝납니다 -->
<Record maxLength="60" playBeep="true" action="https://your-server.com/voicemail-done"/>

<!-- 샵(#) 키만 녹음을 끝내게 하려면 -->
<Record maxLength="60" finishOnKey="#" playBeep="true" action="https://your-server.com/voicemail-done"/>
```

안내 멘트와 `finishOnKey` 를 반드시 맞추세요. "아무 버튼이나 눌러주세요" 라고 안내하면서 `finishOnKey="#"` 로 두면 발신자가 다른 키를 눌러도 녹음이 끝나지 않아 `maxLength` 까지 기다리게 됩니다.

### `<Dial>`

외부 목적지로 전화를 연결합니다. `<Number>` noun(한국 국내 전화번호, 자동 정규화)과 `<Sip>` noun(SIP URI 직접 연결)을 지원합니다. 한 `<Dial>` 안에 여러 noun 을 넣으면 먼저 응답하는 목적지로 연결됩니다. `callerId` 는 계정 소유 번호로만 설정 가능 (spoofing 방지). `callerId` 를 생략하면 `<Number>` 대상에는 걸려 온 통화의 발신자 번호가 표시되며, 통신사망으로 직접 걸려 온 통화이고 그 발신자가 국내 번호인 경우에만 그렇습니다. 그 외에는 통화가 걸려 온 계정 번호가 표시됩니다. `<Number>` 로 연결할 때는 걸려 온 전화를 그대로 넘기는 **착신전환**으로 나갑니다. 발신번호를 그 사람 번호로 바꿔 새로 거는 것이 아니라서, 받는 장비에 따라 한국AI통신 번호가 표시될 수도 있습니다.

- `callerId` (string, 선택): `<Number>` 대상에 표시할 번호 — 계정 소유 번호여야 함. 미소유 번호 지정시 dial 실패 (DialCallStatus=failed)
- `timeout` (integer, 선택): 연결 대기 시간 (초). 기본값: 30
- `timeLimit` (integer, 선택): 연결된 뒤 최대 통화 시간 (초). 도달하면 연결된 상대와의 통화만 끊고 action 으로 진행합니다. 생략하면 제한 없음. 최대 86400(24시간)
- `action` (string, 선택): Dial 종료 후 요청할 URL (POST)
- `referUrl` (string, 선택): 연결된 SIP 단말이 통화 중 SIP REFER(호 전환)를 보냈을 때 요청할 URL (POST). 지정하지 않으면 REFER 는 거절됩니다

`timeout` 과 `timeLimit` 은 재는 구간이 다릅니다. `timeout` 은 **상대가 받을 때까지** 기다리는 시간이고, `timeLimit` 은 **받은 뒤부터** 허용하는 통화 시간입니다.

월 발신 통화 포함량을 다 쓴 계정(초과 사용이 없는 플랜)은 `<Dial>` 이 상대에게 걸지 않고 `DialCallStatus=failed` 로 `action` 을 호출합니다 — [요금](/docs/api-reference/pricing) 참고.

`timeLimit` 에 도달하면 연결된 상대와의 통화만 끊기고 **발신자의 통화는 유지**된 채 `action` 이 호출됩니다(`DialCallStatus=completed`). 종료 안내는 `action` 이 반환하는 XML 에 넣으세요.

**통화 중간에 안내를 넣으려면** — `<Dial>` 로 연결된 상태에서는 흐름 문서가 멈춰 있으므로 `<Say>` 로 안내를 끼워 넣을 수 없습니다. 대신 [통화 중 안내 재생 API](/docs/api/calls/playIntoCall) 를 호출하세요. 통화를 끊지 않고 오디오만 얹으며, 양쪽 모두 / 발신자만 / 상대만 중에서 고를 수 있습니다.

"잔액이 5분 남았습니다" 처럼 `timeLimit` 도달 전에 미리 알리는 용도로 쓰면 됩니다.


```xml
<!-- 연결 후 5분이 지나면 상대와의 통화를 끊고 /after-dial 로 진행 -->
<Dial timeLimit="300" action="https://your-server.com/after-dial">
  <Number>01012345678</Number>
</Dial>
```

**action 요청 파라미터** ([공통 파라미터](#action-콜백-파라미터)에 추가)

- `DialCallStatus` (string, 필수): Dial 결과 — completed / busy / no-answer / failed. 아래 값 표 참고

| `DialCallStatus` | 뜻 |
| --- | --- |
| `completed` | 상대가 받아 연결됐다가 끝났습니다 |
| `busy` | 걸린 대상이 모두 통화 중(SIP 486)으로 응답했습니다 |
| `no-answer` | `timeout` 안에 아무도 받지 않았습니다. 대상이 여럿이면 하나라도 울리다 끝난 경우 이 값입니다 |
| `failed` | 없는 번호·거절·망 장애 등으로 연결하지 못했습니다 |

발신자가 끊는 등 통화가 `<Dial>` 도중 끝나면 `action` 은 요청되지 않습니다.

##### SIP REFER 로 걸려온 호 전환 받기 — `referUrl`

`<Sip>` 으로 연결된 단말이 통화 중 **SIP REFER** 로 호 전환을 요청할 수 있습니다. 이때
한국AI통신은 전환을 대신 수행하지 않고, **`referUrl` 로 물어본 뒤 돌려주신 XML 대로** 진행합니다.
전환 대상을 그쪽 정책으로 고르거나, 안내를 먼저 들려주거나, 아예 무시하실 수 있습니다.

```xml
<!-- 처음 응답 -->
<Dial referUrl="https://your-server.com/refer">
  <Sip>sip:agent-a@example.com</Sip>
</Dial>
```

```xml
<!-- referUrl 이 돌려주는 XML — 여기서부터 흐름을 이어받습니다 -->
<Response>
  <Dial><Number>01012345678</Number></Dial>
</Response>
```

동작 순서입니다.

1. 단말이 REFER 를 보냅니다
2. 한국AI통신이 전환을 **수락**했다고 단말에 알리고, 그 단말과의 연결을 끊습니다
3. `referUrl` 을 호출합니다
4. 돌려주신 XML 을 **원래 발신자와의 통화 위에서** 실행합니다 — 발신자는 계속 연결돼 있습니다

**referUrl 요청 파라미터** ([공통 파라미터](#action-콜백-파라미터)에 추가)

- `ReferTransferTarget` (string, 필수): 단말이 REFER 의 Refer-To 헤더로 지정한 전환 대상

**`referUrl` 을 지정하지 않으면 REFER 는 거절됩니다**(SIP `603`). 통화는 그대로 유지됩니다.

또한 **attended transfer(REFER with `Replaces`)는 지원하지 않습니다** — 거절됩니다. 전환 대상과
먼저 통화해 본 뒤 넘기려면 [CallToken](/docs/call-token)으로 별도 통화를 거세요.

거절된 REFER 는 통화 이벤트에 `refer_transfer.rejected`(사유 포함)로 남습니다.

`<Dial>` 의 결과는 통화 조회 응답의 `transferTo` · `transferStatus` · `transferDuration` 에
**실리지 않습니다.** 그 필드들은 에이전트 전환과 SIP REFER 전환에만 기록됩니다 —
[통화 전환 결과](/docs/call-transfers) 를 참고하세요.

#### `<Number>` noun

한국 국내 전화번호로 연결합니다. 본문 번호는 국내 형식으로 자동 정규화되며 통신사(PSTN)를 통해 발신됩니다.

```xml
<Dial callerId="07012340001" timeout="30" action="https://your-server.com/after-dial">
  <Number>01012345678</Number>
</Dial>
```


- `statusCallback` (string, 선택): 연결된 상대(수신 leg)의 상태를 통지받을 URL (POST). `<Dial>` 전체가 아니라 연결된 목적지의 상태입니다
- `statusCallbackEvent` (string, 선택): 수신할 이벤트를 공백으로 구분. answered(상대가 받음) / completed(그 연결이 끝남). 기본값: answered completed

`statusCallback` 은 **연결된 상대가 실제로 받은 순간**을 알려줍니다. `<Dial>` 의 `action` 은 연결이 *끝난 뒤*에 호출되므로, "상담원이 언제 받았는지"를 그때그때 알아야 하는 경우에 씁니다.

통지에는 그 연결만의 통화 ID(`CallId`), **원래 통화 ID(`ParentCallId`)**, 그리고 연결된 번호(`To`)와 발신번호(`From`)가 실립니다. 나머지 파라미터는 [Status Callback](/docs/webhooks/status) 과 같습니다.

목적지를 여러 개 넣으면(`<Number>` 를 둘 이상) **실제로 연결된 한 곳에 대해서만** 통지가 발송됩니다. 아무도 받지 않으면 `statusCallback` 이 설정된 첫 목적지 기준으로 종료 통지가 나갑니다 — 목적지마다 각각의 결과를 따로 받는 것은 지원하지 않습니다.


```xml
<Dial callerId="07012340001">
  <Number statusCallback="https://your-server.com/leg"
          statusCallbackEvent="answered completed">01012345678</Number>
</Dial>
```

`<Sip>` noun 에도 같은 두 속성을 쓸 수 있습니다.

#### `<Sip>` noun

발신자를 **SIP 엔드포인트(SIP URI)로 직접 연결**합니다. 사내 PBX·FreeSWITCH·Asterisk 나 외부 SIP UA 로 통화를 브릿지할 때 사용합니다. 한국AI통신 통화 엔진이 미디어 경로에 남아 있으므로 녹음·과금·관측이 그대로 유지됩니다.

**SIP 트렁크 연결 부가서비스 필요.** `<Sip>` 연결은 [SIP 트렁크](/docs/sip-trunk) 와 동일한 **SIP 트렁크 연결 부가서비스** 입니다. [대시보드 → 부가서비스](https://platform.claw-ops.com/addons) 에서 **SIP 트렁크 연결** 을 활성화한 계정만 사용할 수 있습니다. 미활성 계정이 `<Sip>` 를 반환하면 해당 목적지는 건너뜁니다(다른 noun 이 없으면 `DialCallStatus=failed`).

**SIP URI 형식**: `sip:user@host` 또는 `sip:user@host:port` (예: `sip:1001@pbx.example.com`, `sip:agent@203.0.113.10:5060`).

**전송 방식**: 기본은 UDP 입니다. TLS 로 연결하려면 `sips:` 를 쓰거나 `;transport=tls` 를 붙이세요(예: `sip:agent@sip.example.com:5061;transport=tls`). 미디어는 SRTP 가 아닌 RTP 로 보냅니다.

**제약 (보안)** — 아래 목적지는 거절됩니다.
- **사설·loopback·내부 IP** (`10.x`, `192.168.x`, `127.x`, CGNAT 등) — 공인 호스트만 허용 (SSRF 방지).
- IP 인코딩 트릭·헤더 인젝션·다중 `@` 등 비정상 URI.

발신 시 걸려 온 통화의 발신자 번호가 상대 SIP 엔드포인트에 표시되며(`callerId` 미지정 시), 연결된 SIP 통화 시간은 **발신 통화(outbound) 분으로 과금**됩니다.

```xml
<Dial timeout="30" action="https://your-server.com/after-dial">
  <Sip>sip:1001@pbx.example.com</Sip>
</Dial>
```

`<Number>` 와 함께 넣어 먼저 응답하는 쪽으로 연결할 수도 있습니다.

```xml
<Dial>
  <Sip>sip:1001@pbx.example.com</Sip>
  <Number>01012345678</Number>
</Dial>
```

### `<Connect>`

WebSocket Stream을 연결하여 실시간 양방향 오디오를 처리합니다. AI Agent 연동에 주로 사용됩니다.

하위에 `<Stream>` 엘리먼트를 포함하며, `<Stream>`은 `<Parameter>` 자식을 가질 수 있습니다.

**Connect 속성**

- `action` (string, 선택): 스트림이 닫힌 뒤 다음 VoiceML을 받아올 URL. 지정하면 WebSocket이 닫혀도 통화가 유지됩니다.

`action` 을 지정하지 않으면 WebSocket이 닫히는 순간 통화가 종료됩니다. 지정하면 스트림 종료 후
해당 URL로 POST하고, 응답한 VoiceML이 통화를 이어받습니다 — AI 스트림으로 응대하다가
`<Dial>` 로 사람에게 넘기는 전환에 사용합니다. close code는 무엇이든 상관없습니다.

**action 요청 파라미터** ([공통 파라미터](#action-콜백-파라미터)에 추가)

- `StreamEvent` (string, 필수): stopped = 스트림이 정상 종료됨 / failed = 연결에 실패했거나 소켓 오류로 끊김
- `StreamCloseCode` (string, 필수): WebSocket close code (예: 1000, 1005). failed 면 빈 문자열
- `StreamCloseReason` (string, 필수): WebSocket close reason 원문. 없으면 빈 문자열


```xml
<!-- 1) 최초 응답: 스트림 연결 + 종료 후 돌아올 곳 지정 -->
<Connect action="https://your-server.com/voice/after-stream">
  <Stream url="wss://your-server.com/stream"/>
</Connect>

<!-- 2) 서버가 WebSocket을 닫으면 action URL이 호출되고, 그 응답으로 전환 -->
<Dial><Number>01012345678</Number></Dial>
```

**Stream 속성**

- `url` (string, 필수): WebSocket 서버 URL (wss://)
- `track` (string, 선택): 스트리밍할 오디오 트랙. 현재 inbound 만 지원 (기본값 inbound, 다른 값을 넣어도 inbound 로 동작)

**Parameter 속성**

- `name` (string, 필수): 파라미터 이름
- `value` (string, 필수): 파라미터 값


```xml
<Connect>
  <Stream url="wss://your-server.com/stream" track="inbound">
    <Parameter name="userId" value="123"/>
    <Parameter name="language" value="ko"/>
  </Stream>
</Connect>
```


Stream 프로토콜에 대한 자세한 내용은 [Stream WebSocket 문서](/docs/build/stream)를 참고하세요.

### `<Hangup>`

통화를 종료합니다. 속성이 없습니다.

```xml
<Hangup/>
```

### `<Reject>`

수신 통화를 거절합니다.

- **응답의 첫(유일) verb 가 `<Reject>` 일 때**: 통화를 **받지 않고(answer 전)** 거절합니다. 발신자에게 실제 SIP 응답이 나가고, **통화료·AI 비용이 발생하지 않습니다.** 통화 발신번호 기반 스팸/무효 통화 필터링에 사용합니다.
- **앞에 다른 verb(`<Say>` 등)가 있으면**: 이미 통화를 받은 뒤라 `<Hangup>` 과 동일하게 단순 종료됩니다(과금됨).

`reason` 속성으로 발신자 단말에 보일 응답을 지정합니다:

| `reason` | 발신자 SIP 응답 | 의미 |
|----------|----------------|------|
| `rejected` (기본) | 403 Forbidden | 거절 |
| `busy` | 486 Busy Here | 통화중 |

```xml
<Response>
  <Reject reason="busy"/>
</Response>
```


특정 번호를 늘 막거나 번호 표시제한 통화를 일괄 거절하려면 웹훅에서 판단하지 않아도 됩니다 —
[수신 통화 차단](/docs/call-blocking) 의 차단 목록과 번호별 설정은 웹훅을 부르기 전에 거절하고,
AI 에이전트·SDK 에이전트로 받는 번호에도 적용됩니다.

### `<Pause>`

지정된 시간만큼 대기합니다.

- `length` (integer, 선택): 대기 시간 (초). 양의 정수만 유효하며 상한은 3600. 기본값: 1


```xml
<Pause length="2"/>
```

`length` 가 양의 정수가 아니면(`abc`, `-5`, `0`, `1.5` 등) **통화를 끊지 않고 기본값 1초로 대기**합니다. 3600 을 넘기면 3600 으로 맞춥니다. 두 경우 모두 통화 이벤트에 기록됩니다.

`<Gather>` 안에 중첩하면 **대기 도중 키를 눌러 끊을 수 있습니다** — `<Say>` / `<Play>` 와 동일하게 동작합니다.

**첫 verb 로 두면 전화를 받기 전에 기다립니다** (수신 통화 한정). 그동안 발신자는 계속 링백을 듣고, **응답 전이므로 그 시간은 과금되지 않습니다.**

```xml
<Response>
  <Pause length="6"/>   <!-- 6초 더 울린 뒤 받는다. 과금 안 됨 -->
  <Say language="ko">연결되었습니다.</Say>
</Response>
```

### `<Redirect>`

다른 URL로 요청을 리다이렉트하여 새로운 VoiceML을 가져옵니다. 엘리먼트 텍스트에 URL을 포함합니다. 호출은 항상 POST 입니다.

```xml
<Redirect>https://your-server.com/next-step</Redirect>
```

### `<Message>`

통화 중에 문자를 보냅니다. 음성 동사가 아니라서 흐름을 멈추지 않습니다 — 다음 동사가 이어서 실행됩니다.

- `to` (string, 선택): 수신번호. 생략하면 통화 상대방(수신 통화는 발신자, 발신 통화는 착신자)
- `from` (string, 선택): 문자 발신번호. 생략하면 이 통화의 보유 번호. 계정에 문자 발신번호로 등록된 번호만 사용할 수 있습니다
- `subject` (string, 선택): LMS 제목. 지정하면 본문 길이와 무관하게 LMS로 발송됩니다
- `action` (string, 선택): 발송 접수 결과를 POST 받을 URL. 지정하면 이후 동사는 실행되지 않고 응답 VoiceML로 넘어갑니다

본문은 `<Body>` 자식 엘리먼트에 담습니다.

```xml
<Response>
  <Say language="ko">안내 문자를 보내드렸습니다.</Say>
  <Message>
    <Body>상담 예약은 여기서: https://example.com/r/abc</Body>
  </Message>
  <Hangup/>
</Response>
```

**SMS / LMS는 자동으로 결정됩니다.** 본문이 EUC-KR 90byte를 넘거나 `subject`가 있으면 LMS, 그 외에는 SMS입니다. 한글만 사용하면 최대 45자까지 SMS이며, 한글·영문 혼합 본문은 글자 수가 아니라 EUC-KR 바이트를 기준으로 합니다. 직접 고를 필요가 없습니다. LMS 본문 상한은 2,000자입니다.

`action`을 지정하면 [공통 파라미터](#action-콜백-파라미터)에 다음 파라미터가 추가로 전달됩니다.

| 파라미터 | 값 |
| --- | --- |
| `MessageStatus` | `queued` (접수 성공) / `failed` |
| `MessageSid` | 메시지 ID (성공 시) |
| `MessageType` | `sms` / `lms` |
| `ErrorCode` | 실패 사유 (`from_not_registered`, `quota_exceeded`, `override_quota_exceeded`, `messaging_blocked`, `body_too_long`, `timeout` 등) |

**`ErrorCode`가 `timeout`이면 발송 여부는 알 수 없습니다.** 통화가 무음이 되지 않도록 접수 응답을 기다리는 시간에 상한이 있어서, 접수는 끝났는데 응답만 늦은 경우에도 `timeout`이 됩니다. 플랫폼이 내부적으로 한 번 더 확인하므로 대부분은 정확한 결과가 나오지만, 그마저 늦으면 `timeout`으로 보고됩니다. 이때 다시 보내면 문자가 두 번 나갈 수 있습니다 — 재발송 대신 메시지 조회 API로 실제 발송 여부를 확인하세요.

**발신자가 먼저 끊으면 발송되지 않습니다.** 통화가 종료된 뒤에는 어떤 동사도 실행되지 않기 때문입니다. 고객이 안내를 끝까지 듣지 않는 경우가 흔하므로, `<Message>`는 흐름 **앞쪽**(첫 안내 직후)에 두세요. 마지막에 두면 놓치는 통화가 생깁니다.

발송에 실패해도 통화는 끊기지 않고 다음 동사로 진행합니다. 한 통화에서 보낼 수 있는 문자는 **최대 5건**입니다.

## 중첩 규칙

| 부모 엘리먼트 | 허용되는 자식 엘리먼트 |
|---|---|
| `<Gather>` | `<Say>`, `<Play>` |
| `<Dial>` | `<Number>` |
| `<Connect>` | `<Stream>` |
| `<Stream>` | `<Parameter>` |
| `<Message>` | `<Body>` |
| `<Say>`, `<Play>`, `<Record>`, `<Hangup>`, `<Reject>`, `<Pause>`, `<Redirect>` | 중첩 불가 |

중첩 규칙을 따르지 않는 XML은 파싱 오류가 발생합니다. 각 동사에 허용된 자식 엘리먼트만 사용하세요.

## 서버 구현 예제

Webhook 엔드포인트를 구현하여 전화를 수신하고 VoiceML로 응답하는 예제입니다.

**Python (Flask)**

```python
from flask import Flask, request

app = Flask(__name__)

@app.route("/voice", methods=["POST"])
def handle_call():
    call_id = request.form.get("CallId")
    from_number = request.form.get("From")
    return """<?xml version="1.0" encoding="UTF-8"?>
<Response>
  <Say language="ko">안녕하세요. 한국AI통신에 연결되었습니다.</Say>
  <Hangup/>
</Response>""", 200, {"Content-Type": "application/xml"}
```


로컬 개발 시 [ngrok](https://ngrok.com) 등의 터널링 도구를 사용하면 외부에서 로컬 서버로 Webhook을 전달받을 수 있습니다.
Webhook 없이 AI 에이전트로 전화를 처리하려면 [Voice Agent](/docs/voice-agent)를 참고하세요.

## 예제: 통화 시간 제한 + 잔여시간 안내

유료 상담처럼 **통화 시간이 정해진** 서비스에서 자주 쓰는 조합입니다. 세 가지를 함께 씁니다.

| 필요한 것 | 쓰는 것 |
|---|---|
| 상대가 **언제 받았는지** 알기 | `<Number statusCallback>` 의 `answered` |
| 통화 중간에 남은 시간 알리기 | [통화 중 안내 재생 API](/docs/api/calls/playIntoCall) |
| 시간이 되면 자동 종료 | `<Dial timeLimit>` |

```xml
<Response>
  <Say language="ko">상담원을 연결합니다.</Say>
  <Dial timeLimit="1800" action="https://your-server.com/after-dial">
    <Number statusCallback="https://your-server.com/leg"
            statusCallbackEvent="answered">01012345678</Number>
  </Dial>
</Response>
```

상대가 받으면 `statusCallback` 으로 통지가 오고, **그 시점부터** 시간을 재면 됩니다. `<Dial>` 을 반환한 시각부터 재면 상대가 받기까지의 벨 시간이 함께 계산되어 어긋납니다.

```python
@app.route("/leg", methods=["POST"])
def leg_status():
    if request.form["CallStatus"] == "in-progress":
        parent = request.form["ParentCallId"]   # 원래 통화
        # 25분 뒤 양쪽에 안내 — 남은 5분을 알린다
        schedule_in(25 * 60, lambda: play_into_call(parent, "잔액이 5분 남았습니다."))
    return "", 204


def play_into_call(call_id, text):
    requests.post(
        f"https://api.claw-ops.com/v1/accounts/{ACCOUNT_ID}/calls/{call_id}/actions/play",
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={"text": text, "target": "both"},
    )
```

30분(`timeLimit="1800"`)이 되면 **연결된 상대와의 통화만 끊기고 발신자의 통화는 유지**된 채 `action` 이 호출됩니다. 종료 안내는 거기서 넣습니다.

```python
@app.route("/after-dial", methods=["POST"])
def after_dial():
    return """<Response>
      <Say language="ko">상담 시간이 종료되었습니다. 이용해 주셔서 감사합니다.</Say>
      <Hangup/>
    </Response>""", 200, {"Content-Type": "application/xml"}
```


안내에 쓰는 `voice` 는 `<Say>` 와 같은 값을 주면 음색이 통일됩니다. 생략하면 무료 기본 음성으로 읽습니다.

## 전체 예제: 통신사 콜센터 IVR

본인확인 → 다단계 메뉴 → AI 상담사 스트림 / 상담원 Dial / 음성사서함 분기까지 포함하는 실제 동작하는 예제입니다. 운영 환경에서 E2E 검증된 흐름입니다.

### 통화 흐름

```
[발신]
  ↓ POST /ivr/enter
인사 + 녹음 안내 + 언어선택 (Gather numDigits=1)
  ↓ Digits=1
본인확인 1단계 (Gather numDigits=4)
  ↓ Digits=1234
본인확인 2단계 (Gather numDigits=6)
  ↓ Digits=900101
메인 메뉴 (Gather numDigits=1)
  ├─ 1: 요금조회       → <Connect><Stream wss://…/>
  ├─ 2: 데이터/부가서비스 → 서브메뉴
  ├─ 3: 분실/도난     → <Dial> 상담원
  ├─ 0: 상담원 연결    → <Dial> 상담원 → busy 시 음성사서함
  └─ *: 메뉴 재안내

음성사서함 → <Record> (아무 키로 종료) → action callback (RecordingUrl)
```

### 1. 진입점 + 본인확인

```js
import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: false }));

const BASE = 'https://your-server.com/ivr';
const xml = (body) => `<?xml version="1.0" encoding="UTF-8"?>\n<Response>${body}</Response>`;
const sendXml = (res, body) => res.type('xml').send(xml(body));

// 진입점
app.post('/ivr/enter', (req, res) => {
  sendXml(res, `
    <Say language="ko-KR">안녕하세요. 상담 품질 향상을 위해 통화 내용이 녹음됩니다.</Say>
    <Gather numDigits="1" timeout="5" action="${BASE}/lang">
      <Say language="ko-KR">한국어는 1번, English press 2.</Say>
    </Gather>
    <Redirect>${BASE}/enter</Redirect>
  `);
});

// 언어 선택
app.post('/ivr/lang', (req, res) => {
  if (req.body.Digits === '2') {
    return sendXml(res, `<Say language="en-US">English service unavailable.</Say><Hangup/>`);
  }
  sendXml(res, `<Redirect>${BASE}/verify</Redirect>`);
});

// 본인확인 1단계: 휴대폰 뒷 4자리 (numDigits=4 라 # 안눌러도 4자리 채우면 즉시 진행)
app.post('/ivr/verify', (req, res) => {
  sendXml(res, `
    <Gather numDigits="4" timeout="8" action="${BASE}/verify-2">
      <Say language="ko-KR">가입하신 휴대폰 뒷 네 자리를 누르세요.</Say>
    </Gather>
    <Redirect>${BASE}/enter</Redirect>
  `);
});

// 본인확인 2단계: 생년월일 6자리
app.post('/ivr/verify-2', (req, res) => {
  // req.body.Digits = 휴대폰 뒷 4자리. 세션/Redis 에 보관 후 DB 조회.
  sendXml(res, `
    <Gather numDigits="6" timeout="10" action="${BASE}/menu">
      <Say language="ko-KR">생년월일 여섯 자리를 누르세요.</Say>
    </Gather>
    <Redirect>${BASE}/enter</Redirect>
  `);
});
```

### 2. 메인 메뉴 + 라우팅

```js
app.post('/ivr/menu', (req, res) => {
  sendXml(res, `
    <Gather numDigits="1" timeout="6" action="${BASE}/route">
      <Say language="ko-KR">
        본인 확인이 완료되었습니다.
        요금 조회는 1번, 데이터 서비스는 2번, 분실 신고는 3번,
        상담원 연결은 0번, 메뉴를 다시 들으시려면 별표를 눌러주세요.
      </Say>
    </Gather>
    <Redirect>${BASE}/menu</Redirect>
  `);
});

app.post('/ivr/route', (req, res) => {
  const d = req.body.Digits;

  if (d === '1') {
    // 요금조회 AI 상담사 — WebSocket 스트림 연결
    return sendXml(res, `
      <Say language="ko-KR">AI 상담사에게 연결합니다.</Say>
      <Connect>
        <Stream url="wss://your-server.com/ai/billing">
          <Parameter name="topic" value="billing"/>
        </Stream>
      </Connect>
    `);
  }

  if (d === '3') {
    // 분실/도난 — 즉시 상담원 Dial
    return sendXml(res, `
      <Say language="ko-KR">분실 신고는 즉시 상담원에게 연결됩니다.</Say>
      <Dial timeout="30" action="${BASE}/after-dial" callerId="07012340001">
        <Number>01099991111</Number>
      </Dial>
    `);
  }

  if (d === '0') {
    return sendXml(res, `
      <Dial timeout="30" action="${BASE}/after-dial" callerId="07012340001">
        <Number>01099991111</Number>
      </Dial>
    `);
  }

  // '*' 또는 기타 → 메뉴 재안내
  return sendXml(res, `<Redirect>${BASE}/menu</Redirect>`);
});
```

### 3. Dial 결과 분기 + 음성사서함

```js
app.post('/ivr/after-dial', (req, res) => {
  // DialCallStatus: completed / busy / no-answer / failed
  if (req.body.DialCallStatus === 'completed') {
    return sendXml(res, `<Hangup/>`);
  }
  // busy / no-answer / failed → 음성사서함으로
  sendXml(res, `
    <Say language="ko-KR">현재 상담원이 모두 통화 중입니다. 신호음 후 메시지를 남겨주세요.</Say>
    <Redirect>${BASE}/voicemail</Redirect>
  `);
});

app.post('/ivr/voicemail', (req, res) => {
  sendXml(res, `
    <Say language="ko-KR">말씀을 마치신 후에는 아무 버튼이나 눌러주세요.</Say>
    <Record maxLength="60" playBeep="true" action="${BASE}/voicemail-done"/>
    <Say language="ko-KR">메시지가 저장되지 않았습니다.</Say>
    <Hangup/>
  `);
});

app.post('/ivr/voicemail-done', (req, res) => {
  // req.body.RecordingUrl     — 24시간 유효 GCS 서명 URL (그대로 GET 다운로드 가능)
  // req.body.RecordingDuration — 녹음 길이 (초)
  // req.body.Digits            — 녹음을 종료시킨 키 (#)
  console.log('voicemail saved:', {
    callId: req.body.CallId,
    url: req.body.RecordingUrl,
    duration: req.body.RecordingDuration,
  });
  // 실제 운영에서는 RecordingUrl 을 본인 스토리지로 24h 안에 복사해 영구 보관 권장
  sendXml(res, `
    <Say language="ko-KR">메시지가 접수되었습니다. 빠른 시일 내에 연락드리겠습니다.</Say>
    <Hangup/>
  `);
});
```

### 4. AI 상담사 — WebSocket Stream

`<Connect><Stream>` 으로 한국AI통신이 wss\:// 서버에 연결합니다. μ-law 8kHz base64 프레임을 양방향 주고받으며, 통화 끊기까지 유지됩니다.

```js
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 9000, path: '/ai/billing' });

wss.on('connection', (ws) => {
  let streamSid, callSid;

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());

    if (msg.event === 'start') {
      streamSid = msg.start.streamId;
      callSid = msg.start.callId;
      // STT/LLM 세션 시작
      return;
    }

    if (msg.event === 'media') {
      const inboundAudio = Buffer.from(msg.media.payload, 'base64'); // G.711 μ-law
      // STT 로 전사 → LLM 응답 생성 → TTS μ-law 로 인코딩 → 아래처럼 전송
      // ws.send(JSON.stringify({
      //   event: 'media',
      //   streamSid,
      //   media: { payload: ttsOutputBase64 }
      // }));
    }

    if (msg.event === 'stop') {
      // 세션 정리
    }
  });
});
```

자세한 메시지 포맷은 [Stream WebSocket 프로토콜](/docs/build/stream) 참고.

### 시스템 한계

- **상담사 대기열** (`<Queue>` / `<Enqueue>`) 미지원 — busy 시 음성사서함으로 우회
- **3자 통화 / Conference** 미지원
- **`Gather input="speech"`** 미지원 — DTMF only
- **`<Record>` 의 무음 종료(`timeout`) 미지원** — TwiML 은 무음 N초로도 녹음을 끝내지만 VoiceML 은 `finishOnKey` 와 `maxLength`, 발신자 끊음으로만 끝납니다. 발신자가 아무 키도 누르지 않으면 `maxLength` 까지 녹음되므로, `finishOnKey` 를 비활성화(`""`)할 때는 `maxLength` 를 짧게 잡으세요.
- **`bargeIn` 속성** 무시됨 — TwiML 의 `bargeIn` 은 음성(speech) 전용이라 DTMF-only 인 `<Gather>` 에서는 no-op 입니다. DTMF barge-in 은 스위치 없이 항상 동작합니다.
- **`<Pause>` 의 answer 전 대기는 수신 통화에서만** 동작합니다 — 발신 통화는 상대가 받은 뒤에야 VoiceML 이 실행되므로 "받기 전 대기" 가 성립하지 않습니다.
