# OpenAI Realtime (/docs/sip-trunk/openai-realtime)

## 개요

한국AI통신 070 번호를 OpenAI Project 의 SIP 게이트웨이 (`sip.api.openai.com`) 로 라우팅합니다. 통화가 도착하면 OpenAI 가 `realtime.call.incoming` webhook 을 호출하고, 우리 서버가 `accept` 응답하면 OpenAI 가 음성 모델로 통화를 처리합니다.

**SIP 트렁크 연결 부가서비스** 가 활성화되어 있어야 합니다.
[대시보드 → 부가서비스](https://platform.claw-ops.com/addons) 에서 켜주세요.

## 동작 흐름

```mermaid
sequenceDiagram
  autonumber
  participant Caller as 발신자
  participant Co as 한국AI통신
  participant Oa as sip.api.openai.com
  participant Wh as 고객사 Webhook 서버
  Caller->>Co: PSTN 발신 (070 번호)
  Co->>Oa: SIP INVITE over TLS
  Oa->>Wh: POST realtime.call.incoming
  Wh->>Oa: POST /v1/realtime/calls/{call_id}/accept
  Oa-->>Co: 통화 수락
  Wh->>Oa: wss://api.openai.com/v1/realtime?call_id=...
```

## 사전 준비물

| 항목 | 출처 |
| --- | --- |
| **Project ID** (`proj_xxx`) | [platform.openai.com](https://platform.openai.com) → Settings → General |
| **API Key** (`sk-proj-...`) | Settings → API keys |
| **Webhook Signing Secret** (`whsec_...`) | Settings → Webhooks (3단계에서 생성) |

---

## 1단계 — Webhook 핸들러 서버

OpenAI 가 콜이 들어올 때 호출할 엔드포인트입니다. 서명 검증 후 `accept` 를 호출하고, WebSocket 으로 세션을 모니터링합니다.

`package.json`:

```json
{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts"
  },
  "dependencies": {
    "body-parser": "^1.20.3",
    "dotenv": "^16.4.5",
    "express": "^4.21.2",
    "openai": "^6.37.0",
    "ws": "^8.18.0"
  },
  "devDependencies": {
    "@types/body-parser": "^1.19.5",
    "@types/express": "^4.17.21",
    "@types/node": "^22.10.5",
    "@types/ws": "^8.5.13",
    "tsx": "^4.19.2",
    "typescript": "^5.7.3"
  }
}
```

`.env`:

```bash
OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxx
OPENAI_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
PORT=8000
```

`src/index.ts`:

```typescript
import bodyParser from "body-parser"
import "dotenv/config"
import express, { Request, Response } from "express"
import OpenAI from "openai"
import WebSocket from "ws"

const PORT = Number(process.env.PORT ?? 8000)
const OPENAI_API_KEY = process.env.OPENAI_API_KEY!
const OPENAI_WEBHOOK_SECRET = process.env.OPENAI_WEBHOOK_SECRET!

const openai = new OpenAI({
  apiKey: OPENAI_API_KEY,
  webhookSecret: OPENAI_WEBHOOK_SECRET,
})

const AUTH_HEADERS = {
  Authorization: `Bearer ${OPENAI_API_KEY}`,
  "Content-Type": "application/json",
}

const callAccept = {
  type: "realtime",
  model: "gpt-realtime",
  instructions: "당신은 친절한 한국어 음성 비서입니다.",
  audio: { output: { voice: "alloy" } },
}

const responseCreate = {
  type: "response.create",
  response: { instructions: "사용자에게 '안녕하세요, 무엇을 도와드릴까요?' 라고 인사하세요." },
}

async function websocketTask(callId: string) {
  const ws = new WebSocket(
    `wss://api.openai.com/v1/realtime?call_id=${callId}`,
    { headers: { Authorization: `Bearer ${OPENAI_API_KEY}` } },
  )
  ws.on("open", () => ws.send(JSON.stringify(responseCreate)))
  ws.on("message", (raw) => console.log(`[ws] ${raw.toString("utf8")}`))
  ws.on("error", (e) => console.error("[ws error]", e))
}

const app = express()
app.use(bodyParser.raw({ type: "*/*" }))

app.post("/", async (req: Request, res: Response) => {
  try {
    const event = await openai.webhooks.unwrap(
      (req.body as Buffer).toString("utf8"),
      req.headers as Record<string, string>,
    )

    if (event.type === "realtime.call.incoming") {
      const callId = (event as any).data.call_id
      await fetch(
        `https://api.openai.com/v1/realtime/calls/${callId}/accept`,
        { method: "POST", headers: AUTH_HEADERS, body: JSON.stringify(callAccept) },
      )
      websocketTask(callId).catch((e) => console.error("[ws] failed", e))
    }

    return res.sendStatus(200)
  } catch (e: any) {
    if (e?.name === "InvalidWebhookSignatureError") {
      return res.status(400).send("invalid signature")
    }
    console.error("[webhook] error", e)
    return res.status(500).send("server error")
  }
})

app.listen(PORT, () => console.log(`Listening on http://localhost:${PORT}`))
```

```bash
pnpm i
pnpm dev
# → Listening on http://localhost:8000
```

---

## 2단계 — 서버를 외부로 노출

OpenAI 가 webhook 을 호출할 수 있도록 외부 URL 로 노출합니다.

```bash
brew install cloudflared
cloudflared tunnel --url http://localhost:8000
# → https://xxxx-xxxx-xxxx.trycloudflare.com
```

운영 환경은 고정 도메인 (cloudflared named tunnel, ngrok reserved domain 등) 사용을 권장합니다.

---

## 3단계 — OpenAI 콘솔에서 Webhook 등록

[platform.openai.com](https://platform.openai.com) → Settings → Webhooks → **Create endpoint**

- **URL**: 2단계에서 받은 URL (예: `https://xxxx.trycloudflare.com/`)
- **Event types**: `realtime.call.incoming`

생성 직후 표시되는 **Signing secret** (`whsec_...`) 을 복사해 1단계 `.env` 의 `OPENAI_WEBHOOK_SECRET` 에 붙여넣은 후 서버를 재시작합니다.

---

## 4단계 — 한국AI통신 콘솔에서 SIP 엔드포인트 등록

[platform.claw-ops.com/phone-numbers/sip-endpoints](https://platform.claw-ops.com/phone-numbers/sip-endpoints) → **엔드포인트 추가**

| 필드 | 값 |
| --- | --- |
| 엔드포인트 이름 | `OpenAI Realtime` (자유) |
| **User** | OpenAI Project ID (`proj_xxx`) |
| Host | `sip.api.openai.com` |
| Port | `5061` |
| Transport | **TLS** |
| 외부 SIP 인증 사용 | 체크 안 함 |

라이브 프리뷰가 `sip:proj_xxx@sip.api.openai.com:5061` 로 표시되면 정상입니다.

---

## 5단계 — 070 번호 라우팅을 SIP 로 전환

[platform.claw-ops.com/phone-numbers](https://platform.claw-ops.com/phone-numbers) → 번호 선택 → **인바운드 라우팅** 카드의 **수정** → **SIP** 토글 → 4단계의 엔드포인트 선택 → 저장.

이제 해당 번호로 걸려오는 콜은 OpenAI 의 SIP 게이트웨이로 전달되고, 1단계의 webhook 핸들러가 호출됩니다.

---

## 참고

- [Realtime API with SIP](https://developers.openai.com/api/docs/guides/realtime-sip) — OpenAI 공식 가이드
- [Realtime Calls API](https://developers.openai.com/api/docs/api-reference/realtime-calls) — accept / reject / refer / hangup 엔드포인트 레퍼런스
