새로운 문서를 작성 중입니다.새 문서로 이동
ClawOps Docs

VoiceML

XML 기반 통화 제어 마크업 언어. 요청/응답 형식과 Say, Play, Gather, Record, Dial, Connect, Hangup, Redirect 태그 레퍼런스.

개요

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

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

동사별 검증 상태

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

동사상태검증된 속성 / 동작
<Say>✅ 검증 완료language="ko-KR", 본문 텍스트 TTS 재생, voice(생략 시 무료 기본 음성 / cartesia:<음성 ID> 고품질 유료 음성)
<Play>✅ 검증 완료본문 URL(https) 오디오 재생, WAV (PCM 8k mono) / MP3 (44.1k stereo → 8k mono 자동 변환) 재생, <Gather> 중첩 재생. loop / digits 는 미검증
<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, 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 트렁크 연결 부가서비스 필요. sips:/TLS·사설 IP 는 거절
<Connect><Stream>✅ 검증 완료url (wss://), 기본 inbound track, WebSocket 프로토콜 (connected/start/media/stop), μ-law 8k 양방향 오디오, <Connect action> (스트림 종료 후 통화 유지 + next URL, StreamCloseCode / StreamCloseReason 전달)
<Hangup>✅ 검증 완료통화 즉시 종료, 후속 동사 실행 중단
<Reject>✅ 검증 완료reason="busy|rejected". 응답의 첫 verb 일 때 answer 전 거절 → SIP 486/603 + 통화료·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 헤더가 포함됩니다. 서명 검증을 통해 요청의 무결성을 확인할 수 있습니다.

요청 파라미터

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

파라미터타입필수설명
CallIdstring필수통화 고유 ID (예: CA1a2b3c...)
AccountIdstring필수계정 ID (예: AC...)
Fromstring필수발신 번호 (예: 01012345678)
Tostring필수수신 번호 — ClawOps 번호 (예: 07012340001)
ForwardedFromstring선택착신전환으로 들어온 수신 통화에서, 발신자가 **원래 건 번호**. 여러 번호를 ClawOps 번호 하나로 착신전환했을 때 어느 번호로 걸려온 전화인지 구분하는 값입니다. 착신전환을 실행한 통신사가 전환 정보를 함께 보내온 경우에만 실리며, 직접 걸려온 통화에는 파라미터 자체가 오지 않습니다
CallStatusstring필수통화 상태 (예: in-progress)
Directionstring필수통화 방향 (inbound, outbound)

action 콜백 파라미터

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

파라미터타입필수설명
CallIdstring필수통화 고유 ID — 첫 요청과 같은 값입니다
Fromstring필수발신 번호
Tostring필수수신 번호

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

POST vs GET

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

동사 레퍼런스

<Say>

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

파라미터타입필수설명
languagestring선택음성 언어 (ko, en, ja). 기본값: ko
voicestring선택음성 선택. 생략하면 무료 기본 음성으로 읽습니다(요금 없음). 고품질 음성을 쓰려면 "cartesia" 또는 "cartesia:<음성 ID>" 를 지정합니다 — 이때만 글자수 요금이 발생합니다.

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

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

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

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

  • 멘트에 통화마다 달라지는 값(발신번호·고객명 등)이 들어가 문장이 매번 달라지는 경우
  • 같은 문장이라도 음성을 바꾼 경우 — voice 값이 다르면 음성별로 따로 저장됩니다
  • 저장된 음성이 만료된 경우 — 오래 쓰이지 않은 음성은 정리되며, 자주 쓰는 문장도 약 90일에 한 번은 다시 합성됩니다
<!-- 무료 기본 음성 (voice 생략) -->
<Say language="ko">안녕하세요. 고객센터입니다.</Say>

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

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

<Play>

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

파라미터타입필수설명
loopinteger선택반복 재생 횟수. 0 이면 무한 반복. 기본값: 1
digitsstring선택DTMF 톤 송출 모드. 본문 URL 대신 이 문자열의 각 키를 차례로 송출. 'w' = 500ms 대기, 'W' = 1000ms 대기
<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 은 프롬프트 재생이 끝난 시점 또는 첫 키를 누른 시점부터 시작됩니다.

파라미터타입필수설명
timeoutinteger선택inter-digit 대기 시간 (초). 매 입력마다 리셋. 기본값: 5
numDigitsinteger선택수집할 자릿수. 도달 시 즉시 종료. 미설정 시 finishOnKey 또는 timeout 까지 무제한 수집
finishOnKeystring선택입력 완료 키. 누르면 즉시 종료되며 Digits 에 포함되지 않음. 기본값: '#'. 빈 문자열이면 비활성화
actionstring선택입력 완료 후 요청할 콜백 URL (POST). Digits 가 비어있거나 timeout 시 호출되지 않음
<Gather timeout="5" numDigits="1" action="/handle-input">
  <Say language="ko">1번 상담, 2번 안내, 3번 기타</Say>
</Gather>

action 요청 파라미터 (공통 CallId / From / To 에 추가)

파라미터타입필수설명
Digitsstring필수사용자가 누른 값. finishOnKey 로 종료한 경우 그 키는 포함되지 않습니다

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

<Record>

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

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

action 요청 파라미터 (공통 CallId / From / To 에 추가)

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

RecordingUrl24시간 뒤 만료됩니다. action 콜백을 받은 그 자리에서 내려받아 보관하세요. 콜 플로우 의 녹음 값은 이와 달리 만료되지 않는 API URL 입니다 — 결과를 오래 보관하는 쪽이라 다르게 다룹니다.

<!-- 기본값 그대로: 숫자·별표·샵 어느 키를 눌러도 녹음이 끝납니다 -->
<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 방지).

