통화 녹음 파일 내려받기: Presigned URL과 1시간 만료 처리
KAT Cloud 통화 녹음은 통화 기록의 recordingAI·recordingDirect에 S3 Presigned URL로 담기며 1시간 뒤 만료됩니다. isRecorded 확인, 만료 전 내려받기, 별도 저장소 보관 코드를 정리했습니다.

KAT Cloud 통화 녹음 파일은 통화 기록의 recordingAI(AI 구간)와 recordingDirect(직원 구간) 필드에 S3 Presigned URL로 담겨 내려오며, 이 URL은 1시간 후 만료됩니다. 녹음을 보관해야 한다면 URL을 받은 뒤 바로 내려받아 별도 저장소에 저장해야 합니다. 통화 기록의 isRecorded가 false면 녹음 파일, 요약, STT가 아예 제공되지 않으므로 이 값부터 확인합니다.

녹음 정보가 담기는 곳: recordingAI와 recordingDirect
KAT Cloud(캣클라우드)는 응답 완료된 통화(status: ANSWERED)에 대해, 통화 유형에 따라 녹음 파일 정보를 통화 기록에 포함합니다. AI 에이전트가 응대한 구간은 recordingAI, 직원이 통화한 구간은 recordingDirect입니다. AI가 받다가 직원에게 넘긴 통화(tag: AI_TO_DIRECT)라면 두 필드가 모두 들어올 수 있습니다. 대화 텍스트도 같은 방식으로 sttAI와 sttDirect로 나뉩니다.
{
"callHistoryId": "8f3f8fd9-7ab8-4a26-b523-4f8eb7d38b85",
"status": "ANSWERED",
"tag": "AI_TO_DIRECT",
"isRecorded": true,
"recordingAI": {
"url": "https://storage.example.com/recording-ai.wav",
"expiresAt": "2026-03-12T03:40:00.000Z",
"contentLength": 1234567,
"contentType": "audio/wav"
},
"recordingDirect": {
"url": "https://storage.example.com/recording-direct.wav",
"expiresAt": "2026-03-12T03:40:00.000Z",
"contentLength": 2345678,
"contentType": "audio/wav"
}
}| 필드 | 타입 | 설명 |
|---|---|---|
url | string | S3 Presigned URL(재생·다운로드용) |
expiresAt | string | URL 만료 시각(ISO 8601, 1시간) |
contentLength | number | 파일 크기(bytes) |
contentType | string | Content-Type(audio/wav) |
녹음 정보는 GET /call-history 목록과 GET /call-history/{callHistoryId}?groupId= 상세 조회에서 받을 수 있습니다. 한 통화의 파일을 내려받을 때는 상세 조회를 쓰는 편이 응답이 작고 간단합니다. 통화 유형에 따라 두 필드 중 하나만 있거나 비어 있을 수 있으므로, 코드는 각 필드가 없을 수 있다고 가정하고 작성합니다.
먼저 확인할 값: isRecorded, status, recordingEnabled
녹음 파일이 없는 통화도 많습니다. 내려받기를 시도하기 전에 아래 순서로 걸러 냅니다.
| 확인 값 | 조건 | 의미 |
|---|---|---|
isRecorded | false | 통화 당시 녹음이 꺼져 있었습니다. 녹음 파일, 요약(summary), STT(sttAI·sttDirect)가 모두 없습니다. |
status | ANSWERED가 아님 | MISSED, REJECTED 같은 통화는 녹음 대상이 아닙니다. |
recordingAI·recordingDirect | 값이 없음 | 해당 구간이 없는 통화 유형입니다. |
그룹의 recordingEnabled | false | GET /group에서 보는 그룹 설정입니다. 앞으로의 통화도 녹음되지 않습니다. |
isRecorded는 통화 당시의 녹음 활성화 여부이고, recordingEnabled는 그룹의 현재 설정입니다. 지난 통화를 판단할 때는 isRecorded를, 앞으로 녹음이 남을지 확인할 때는 recordingEnabled를 봅니다.
만료 전에 내려받는 순서
녹음 URL은 1시간 후 만료되므로, 받은 URL을 DB에 저장해 두고 나중에 여는 방식은 쓸 수 없습니다. 아래 순서로 처리합니다.
- 1
GET /call-history/{callHistoryId}?groupId=로 상세 정보를 조회합니다. - 2
isRecorded와 녹음 필드를 확인하고,expiresAt까지 남은 시간을 계산합니다. - 3만료가 임박했다면 상세 조회를 한 번 더 해서 유효한 URL이 오는지 확인합니다.
- 4Presigned URL로 파일을 내려받습니다. 이 요청에는
x-api-key를 붙이지 않습니다. - 5받은 파일 크기를
contentLength와 비교하고, 내 저장소에callHistoryId기준 이름으로 저장합니다.

