KAT Cloud로그인

Node.js(Express)로 KAT Cloud 웹훅 수신 서버 만들기

KAT Cloud 웹훅 이벤트 4종을 Express 서버 하나로 받는 방법입니다. eventType 분기, 즉시 200 응답과 비동기 처리, chatId·callHistoryId 기반 중복 제거까지 Node.js 코드로 정리했습니다.

가이드KAT Cloud 팀11분 읽기
Node.js(Express)로 KAT Cloud 웹훅 수신 서버 만들기

KAT Cloud 웹훅 수신 서버는 KAT Cloud가 이벤트마다 보내는 HTTP POST 요청을 받아 내 시스템에 반영하는 HTTPS 엔드포인트입니다. Node.js에서는 Express 라우트 하나로 MESSAGE_RECEIVED, SEND_RESULT, CALL_RECEIVED, CALL_COMPLETED 네 가지 이벤트를 모두 받을 수 있습니다. 핵심은 세 가지입니다. 요청을 받자마자 200을 돌려주고, 실제 처리는 응답 뒤에 비동기로 하고, 같은 이벤트가 두 번 와도 한 번만 처리하는 것입니다.

KAT Cloud 웹훅 이벤트 4종과 페이로드 구조

KAT Cloud(캣클라우드) 웹훅은 모든 이벤트가 같은 겉모양을 가집니다. 최상위에 eventType과 timestamp가 있고, 이벤트별 내용은 data 안에 들어 있습니다.

JSON
{
  "eventType": "SEND_RESULT",
  "timestamp": "2024-03-05T10:30:05.000Z",
  "data": {
    "chatId": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "chatRoomId": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
    "chatType": "SMS",
    "sendStatus": "SUCCESS",
    "resultStatus": "4100",
    "resultMessage": "전달",
    "sentAt": "2024-03-05T10:30:05.000Z"
  }
}

네 이벤트가 언제 오고, data 안에서 무엇으로 식별하는지 정리하면 다음과 같습니다.

이벤트언제 오나data 안의 식별자주로 쓰는 필드
MESSAGE_RECEIVED고객이 내 번호로 문자를 보냈을 때chatId, chatRoomIdsenderPhoneNumber, content, imageList
SEND_RESULT메시지 사업자가 최종 발송 결과를 회신했을 때chatIdsendStatus, resultStatus, resultMessage
CALL_RECEIVED전화가 수신됐을 때(착신 알림)callHistoryIdfromNumber, toNumber, handOffReason
CALL_COMPLETED통화 종료 후 STT·요약 분석이 끝났을 때callHistoryIdstatus, tag, duration, summary

API 문서가 제시하는 웹훅 처리 권장사항은 짧습니다. 타임아웃은 10초이고, HTTP 2xx로 응답하면 성공으로 처리하며, 비동기로 처리하고 즉시 응답하는 방식을 권장합니다. 필드 전체 목록은 웹훅 이벤트 문서에서 확인할 수 있습니다. 왜 폴링 대신 웹훅을 쓰는지, 큐를 어디에 두는지 같은 설계 배경은 웹훅 이벤트 아키텍처 글에서 따로 다룹니다.

1단계: 이벤트마다 웹훅 등록하기

웹훅은 POST /webhook으로 등록하며, 요청 하나에 eventType 하나와 수신 url 하나를 넣습니다. URL은 https 주소여야 합니다. 네 이벤트를 모두 받으려면 네 번 등록합니다. 이 글의 서버는 공식 문서의 Node.js 예제처럼 하나의 경로에서 eventType으로 분기하므로, 네 이벤트를 같은 URL로 등록합니다. 웹훅 관리 API는 비즈니스 요금제 그룹 또는 별도 허용된 그룹에서만 쓸 수 있고, 조건이 맞지 않으면 403 AU017이 반환됩니다.

Shell
export KAT_API_KEY="콘솔에서 발급받은 키"
BASE="https://studio-gateway.zerocall.kr"
HOOK_URL="https://hooks.example.com/webhook/katcloud"

for EVENT in MESSAGE_RECEIVED SEND_RESULT CALL_RECEIVED CALL_COMPLETED; do
  curl -s -X POST "$BASE/webhook" \
    -H "x-api-key: $KAT_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"eventType\":\"$EVENT\",\"url\":\"$HOOK_URL\"}"
  echo
