ClawOps Docs

WebRTC 통합

브라우저에서 ClawOps 번호로 click-to-call 을 구현하는 가이드. 동작하는 최소 구현부터 프로덕션 보안까지 단계별로 다룹니다.

WebRTC 통합

브라우저에서 마이크 권한만 받으면 ClawOps 번호로 발신할 수 있는 click-to-call 위젯을 구현합니다.

ClawOps 의 WebRTC 는 표준 SIP-over-WebSocket (RFC 7118) 위에서 동작하므로, JsSIP 등 검증된 SIP UA 라이브러리를 그대로 사용할 수 있습니다.

이 페이지는 두 부분으로 나뉩니다:

  1. 구현 — 우선 동작하게 만드는 최소 흐름
  2. 보안 — 운영 환경에 올리기 전에 반드시 추가할 것들

부가서비스 활성화 필요. WebRTC 통화는 SIP 트렁크 연결 부가서비스 입니다. 대시보드 → 부가서비스 에서 활성화 후 진행하세요.

아키텍처


구현

여기까지만 따라하면 로컬에서 발신이 동작합니다. 운영 환경 보안은 다음 보안 섹션에서 추가합니다.

1단계: SipCredential 발급

대시보드에서 WebRTC 용 SipCredential 을 1회 만듭니다.

platform.claw-ops.comSIP Credentials 메뉴로 이동합니다.

필드
allowed_numbers발신자 번호로 허용할 070 번호 목록
realm자동 생성 (<random>.sip.claw-ops.com)

생성된 credential_id (예: SC_xxxxx) 와 계정 ID 를 메모합니다. 다음 단계에서 사용합니다.

2단계: 서버측 토큰 발급 API

브라우저가 ClawOps API Key 를 직접 호출하면 안 됩니다. 자체 서버에 엔드포인트 1개를 두고 API Key 를 서버 환경변수로 가립니다.

서버가 하는 일은 두 가지뿐입니다:

  1. ClawOps 에 단기 SIP 토큰을 요청 (POST /sip-credentials/:id/tokens)
  2. ICE/TURN 서버 정보를 요청 (GET /ice-servers)
// app/api/webrtc-call/token/route.ts (Next.js)
const API_BASE = process.env.CLAWOPS_API_BASE!;          // https://api.claw-ops.com
const ACCOUNT_ID = process.env.CLAWOPS_ACCOUNT_ID!;
const CREDENTIAL_ID = process.env.CLAWOPS_WEBRTC_CREDENTIAL_ID!;
const API_KEY = process.env.CLAWOPS_API_KEY!;

export async function POST() {
  const headers = { Authorization: `Bearer ${API_KEY}` };

  // 1. SIP 단기 토큰 발급 (TTL 7분)
  const tokenRes = await fetch(
    `${API_BASE}/v1/accounts/${ACCOUNT_ID}/sip-credentials/${CREDENTIAL_ID}/tokens`,
    {
      method: 'POST',
      headers: { ...headers, 'content-type': 'application/json' },
      body: JSON.stringify({ ttl_seconds: 420 }),
    },
  );
  const { token } = await tokenRes.json();

  // 2. ICE/TURN 서버 정보
  const iceRes = await fetch(`${API_BASE}/v1/accounts/${ACCOUNT_ID}/ice-servers`, { headers });
  const { ice_servers } = await iceRes.json();

  // 3. 브라우저로 그대로 전달
  return Response.json({ token, iceServers: ice_servers });
}

왜 토큰을 그대로 넘겨주나요?

토큰은 JWT 입니다. 안에 sip_username / sip_password / ws_url / realm 등 SIP 인증에 필요한 값이 들어 있습니다. 어차피 브라우저가 SIP 등록할 때 다 쓰는 값이라 숨길 의미가 없습니다. 서버가 가려야 할 건 "API Key" 하나뿐 이고, 토큰 자체는 만료 7분짜리 1회용이라 노출돼도 무해합니다 (Twilio Voice JS / Vonage Client SDK 와 동일 패턴).

3단계: 브라우저 — JsSIP UA

npm install jssip

브라우저는 다음 흐름을 따릅니다:

  1. 위에서 만든 /api/webrtc-call/token 호출
  2. 받은 JWT 의 payload 를 디코드해서 SIP 자격(sip_username, sip_password, ws_url, realm) 추출
  3. JsSIP UA 만들고 발신
import { UA, WebSocketInterface } from 'jssip';

// JWT payload 디코드 — 서명 검증은 ClawOps 게이트웨이가 함
function decodeJwtPayload(jwt: string) {
  const base64 = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
  return JSON.parse(atob(base64));
}

