서버
웹훅
채널에 웹훅 주소를 등록하면 연락처 인증, 새 대화, 새 메시지 같은 이벤트를 서버로 받아요. 모든 요청에는 서명이 붙어 있으니 처리하기 전에 꼭 검증하세요.
서명
요청 헤더 Entropy-Signature는 t=<unix 초>,v1=<서명> 형식이에요. 서명은 hex(HMAC_SHA256(webhookSecret, t + "." + rawBody))예요.
Entropy-Signature: t=1792915200,v1=5c1f0e…9ab2
Entropy-Event-Id: evt_01J8Z4…- 본문을 파싱하기 전에 받은 원본 바이트 그대로 계산하세요. 다시 직렬화한 JSON은 서명이 달라져요.
- 비교는 상수 시간 함수(
timingSafeEqual,hmac.compare_digest)로 하세요. t가 현재 시각과 300초(5분) 넘게 차이 나면 거절하세요. 재전송 공격을 막아요.
import { createHmac, timingSafeEqual } from 'node:crypto';
/**
* Entropy-Signature: t=<unix>,v1=<hex HMAC_SHA256(webhookSecret, t + "." + rawBody)>
* 5분(300초)보다 오래된 요청은 재전송 공격으로 보고 거절합니다.
*/
export function verifyEntropySignature(rawBody, header, secret, toleranceSec = 300, nowSec = Date.now() / 1000) {
const parts = {};
for (const kv of String(header ?? '').split(',')) {
const i = kv.indexOf('=');
if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
}
if (!/^\d+$/.test(parts.t ?? '') || !/^[0-9a-f]{64}$/i.test(parts.v1 ?? '')) return false;
if (Math.abs(nowSec - Number(parts.t)) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(parts.t + '.').update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, 'hex'));
}서명은 원본 바이트로 계산해요. JSON.parse 전에 검증하세요.
이벤트
| 이벤트 | 언제 |
|---|---|
contact.verified | 연락처가 이메일 또는 휴대폰 인증을 마쳤을 때 |
conversation.created | 새 대화가 시작됐을 때 |
message.created | 새 메시지가 저장됐을 때 |
conversation.closed | 대화가 종료됐을 때 |
feedback.status_changed | 피드백과 연결된 이슈의 상태가 바뀌었을 때 |
contact.erased | 회원 탈퇴 요청이나 보관 기간 만료로 연락처와 상담 기록을 지웠을 때 |
본문 예
{
"id": "evt_01J8Z4…",
"type": "contact.verified",
"createdAt": "2026-10-27T09:01:12Z",
"channelId": "ch_01J…",
"data": {
"contact": {
"id": "ct_01J…",
"externalMemberId": null,
"name": "고객",
"email": null,
"phone": "+821012345678",
"phoneVerifiedAt": "2026-10-27T09:01:12Z",
"consents": {
"termsAt": "2026-10-27T09:01:12Z",
"privacyAt": "2026-10-27T09:01:12Z",
"marketingAt": null,
"termsVersion": "2026-10"
}
}
}
}{
"id": "evt_01J8Z9…",
"type": "feedback.status_changed",
"createdAt": "2026-11-03T02:15:00Z",
"channelId": "ch_01J…",
"data": {
"conversationId": "cv_01J8Z3K6W2",
"contactId": "ct_01J…",
"linkedIssueId": "iss_01J…",
"status": "completed"
}
}{
"id": "evt_01J9A2…",
"type": "contact.erased",
"createdAt": "2026-11-04T05:00:00Z",
"channelId": "ch_01J…",
"data": {
"contact": {
"id": "ct_01J…",
"externalMemberId": "user_2041"
},
"reason": "request",
"erasedAt": "2026-11-04T05:00:00Z",
"counts": {
"conversations": 2,
"messages": 17,
"attachments": 1
}
}
}reason은 탈퇴 회원 삭제 API를 호출했으면 request, 대화가 남지 않은 연락처가 보관 기간 동안 활동이 없었으면 retention이에요. 받으면 우리 쪽에 남은 연락처 연결 정보도 지워 주세요.
인증된 연락처를 회원으로 가입시키기
contact.verified에 200과 { "externalMemberId": "…" }로 응답하면 엔트로피가 연락처를 그 회원과 이어요. 이후 대화는 회원 기준으로 모여요. 회원을 만들지 않으려면 본문 없이 200만 돌려주세요.
{
"externalMemberId": "user_2041"
}전송 보장과 재시도
- 최소 한 번 전송해요. 같은 이벤트가 두 번 올 수 있으니
Entropy-Event-Id로 중복을 거르세요. 2xx가 아니거나 응답이 없으면 간격을 늘려 가며 24시간 동안 다시 보내요.- 처리가 오래 걸리면 먼저
200을 돌려주고 작업은 큐에서 하세요. - 이벤트 순서는 보장하지 않아요. 순서가 중요하면
createdAt을 비교하세요.