done

# 등록된 웹훅 목록(id, eventType, url) 확인
curl -s "$BASE/webhook" -H "x-api-key: $KAT_API_KEY"

이미 등록한 웹훅의 주소만 바꿀 때는 새로 등록하지 말고 PATCH /webhook/{webhookId}에 url을 보내고, 필요 없는 웹훅은 DELETE /webhook/{webhookId}로 지웁니다. 같은 이벤트를 실수로 여러 번 등록하면 같은 알림이 여러 번 올 수 있으니, 배포 스크립트에서는 GET /webhook으로 기존 목록을 먼저 확인하는 편이 안전합니다.

2단계: Express 서버 — 먼저 200, 처리는 나중에

서버 코드는 Node.js 18 이상과 Express 4를 기준으로 합니다. 라우트는 요청 본문에서 eventType과 data를 꺼낸 뒤, 무엇보다 먼저 200을 돌려줍니다. 그다음 중복 여부를 확인하고, 실제 처리는 setImmediate로 응답 이후에 실행합니다.

JavaScript
// server.js — KAT Cloud 웹훅 수신 서버 (Node.js 18+, Express 4)
const express = require('express');
const { dedupeKey, claim, release } = require('./dedupe');

const app = express();
// CALL_COMPLETED에는 STT 배열이 들어 있어 기본 한도(100kb)를 넘을 수 있다
app.use(express.json({ limit: '2mb' }));

const SUCCESS_CODES = ['4100', '6600', '7000']; // SMS, LMS·MMS, 카카오

const handlers = {
  async MESSAGE_RECEIVED(data) {
    console.log('문자 수신', data.chatRoomId, data.senderPhoneNumber, data.content);
    // 예: 상담 대기열에 넣기, 자동응답 만들기
  },
  async SEND_RESULT(data) {
    const delivered = SUCCESS_CODES.includes(String(data.resultStatus));
    console.log('발송 결과', data.chatId, data.resultStatus, data.resultMessage, delivered);
  },
  async CALL_RECEIVED(data) {
    console.log('착신', data.callHistoryId, data.fromNumber, data.handOffReason || '');
  },
  async CALL_COMPLETED(data) {
    console.log('통화 완료', data.callHistoryId, data.status, data.tag, data.duration);
    // 예: DB 저장, 슬랙 알림, CRM 기록
  },
};

app.post('/webhook/katcloud', (req, res) => {
  const { eventType, data } = req.body || {};

  // 1) 무엇보다 먼저 200을 돌려준다 (타임아웃 10초)
  res.status(200).json({ received: true });

  // 2) 모르는 이벤트나 data가 없는 요청은 조용히 넘긴다
  const handler = handlers[eventType];
  if (!handler || !data) return;

  // 3) 이미 처리한 이벤트면 건너뛴다
  const key = dedupeKey(eventType, data);
  if (key && !claim(key)) return;

  // 4) 실제 처리는 응답 이후에
  setImmediate(async () => {
    try {
      await handler(data);
    } catch (err) {
      if (key) release(key); // 실패하면 다음에 다시 처리할 수 있게 키를 푼다
      console.error('[webhook] 처리 실패', eventType, key, err);
    }
  });
});

app.get('/healthz', (_req, res) => res.send('ok'));

app.listen(process.env.PORT || 3000, () => console.log('webhook server ready'));
KAT Cloud가 Express 서버로 이벤트를 POST하면 서버가 즉시 200을 돌려주고, 중복이 아닌 이벤트만 작업으로 넘겨 핸들러가 처리하는 순서도
응답은 10초 제한 안에 바로 돌려주고, 무거운 작업은 응답 뒤에 처리합니다.

express.json()의 기본 본문 한도는 100kb입니다. CALL_COMPLETED에는 sttAI, sttDirect 대화 배열이 통째로 실려 오므로 통화가 길면 한도를 넘을 수 있습니다. 한도를 넘으면 Express가 413을 돌려주고, KAT Cloud 입장에서는 실패한 전송이 됩니다. 위 코드처럼 한도를 넉넉히 잡아 두는 것이 좋습니다.

3단계: id로 중복 제거하기

