VoiceML
XML 기반 통화 제어 마크업 언어. 요청/응답 형식과 Say, Play, Gather, Record, Dial, Connect, Hangup, Redirect 태그 레퍼런스.
개요
ClawOps 번호로 수신 전화가 들어오면, 플랫폼은 설정된 웹훅 URL로 HTTP 요청을 전송합니다. 서버는 VoiceML(XML)로 응답하여 통화 흐름을 제어합니다.
VoiceML은 TwiML 호환 형식으로, 기존 TwiML 기반 코드를 최소한의 수정으로 사용할 수 있습니다.
동사별 검증 상태
각 동사의 운영 환경 실통화 검증 상태입니다. 미검증 항목은 자체 테스트 후 사용 권장.
| 동사 | 상태 | 검증된 속성 / 동작 |
|---|---|---|
<Say> | ✅ 검증 완료 | language="ko-KR", 본문 텍스트 TTS 재생 |
<Play> | ⚠️ 미검증 | — |
<Gather> | ✅ 검증 완료 | numDigits (1/4/6), timeout (inter-digit reset, 부분 입력 전달), action (POST redirect), nested <Say>, 기본 finishOnKey="#", Digits 응답 파라미터 |
<Record> | ✅ 검증 완료 | maxLength, finishOnKey="#", playBeep="true", action, 응답 파라미터 RecordingUrl (24h GCS 서명 URL) / RecordingDuration / Digits |
<Dial> | ✅ 부분 검증 | <Number> noun, timeout, action, DialCallStatus 분기 중 no-answer / failed → action redirect 검증. completed / busy / callerId 표시는 미검증 |
<Connect><Stream> | ✅ 검증 완료 | url (wss://), 기본 inbound track, WebSocket 프로토콜 (connected/start/media/stop), μ-law 8k 양방향 오디오 |
<Hangup> | ✅ 검증 완료 | 통화 즉시 종료, 후속 동사 실행 중단 |
<Reject> | ✅ 검증 완료 | reason="busy|rejected". 응답의 첫 verb 일 때 answer 전 거절 → SIP 486/603 + 통화료·AI 비용 0 |
<Redirect> | ✅ 검증 완료 (간접) | Gather/Dial action 경로를 통한 동기적 next-URL fetch + 응답 VoiceML 실행 |
<Pause> | ⚠️ 미검증 | — |
요청 형식
POST 요청 (기본) — Content-Type: application/x-www-form-urlencoded 파라미터가 요청 본문(body)으로 전송됩니다.
GET 요청 — 파라미터가 쿼리 문자열(query string)으로 전송됩니다.
번호 설정에 서명 키가 등록되어 있으면 POST/GET 모두 X-Signature 헤더가 포함됩니다. 서명 검증을 통해 요청의 무결성을 확인할 수 있습니다.
요청 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| CallId | string | 필수 | 통화 고유 ID (예: CA1a2b3c...) |
| AccountId | string | 필수 | 계정 ID (예: AC...) |
| From | string | 필수 | 발신 번호 (예: 01012345678) |
| To | string | 필수 | 수신 번호 — ClawOps 번호 (예: 07012340001) |
| CallStatus | string | 필수 | 통화 상태 (예: in-progress) |
| Direction | string | 필수 | 통화 방향 (예: inbound) |
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=inboundGET 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).
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| language | string | 선택 | 음성 언어 (ko, en, ja). 기본값: ko |
language 속성은 ko(한국어), en(영어), ja(일본어)를 지원합니다. 생략 시 한국어가 기본 적용됩니다.
<Say language="ko">안녕하세요. 고객센터입니다.</Say><Play>
오디오 파일을 재생하거나 DTMF 톤을 송출합니다. URL 은 본문 텍스트로 지정 (속성이 아님).
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| loop | integer | 선택 | 반복 재생 횟수. 0 이면 무한 반복. 기본값: 1 |
| digits | string | 선택 | 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>를 중첩할 수 있습니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| timeout | integer | 선택 | inter-digit 대기 시간 (초). 매 입력마다 리셋. 기본값: 5 |
| numDigits | integer | 선택 | 수집할 자릿수. 도달 시 즉시 종료. 미설정 시 finishOnKey 또는 timeout 까지 무제한 수집 |
| finishOnKey | string | 선택 | 입력 완료 키. 누르면 즉시 종료되며 Digits 에 포함되지 않음. 기본값: '#'. 빈 문자열이면 비활성화 |
| action | string | 선택 | 입력 완료 후 요청할 콜백 URL (POST). Digits 가 비어있거나 timeout 시 호출되지 않음 |
<Gather timeout="5" numDigits="1" action="/handle-input">
<Say language="ko">1번 상담, 2번 안내, 3번 기타</Say>
</Gather>Gather 완료 시 action URL로 POST 요청이 전송되며, Digits 파라미터에 사용자 입력값이 포함됩니다.
<Record>
발신자 발화를 별도 파일로 녹음합니다. 통화 전체 녹음(MixMonitor)과 독립된 경로.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| maxLength | integer | 선택 | 최대 녹음 시간 (초). 기본값: 60 |
| finishOnKey | string | 선택 | 녹음 종료 키. 기본값: '#' |
| playBeep | boolean | 선택 | 녹음 시작 전 비프음 재생. 기본값: false |
| action | string | 선택 | 녹음 완료 후 요청할 URL |
녹음 완료시 action URL 로 다음 파라미터가 추가 전송됩니다:
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| RecordingUrl | string | 필수 | 녹음 파일의 24시간 유효 서명 URL (GCS). 그대로 GET 으로 다운로드 가능 |
| RecordingDuration | string | 필수 | 녹음 길이 (초). 0 이면 미녹음 |
| Digits | string | 선택 | 녹음을 종료시킨 키 (finishOnKey 와 일치). 시간 초과로 종료되면 빈 문자열 |
<Record maxLength="60" finishOnKey="#" playBeep="true" action="https://your-server.com/voicemail-done"/><Dial>
외부 번호로 전화를 연결합니다. 현재 <Number> noun 만 지원하며 본문은 한국 국내 전화번호 형식으로 자동 정규화됩니다. callerId 는 계정 소유 번호로만 설정 가능 (spoofing 방지).
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| callerId | string | 선택 | 발신자 번호 표시 — 계정 소유 번호여야 함. 미소유 번호 지정시 dial 실패 (DialCallStatus=failed) |
| timeout | integer | 선택 | 연결 대기 시간 (초). 기본값: 30 |
| action | string | 선택 | Dial 종료 후 요청할 URL (POST) |
action 이 설정된 경우 다음 파라미터가 추가 전송됩니다:
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| DialCallStatus | string | 필수 | Dial 결과 — completed / busy / no-answer / failed / canceled |
<Dial callerId="07012340001" timeout="30" action="https://your-server.com/after-dial">
<Number>01012345678</Number>
</Dial><Sip> noun 은 현재 미지원입니다. SIP URI 발신이 필요하면 별도로 문의해 주세요.
<Connect>
WebSocket Stream을 연결하여 실시간 양방향 오디오를 처리합니다. AI Agent 연동에 주로 사용됩니다.
하위에 <Stream> 엘리먼트를 포함하며, <Stream>은 <Parameter> 자식을 가질 수 있습니다.
Stream 속성
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| url | string | 필수 | WebSocket 서버 URL (wss://) |
| track | string | 선택 | 스트리밍할 오디오 트랙 (inbound, outbound, both) |
Parameter 속성
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| name | string | 필수 | 파라미터 이름 |
| value | string | 필수 | 파라미터 값 |
<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 | 거절 |
busy | 486 Busy Here | 통화중 |
<Response>
<Reject reason="busy"/>
</Response><Pause>
지정된 시간만큼 대기합니다.
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| length | integer | 선택 | 대기 시간 (초). 기본값: 1 |
<Pause length="2"/><Redirect>
다른 URL로 요청을 리다이렉트하여 새로운 VoiceML을 가져옵니다. 엘리먼트 텍스트에 URL을 포함합니다. 호출은 항상 POST 입니다.
<Redirect>https://your-server.com/next-step</Redirect>중첩 규칙
| 부모 엘리먼트 | 허용되는 자식 엘리먼트 |
|---|---|
<Gather> | <Say>, <Play> |
<Dial> | <Number> |
<Connect> | <Stream> |
<Stream> | <Parameter> |
<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를 참고하세요.
전체 예제: 통신사 콜센터 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 finishOnKey="#"> → 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, `
<Record maxLength="60" finishOnKey="#" 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 미지원
<Sip>noun 미지원 — Dial 은<Number>만Gather input="speech"미지원 — DTMF only