Presigned URL은 URL 자체에 접근 권한이 들어 있는 임시 주소입니다. 여기에 KAT Cloud API 키를 헤더로 붙이면 키가 다른 호스트로 전달될 뿐 얻는 것이 없습니다. 또 URL이 유효한 동안은 주소만 알면 누구나 파일을 열 수 있으므로, 애플리케이션 로그나 고객 화면에 URL을 그대로 남기지 않습니다.
cURL로는 상세 조회와 다운로드를 두 번에 나눠 실행합니다.
# 1) 상세 조회
curl -G "https://studio-gateway.zerocall.kr/call-history/8f3f8fd9-7ab8-4a26-b523-4f8eb7d38b85" \
-H "x-api-key: $KAT_API_KEY" \
--data-urlencode "groupId=1"
# 2) 응답의 recordingAI.url 값을 RECORDING_URL에 넣고 내려받기(API 키 헤더 없음)
curl -fSL -o 8f3f8fd9-ai.wav "$RECORDING_URL"Python과 Node.js 다운로드 코드
아래 Python 함수는 한 통화의 녹음을 모두 내려받습니다. 만료 5분 전이면 다시 조회하고, 임시 파일로 받은 뒤 크기가 맞을 때만 최종 파일로 바꿉니다. 이미 받은 파일은 건너뛰므로 같은 통화로 여러 번 호출해도 안전합니다.
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import requests
BASE_URL = "https://studio-gateway.zerocall.kr"
api = requests.Session()
api.headers.update({"x-api-key": os.environ["KAT_API_KEY"]})
MARGIN = timedelta(minutes=5) # 만료까지 5분 미만이면 다시 조회
def get_call(group_id, call_history_id):
resp = api.get(f"{BASE_URL}/call-history/{call_history_id}",
params={"groupId": group_id}, timeout=15)
resp.raise_for_status()
body = resp.json()
return body["result"] if isinstance(body, dict) and "result" in body else body
def expires_soon(rec):
expires_at = datetime.fromisoformat(rec["expiresAt"].replace("Z", "+00:00"))
return expires_at - datetime.now(timezone.utc) < MARGIN
def fetch_file(rec, dest):
tmp = dest.with_suffix(".part")
# Presigned URL에는 API 키를 보내지 않으므로 세션 대신 requests.get을 씁니다.
with requests.get(rec["url"], stream=True, timeout=60) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_content(chunk_size=256 * 1024):
f.write(chunk)
size = tmp.stat().st_size
if rec.get("contentLength") and size != rec["contentLength"]:
tmp.unlink()
raise IOError(f"크기 불일치: {size} != {rec['contentLength']}")
tmp.rename(dest)
def download_recordings(group_id, call_history_id, out_dir="recordings"):
call = get_call(group_id, call_history_id)
if not call.get("isRecorded") or call.get("status") != "ANSWERED":
return [] # 녹음·요약·STT가 없는 통화
saved = []
for field, suffix in (("recordingAI", "ai"), ("recordingDirect", "direct")):
rec = call.get(field)
if not rec or not rec.get("url"):
continue
dest = Path(out_dir) / str(group_id) / f"{call_history_id}-{suffix}.wav"
if dest.exists():
saved.append(dest) # 이미 받은 파일
continue
if expires_soon(rec):
rec = get_call(group_id, call_history_id).get(field) or {}
if not rec.get("url") or expires_soon(rec):
raise RuntimeError(f"{call_history_id} {field}: 유효한 URL을 받지 못했습니다.")
dest.parent.mkdir(parents=True, exist_ok=True)
fetch_file(rec, dest)
saved.append(dest)
return saved
print(download_recordings(1, "8f3f8fd9-7ab8-4a26-b523-4f8eb7d38b85"))Node.js 18 이상에서는 내장 fetch와 스트림으로 같은 일을 할 수 있습니다.
const fs = require('node:fs');
const { Readable } = require('node:stream');
const { pipeline } = require('node:stream/promises');
const BASE_URL = 'https://studio-gateway.zerocall.kr';
async function getCall(groupId, callHistoryId) {
const url = `${BASE_URL}/call-history/${callHistoryId}?groupId=${groupId}`;
const res = await fetch(url, { headers: { 'x-api-key': process.env.KAT_API_KEY } });
if (!res.ok) throw new Error(`call-history ${res.status}`);
const body = await res.json();
return body.result ?? body;
}
async function saveRecording(rec, path) {
const res = await fetch(rec.url); // Presigned URL: API 키 헤더 없이 요청
if (!res.ok) throw new Error(`download ${res.status}`);
await pipeline(Readable.fromWeb(res.body), fs.createWriteStream(path));
}
(async () => {
const call = await getCall(1, '8f3f8fd9-7ab8-4a26-b523-4f8eb7d38b85');
if (call.isRecorded && call.recordingAI?.url) {
await saveRecording(call.recordingAI, `${call.callHistoryId}-ai.wav`);
}
})();웹훅의 recordingUrl, 그리고 보관 정책
CALL_COMPLETED 웹훅의 data에도 recordingUrl(선택)이 들어 있습니다. 다만 웹훅 페이로드의 이 URL에 대해서는 만료 시각 필드가 따로 없으므로, 웹훅은 내려받을 통화를 알려 주는 신호로 쓰고 실제 파일은 상세 조회의 recordingAI·recordingDirect와 expiresAt을 기준으로 받는 구성이 다루기 쉽습니다. 웹훅을 받으면 즉시 2xx로 응답하고, 다운로드는 작업 큐에서 처리합니다. 웹훅 없이 주기적으로 모은다면 통화 기록 증분 동기화 스크립트가 기록을 저장한 직후 이 함수를 호출하면 됩니다.
녹음을 어디에 얼마나 둘지도 정해야 합니다.
- 내 저장소에 보관한다면 버킷 접근 권한을 최소로 두고, 보관 기간이 지나면 자동 삭제되게 수명 주기 규칙을 걸어 둡니다.
- KAT Cloud에는 녹취 보관 부가서비스(10,000분당 월 3,000원부터)도 있습니다. 직접 보관 비용과 비교해 요금표에서 확인하세요.
- 녹음 파일에는 고객 음성과 대화 내용이 들어 있습니다. 녹음 사실 고지, 접근 권한, 보관 기간 같은 일반 원칙은 AI 전화와 개인정보에 정리했으며, 실제 적용은 법률 검토가 필요합니다.
녹음과 함께 내려오는 sttAI, sttDirect, summary의 구조는 통화 받아쓰기와 요약 데이터 구조에서 설명합니다. 원문은 API 문서의 통화 기록과 웹훅 이벤트에 있습니다.
자주 묻는 질문
KAT Cloud 녹음 URL은 얼마 동안 쓸 수 있나요?
KAT Cloud 통화 기록의 녹음 URL(recordingAI.url, recordingDirect.url)은 S3 Presigned URL이며 1시간 후 만료됩니다. 정확한 만료 시각은 같은 객체의 expiresAt에 ISO 8601 형식으로 들어 있으므로, 이 시각 전에 내려받아 별도 저장소에 보관해야 합니다.
isRecorded가 false인 통화는 녹음을 받을 방법이 없나요?
KAT Cloud에서 isRecorded가 false인 통화는 통화 당시 녹음이 활성화되지 않았다는 뜻이며, 녹음 파일과 요약, STT 대화 내역이 제공되지 않습니다. 앞으로의 통화를 녹음하려면 GET /group의 recordingEnabled 값으로 그룹의 녹음 설정을 먼저 확인합니다.
녹음 파일은 어떤 형식으로 내려오나요?
KAT Cloud API 문서에 따르면 녹음 파일의 contentType은 audio/wav이고, 파일 크기는 contentLength(bytes)로 함께 제공됩니다. 내려받은 뒤 파일 크기를 contentLength와 비교하면 전송이 중간에 끊겼는지 확인할 수 있습니다.
- #통화 녹음
- #Presigned URL
- #녹음 다운로드
- #KAT Cloud API