웹훅 수신 서버는 같은 이벤트가 두 번 올 수 있다고 가정하고 만드는 것이 안전합니다. 네트워크가 잠깐 끊기거나 응답이 늦으면 보내는 쪽과 받는 쪽의 판단이 어긋날 수 있기 때문입니다. 그런데 KAT Cloud 웹훅 페이로드에는 이벤트 전용 id 필드가 없습니다. 그래서 이벤트 종류와 data 안의 식별자를 묶어 중복 판단 키로 씁니다.

이벤트중복 판단 키이유
MESSAGE_RECEIVEDeventType + chatId수신 메시지마다 고유한 32자 ID
SEND_RESULTeventType + chatId발송한 메시지의 ID와 같음
CALL_RECEIVEDeventType + callHistoryId + 전환 여부일반 착신과 AI→직원 전환 알림을 구분
CALL_COMPLETEDeventType + callHistoryId통화 한 건에 분석 결과 한 번

CALL_RECEIVED와 CALL_COMPLETED는 같은 callHistoryId를 쓰므로 키에 eventType을 반드시 넣어야 합니다. 또 CALL_RECEIVED는 AI 응대 전뿐 아니라 직원 연결 전에도 올 수 있고, 이때만 handOffReason이 채워집니다. 두 알림을 하나로 합쳐 버리지 않도록 전환 여부를 키에 함께 넣습니다.

웹훅 요청을 받으면 eventType과 식별자로 키를 만들고, 저장소에서 처리 여부를 확인한 뒤 처음 보는 이벤트만 핸들러로 넘기는 흐름도
이벤트 id가 없으므로 data 안의 식별자로 키를 만듭니다.
JavaScript
// dedupe.js — 이벤트 중복 제거 (단일 프로세스용 메모리 저장소)
const TTL_MS = 24 * 60 * 60 * 1000; // 같은 키를 24시간 기억
const seen = new Map(); // key -> 만료 시각(ms)

// 페이로드에 이벤트 id가 없으므로 data 안의 식별자로 키를 만든다
function dedupeKey(eventType, data = {}) {
  switch (eventType) {
    case 'MESSAGE_RECEIVED':
    case 'SEND_RESULT':
      return data.chatId ? `${eventType}:${data.chatId}` : null;
    case 'CALL_RECEIVED':
      return data.callHistoryId
        ? `${eventType}:${data.callHistoryId}:${data.handOffReason ? 'handoff' : 'ring'}`
        : null;
    case 'CALL_COMPLETED':
      return data.callHistoryId ? `${eventType}:${data.callHistoryId}` : null;
    default:
      return null;
  }
}

// 처음 보는 키면 true(처리), 이미 본 키면 false(건너뜀)
function claim(key) {
  const now = Date.now();
  for (const [k, expiresAt] of seen) {
    if (expiresAt > now) break; // Map은 넣은 순서대로 돌므로 오래된 것부터 정리된다
    seen.delete(k);
  }
  if (seen.has(key)) return false;
  seen.set(key, now + TTL_MS);
  return true;
}

function release(key) {
  seen.delete(key);
}

module.exports = { dedupeKey, claim, release };

메모리 Map은 서버 한 대, 프로세스 하나일 때만 맞습니다. 인스턴스를 여러 대 띄우거나 재시작 후에도 기억해야 한다면 Redis나 DB로 옮깁니다. Redis라면 키를 만료 시간과 함께 "없을 때만 저장"하는 명령 하나로 같은 효과를 낼 수 있습니다.

Text
SET katcloud:dedupe:CALL_COMPLETED:8f3f8fd9-7ab8-4a26-b523-4f8eb7d38b85 1 NX EX 86400

DB에 저장하는 핸들러라면 chatId나 callHistoryId에 고유 제약을 걸고 upsert로 쓰는 방법이 가장 단순합니다. 이 방식은 Python(FastAPI) 웹훅 서버 글에서 SQLite로 구현합니다.

4단계: 로컬 테스트와 연결 점검

서버를 띄운 뒤에는 같은 페이로드를 두 번 보내 로그가 한 번만 찍히는지 확인합니다.

Shell
node server.js

# 다른 터미널에서 같은 요청을 두 번 보낸다
for i in 1 2; do
  curl -s -X POST http://localhost:3000/webhook/katcloud \
    -H "Content-Type: application/json" \
    -d '{"eventType":"SEND_RESULT","timestamp":"2024-03-05T10:30:05.000Z","data":{"chatId":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx","chatType":"SMS","sendStatus":"SUCCESS","resultStatus":"4100","resultMessage":"전달"}}'
  echo