파라미터타입필수설명
callerIdstring선택발신자 번호 표시 — 계정 소유 번호여야 함. 미소유 번호 지정시 dial 실패 (DialCallStatus=failed)
timeoutinteger선택연결 대기 시간 (초). 기본값: 30
timeLimitinteger선택연결된 뒤 최대 통화 시간 (초). 도달하면 연결된 상대와의 통화만 끊고 action 으로 진행합니다. 생략하면 제한 없음. 최대 86400(24시간)
actionstring선택Dial 종료 후 요청할 URL (POST)

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

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

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

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

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

action 요청 파라미터 (공통 CallId / From / To 에 추가)

파라미터타입필수설명
DialCallStatusstring필수Dial 결과 — completed / busy / no-answer / failed / canceled

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

<Number> noun

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

<Dial callerId="07012340001" timeout="30" action="https://your-server.com/after-dial">
  <Number>01012345678</Number>
</Dial>
파라미터타입필수설명
statusCallbackstring선택연결된 상대(수신 leg)의 상태를 통지받을 URL (POST). <Dial> 전체가 아니라 연결된 목적지의 상태입니다
statusCallbackEventstring선택수신할 이벤트를 공백으로 구분. answered(상대가 받음) / completed(그 연결이 끝남). 기본값: answered completed

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

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

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

<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 로 통화를 브릿지할 때 사용합니다. ClawOps 통화 엔진이 미디어 경로에 남아 있으므로 녹음·과금·관측이 그대로 유지됩니다.

SIP 트렁크 연결 부가서비스 필요. <Sip> 연결은 SIP 트렁크 와 동일한 SIP 트렁크 연결 부가서비스 입니다. 대시보드 → 부가서비스 에서 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).

제약 (보안) — 아래 목적지는 거절됩니다.

  • sips: / ;transport=tls (TLS) — 현재 미지원 (UDP 전송만).
  • 사설·loopback·내부 IP (10.x, 192.168.x, 127.x, CGNAT 등) — 공인 호스트만 허용 (SSRF 방지).
  • IP 인코딩 트릭·헤더 인젝션·다중 @ 등 비정상 URI.

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

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

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

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

<Connect>

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

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

Connect 속성