export async function call(destination: string) {
  // 1. 서버에서 토큰 + ICE 받기
  const res = await fetch('/api/webrtc-call/token', { method: 'POST' });
  const { token, iceServers } = await res.json();

  // 2. JWT payload 에서 SIP 자격 추출
  const claims = decodeJwtPayload(token);
  const wsUri        = claims.ws_url;             // wss://sip.claw-ops.com:5063
  const realm        = claims.realm;              // <random>.sip.claw-ops.com
  const sipUsername  = claims.sip_username;       // ephem_xxxxx (1회용)
  const sipPassword  = claims.sip_password;
  const callerId     = claims.allowed_caller_ids[0];  // 발신자로 표시될 070 번호

  // 3. JsSIP UA 만들기
  const socket = new WebSocketInterface(wsUri);
  const ua = new UA({
    sockets: [socket],
    uri: `sip:${sipUsername}@${realm}`,
    password: sipPassword,
    register: false,
  });
  ua.start();

  // 4. 발신
  const session = ua.call(`sip:${destination}@${realm}`, {
    mediaConstraints: { audio: true, video: false },
    pcConfig: { iceServers },
    // From URI 발신번호 (자세한 이유는 아래 박스)
    fromUserName: callerId,
  });

  // 5. 통화 연결 후 상대 음성 재생
  session.on('confirmed', () => {
    const audioEl = document.querySelector<HTMLAudioElement>('#remote-audio')!;
    const tracks = session.connection.getReceivers().map(r => r.track).filter(Boolean);
    audioEl.srcObject = new MediaStream(tracks);
  });

  return {
    hangup: () => { session.terminate(); ua.stop(); },
  };
}

페이지 어딘가에 오디오 엘리먼트를 두고:

<audio id="remote-audio" autoplay />
<button onclick="call('07012345678')">전화하기</button>

fromUserName 이 필요한가

JsSIP 는 기본적으로 From URI user 부분에 UA 의 uri 값(ephem_xxx) 을 그대로 씁니다. 하지만 ClawOps 게이트웨이는 발신번호 형식을 ^\+?[0-9]{1,20}$ 로 강제합니다 (위변조 방지).

fromUserName: callerId 옵션으로 From URI user 를 전화번호 형식으로 덮어씁니다. 인증 자체는 SIP digest username 에 의존하므로 영향 없음.

여기까지가 동작하는 최소 구성입니다. 그러나 운영 환경에 그대로 올리면 안 됩니다. 다음 보안 섹션을 적용하세요.


보안

운영 환경 click-to-call 은 비용 폭주 / 발신번호 위조 / 토큰 도용 위험에 노출됩니다. 아래 5가지를 모두 적용하세요.

S1. API Key 노출 금지

  • API Key 는 서버 환경변수에만 둡니다 (process.env.CLAWOPS_API_KEY).
  • NEXT_PUBLIC_* / VITE_* 등 클라이언트 번들에 들어가는 변수에 절대 넣지 마세요.
  • 클라이언트는 본인의 서버 에만 요청하고, 본인 서버가 ClawOps API 를 호출합니다 (위 2단계 참고).

S2. 도착 번호(destination) 는 서버가 결정

- // ❌ 클라이언트가 보낸 destination 을 그대로 사용
- const { destination } = await req.json();

+ // ✅ 서버가 세션 컨텍스트로부터 결정
+ const destination = await resolveDestinationForUser(req);  // 예: 사용자 ID → 담당자 번호

클라이언트가 destination 을 결정하면 토큰 탈취 시 임의 번호로 발신 가능 → 요금 폭탄. 사용자별 1:1 콜센터라면 세션 사용자 ID → 담당자 번호로 매핑. 범용 데모(우리 콜센터로만 발신) 라면 환경변수 1개로 박아두기.

S3. 봇 방어 (Cloudflare Turnstile)

토큰 발급 엔드포인트가 인증 없이 열려있다면 봇이 무한 발신 가능. Turnstile invisible challenge 를 페이지 로드 시점에 미리 실행해서 클릭 시점에 토큰 준비 완료 상태로 둡니다.

// lib/turnstile-context.tsx — 페이지 로드 시 토큰 사전 발급
'use client';
import { createContext, useContext, useEffect, useRef, useState } from 'react';

const SITEKEY = process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY!;
const Ctx = createContext<{ token: string | null; consume: () => void }>({
  token: null, consume: () => {},
});
export const useTurnstile = () => useContext(Ctx);