done

외부 연결은 POST /webhook/{webhookId}/test로 점검합니다. 등록된 URL로 테스트 페이로드를 보내고, 결과의 success, statusCode, responseTime(ms), error로 내 서버가 어떻게 응답했는지 알려 줍니다. 테스트 페이로드의 구체적인 내용은 문서에 정해져 있지 않으므로, 서버는 필드가 비어 있어도 500을 내지 않게 만들어 두어야 합니다.

Shell
# webhookId는 GET /webhook 응답의 id
curl -s -X POST "$BASE/webhook/1/test" -H "x-api-key: $KAT_API_KEY"

statusCode가 200이 아니거나 responseTime이 길게 나오면 프록시 설정, 인증서, 라우트 경로를 차례로 확인합니다. 증상별 점검 순서는 웹훅 등록·수정·테스트 글에 정리되어 있습니다.

운영할 때 챙길 것

로컬에서 잘 돌던 서버도 운영에 올리면 다른 문제가 생깁니다. 다음 항목을 배포 전에 확인합니다.

  • HTTPS: 웹훅 URL은 https만 등록할 수 있습니다. 로드밸런서나 리버스 프록시에서 TLS를 종료하고 Express로 넘기는 구성이 일반적입니다.
  • 유실 대비: 200을 먼저 돌려준 뒤 프로세스가 죽으면 그 이벤트는 다시 오지 않을 수 있습니다. 중요한 처리는 메모리 대신 Redis 기반 큐나 클라우드 메시지 큐에 먼저 넣습니다.
  • 통화 기록 보정: CALL_COMPLETED를 놓쳤을 때는 GET /call-history의 updatedAfter 커서로 빠진 기록을 채울 수 있습니다. 방법은 통화 기록 증분 동기화 글을 참고합니다.
  • 만료되는 URL: 수신 문자의 imageList 이미지 URL은 일정 시간이 지나면 만료됩니다. 보관해야 하면 핸들러에서 바로 내려받습니다.
  • 로그: 전화번호, 문자 내용, 통화 요약에는 개인정보가 담길 수 있습니다. 디버그 로그에 본문 전체를 남기지 말고 ID 위주로 남깁니다.
  • API 호출량: 핸들러에서 KAT Cloud API를 다시 부를 때는 Rate Limit(60초당 1,200회)을 넘지 않게 큐에서 속도를 조절합니다.

자주 묻는 질문

KAT Cloud 웹훅 서버는 몇 초 안에 응답해야 하나요?

KAT Cloud 웹훅의 타임아웃은 10초이고, HTTP 2xx 응답을 받으면 성공으로 처리합니다. DB 저장이나 외부 API 호출을 끝낸 뒤 응답하면 10초를 넘길 수 있으므로, 요청을 받자마자 200을 돌려주고 처리는 응답 이후에 비동기로 하는 방식이 권장됩니다.

이벤트마다 다른 URL을 등록해야 하나요?

KAT Cloud 웹훅은 이벤트 타입마다 하나씩 등록하지만, 받는 쪽 구조는 직접 정할 수 있습니다. 공식 문서의 Node.js 예제처럼 하나의 엔드포인트에서 eventType으로 분기해도 되고, 이벤트별로 경로를 나누면 이벤트마다 따로 모니터링하고 확장하기 쉬워집니다.

웹훅 페이로드에 이벤트 id가 없는데 중복은 어떻게 거르나요?

KAT Cloud 웹훅 페이로드에는 이벤트 전용 id가 없으므로, 문자 이벤트는 eventType과 chatId, 통화 이벤트는 eventType과 callHistoryId를 묶어 중복 판단 키로 씁니다. CALL_RECEIVED는 AI→직원 전환 시에도 올 수 있으므로 handOffReason 유무까지 키에 넣으면 두 알림을 구분할 수 있습니다.

  • #웹훅
  • #Node.js
  • #Express
  • #KAT Cloud API

Get started

만든 AI에게 전화번호를 주세요.

070 번호 발급, SIP 직접 연결, 음성 AI 모델 연결, 문자·웹훅까지 API 하나로. 월 10,000원부터.