파라미터타입필수설명
actionstring선택스트림이 닫힌 뒤 다음 VoiceML을 받아올 URL. 지정하면 WebSocket이 닫혀도 통화가 유지됩니다.

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

action 요청 파라미터 (공통 CallId / From / To 에 추가)

파라미터타입필수설명
StreamEventstring필수stopped = 스트림이 정상 종료됨 / failed = 연결에 실패했거나 소켓 오류로 끊김
StreamCloseCodestring필수WebSocket close code (예: 1000, 1005). failed 면 빈 문자열
StreamCloseReasonstring필수WebSocket close reason 원문. 없으면 빈 문자열
<!-- 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 속성

파라미터타입필수설명
urlstring필수WebSocket 서버 URL (wss://)
trackstring선택스트리밍할 오디오 트랙 (inbound, outbound, both)

Parameter 속성

파라미터타입필수설명
namestring필수파라미터 이름
valuestring필수파라미터 값
<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 문서를 참고하세요.

<Hangup>

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

<Hangup/>

<Reject>

수신 통화를 거절합니다.

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

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

reason발신자 SIP 응답의미
rejected (기본)603 Decline거절
busy486 Busy Here통화중
<Response>
  <Reject reason="busy"/>
</Response>

<Pause>

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

파라미터타입필수설명
lengthinteger선택대기 시간 (초). 양의 정수만 유효하며 상한은 3600. 기본값: 1
<Pause length="2"/>

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

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

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

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

<Redirect>

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

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

<Message>

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

파라미터타입필수설명
tostring선택수신번호. 생략하면 통화 상대방(수신 통화는 발신자, 발신 통화는 착신자)
fromstring선택발신번호. 생략하면 이 통화의 보유 번호. 계정에 등록된 번호만 사용할 수 있습니다
subjectstring선택LMS 제목. 지정하면 본문 길이와 무관하게 LMS로 발송됩니다
actionstring선택발송 접수 결과를 POST 받을 URL. 지정하면 이후 동사는 실행되지 않고 응답 VoiceML로 넘어갑니다

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

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

SMS / LMS는 자동으로 결정됩니다. 본문이 200byte를 넘거나 subject가 있으면 LMS, 그 외에는 SMS입니다. 직접 고를 필요가 없습니다. LMS 본문 상한은 2,000자입니다.

action을 지정하면 공통 CallId / From / To 에 다음 파라미터가 추가로 전달됩니다.

파라미터
MessageStatusqueued (접수 성공) / failed
MessageSid메시지 ID (성공 시)
MessageTypesms / lms
ErrorCode실패 사유 (from_not_registered, quota_exceeded, override_quota_exceeded, messaging_blocked, body_too_long, timeout 등)

ErrorCodetimeout이면 발송 여부는 알 수 없습니다. 통화가 무음이 되지 않도록 접수 응답을 기다리는 시간에 상한이 있어서, 접수는 끝났는데 응답만 늦은 경우에도 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)

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">안녕하세요. ClawOps에 연결되었습니다.</Say>
  <Hangup/>
</Response>""", 200, {"Content-Type": "application/xml"}

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

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

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

필요한 것쓰는 것
상대가 언제 받았는지 알기<Number statusCallback>answered
통화 중간에 남은 시간 알리기통화 중 안내 재생 API
시간이 되면 자동 종료<Dial timeLimit>
<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> 을 반환한 시각부터 재면 상대가 받기까지의 벨 시간이 함께 계산되어 어긋납니다.

@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 이 호출됩니다. 종료 안내는 거기서 넣습니다.

@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. 진입점 + 본인확인

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. 메인 메뉴 + 라우팅

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 결과 분기 + 음성사서함

app.post('/ivr/after-dial', (req, res) => {
  // DialCallStatus: completed / busy / no-answer / failed / canceled
  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> 으로 ClawOps 가 wss:// 서버에 연결합니다. μ-law 8kHz base64 프레임을 양방향 주고받으며, 통화 끊기까지 유지됩니다.

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 프로토콜 참고.

시스템 한계

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