export function TurnstileProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState<string | null>(null);
  const widgetIdRef = useRef<string | null>(null);
  const containerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (!document.getElementById('cf-turnstile-script')) {
      const s = document.createElement('script');
      s.id = 'cf-turnstile-script';
      s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
      s.async = true;
      document.head.appendChild(s);
    }
    const t = setInterval(() => {
      if (!window.turnstile || !containerRef.current || widgetIdRef.current) return;
      widgetIdRef.current = window.turnstile.render(containerRef.current, {
        sitekey: SITEKEY,
        callback: setToken,
      });
      clearInterval(t);
    }, 200);
    return () => clearInterval(t);
  }, []);

  const consume = () => {
    setToken(null);
    if (widgetIdRef.current) window.turnstile?.reset(widgetIdRef.current);
  };

  return (
    <Ctx.Provider value={{ token, consume }}>
      {children}
      <div ref={containerRef} style={{ position: 'fixed', left: -9999, top: -9999 }} />
    </Ctx.Provider>
  );
}

서버에서 토큰 발급 전에 검증:

const { turnstileToken } = await req.json();
const ok = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
  method: 'POST',
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ secret: process.env.TURNSTILE_SECRET!, response: turnstileToken }),
}).then(r => r.json()).then(r => r.success);
if (!ok) return NextResponse.json({ error: 'turnstile_failed' }, { status: 403 });

토큰 사용 후 consume() 으로 reset 하면 다음 통화용 새 Turnstile 토큰이 자동 발급됩니다.

S4. Rate-limit (PSTN 발신 시 필수)

  • on-net 발신 (자기 계정 070 번호로 발신) → PSTN 비용 0. 길이 cap 만 있으면 OK.
  • PSTN 발신 → 토큰 발급 단위로 IP/계정당 시간/일 한도 필수.

권장 기본값: IP 당 분당 1회 / 일 5회. 프로덕트 특성에 맞게 조정.

S5. 통화 길이 안전망

브라우저 setTimeout 은 사용자가 콘솔로 우회 가능합니다. 서버측 hard cap 을 별도로 둡니다.

시나리오권장
자체 PBX/IVR 이 있다거기서 발신자번호 기준 max-duration 설정
ClawOps 만 사용토큰 TTL 자체를 짧게 (5~7분) → 만료 시점에 통화도 정리됨. 추가로 Calls webhook 으로 길이 모니터링하다 임계 초과 시 hangup

PSTN 발신은 비용이 발생하므로 반드시 길이 상한이 있어야 합니다. 클라이언트만 믿지 마세요.


참고

API Reference (Scalar UI) — 전체 스키마/예제/Try-it-out:

토큰과 JWT 안의 값들

토큰 응답에 들어있는 token 은 HS256 JWT 입니다. 디코드하면 SIP 인증에 필요한 모든 값(sip_username, sip_password, ws_url, realm, allowed_caller_ids 등)이 payload 에 들어 있습니다 — 위 3단계 참고.

서명 검증은 ClawOps 게이트웨이가 SIP digest 단계에서 수행하므로 클라이언트는 디코드만 하면 됩니다.

ws_url / realm 은 환경별로 달라질 수 있으므로 하드코딩하지 말고 토큰 payload 에서 읽어 사용하세요. 전체 응답/payload 필드 목록은 API Reference 참고.

ICE / TURN

GET /ice-servers 가 반환하는 ice_servers 배열을 그대로 RTCPeerConnectioniceServers 옵션에 넘기면 됩니다. TURN 자격은 시간제한 HMAC 으로 발급되며 expires_at 이후 무효 — 새로 발급받으세요.

전체 응답 스키마는 API Reference 참고.

트러블슈팅

증상원인해결
WSS 연결 실패 (status 0)wsUri 하드코딩, 환경 불일치JWT payload 의 ws_url 사용
407 Proxy Authentication Requireddigest 응답 누락JsSIP password 옵션 확인
403 Invalid Caller ID formatFrom URI user 가 phone format 위반fromUserName: callerId 옵션 추가
INVITE 후 즉시 BYE (Q.850 cause=16)self-call (자기 계정 번호 → 자기 계정 번호)ClawOps 가 자동 on-net 라우팅. 별도 작업 불필요
마이크 권한 거부사용자가 거부안내 UI + 재요청 버튼
통화는 되는데 음성이 안 들림ICE/TURN 실패 (사용자 NAT 환경)TURN(turns:5349) 자격 만료 여부 확인

운영 체크리스트

  • CLAWOPS_API_KEY 는 서버 환경변수에만, 클라이언트 번들에 노출 X
  • destination 은 서버에서 결정 (클라이언트 입력 신뢰 X)
  • Cloudflare Turnstile 또는 동등한 봇 방어
  • 토큰 TTL 5~7분 (통화 1건 길이)
  • PSTN 발신은 토큰 발급 Rate-limit (IP/계정당 일/시간 한도)
  • 통화 길이 안전망 (PBX max-duration 또는 webhook 모니터링)
  • HTTPS 필수 — getUserMedia 는 secure context 에서만 동작

다음