개발자
웹훅 Beta
연락처, 대화, 메시지, 태그, 팀 멤버십이 바뀔 때 안정적이고 서명된 알림을 받으세요.
API 레퍼런스 열기엔드포인트 만들기
- 01
설정을 열고 개발자를 선택한 뒤 웹훅에서 엔드포인트 추가를 선택하세요.
- 02
이름과 공개 HTTPS 엔드포인트 URL을 입력하고 이벤트에서 하나 이상을 고르세요.
- 03
엔드포인트 추가를 선택하세요.
POST /api/v1/webhooks로 만들 수도 있어요.
Sonny는 자격 증명이 포함된 URL, localhost, 사설/링크 로컬 IP 대역, 공개되지 않은 주소로 확인되는 DNS 이름을 거부해요.
서명 시크릿을 바로 저장하세요
whsec_로 시작하며 한 번만 표시돼요. 저장했어요를 선택하기 전에 시크릿 관리자에 복사하세요.
엔드포인트를 선택한 채널로 제한하기
설정 → 개발자에서 엔드포인트를 추가할 때와 나중에 편집할 때 모두, 모든 채널을 받을지 선택한 채널만 받을지 정할 수 있어요. 채널 범위 지정 기능 이전에 만든 엔드포인트는 바꾸기 전까지 모든 채널을 받아요.
API와 MCP 클라이언트는 엔드포인트를 만들거나 업데이트할 때 sourceIds로 같은 필터를 설정해요. 빈 배열은 워크스페이스의 모든 소스를 뜻해요. ID가 있으면 대화와 메시지 이벤트는 그 대화가 선택한 소스 중 하나에 속할 때만 전송돼요. 연락처나 멤버십 변경 같은 워크스페이스 수준 이벤트는 소스 필터가 있는 엔드포인트에 전송되지 않아요.
{
"name": "Product A agent",
"url": "https://agent.example.com/sonny",
"events": ["conversation.created", "message.created"],
"sourceIds": ["cm_source_id"]
}통합에서 버리는 이벤트는 보내지 않기
API로 답장하는 에이전트는 따로 설정하지 않으면 자기 답장 때문에 깨어나요. 세 가지 선택 필터로 엔드포인트가 받는 이벤트를 좁힐 수 있어요. 모두 기본적으로 꺼져 있으니 기존 엔드포인트는 바뀌지 않아요.
agentGroupIds- 목록에 있는 상담원 그룹이 소유한 대화만 받아요. 대화가 들어온 채널이 아니라 소유권을 따르므로, 공유 받은편지함의 대화도 그 대화를 소유한 그룹에 전달돼요. 그룹이 없는 대화는 전송되지 않고, 그룹은 보통 대화가 시작된 뒤에 지정되므로
conversation.created는 일치할 그룹이 생기기 전에 발생하는 경우가 많아요. customerMessagesOnly- 고객이 작성한 메시지만 받아요. 팀의 답장, 내부 메모, API·MCP·AI 응답기로 보낸 메시지(이 엔드포인트 자체의 답장 포함)는 건너뛰어요.
excludedSenderIds- 목록에 있는 팀원이 보낸 메시지는 건너뛰어요. 통합에 전용 팀원 계정을 주고 제외하면, 자기 답장으로 깨어나지 않으면서도 사람이 대화를 넘겨받았을 때는 알 수 있어요. AI 에이전트가 물러나야 할 때를 알려 주는 신호예요.
메시지 필터는 message.* 이벤트에만 적용되고, 그 외에는 이벤트 유형으로 제어해요. 필터링된 이벤트는 전송이 되기 전에 제외되므로 비용이 들지 않고 실패로 표시되지도 않아요. 페이로드와 apiVersion은 어느 쪽이든 그대로예요.
연이은 메시지를 한 번의 전송으로 묶기
깨어날 때 스레드 전체를 읽는 소비자에게 10초 안에 네 번 따로 전송해 봤자 얻는 게 없어요. coalesceSeconds를 설정하면 한 대화의 메시지를 그 시간 동안 모았다가 한 번에 전송해요. 기본적으로 꺼져 있으며, 설정하지 않은 엔드포인트는 계속 메시지마다 따로 받아요.
묶인 전송은 대화와 수집된 모든 메시지 id를 담은 conversation.activity로 도착하므로, 메시지마다가 아니라 스레드를 한 번만 읽으세요. 받는 데이터의 형태를 바꾸는 유일한 설정이라 직접 켜야 해요. 다른 필터도 그대로 적용되어, 필터로 제외된 메시지는 그룹에 들어가지 않아요. 대화마다 기간이 따로 있고, 재시도는 그룹을 하나의 전송으로 처리해요.
{
"type": "conversation.activity",
"data": {
"conversationId": "cm_conversation_id",
"messageIds": ["cm_first_message", "cm_second_message"]
}
}Bearer 또는 API 키 인증 뒤의 엔드포인트
수신 서버가 헤더를 요구하는 게이트웨이 뒤에 있다면 엔드포인트를 만들거나 편집할 때 사용자 지정 헤더에 추가하거나, API에서 headers를 보내세요. 값은 저장 시 암호화되며 반환되지 않아요. 저장된 헤더는 이름만 돌아오고, 그 이름만 다시 보내면 저장된 값이 유지돼요. 모든 헤더를 지우려면 빈 배열을 보내세요.
엔드포인트를 다른 호스트로 옮기면 재사용이 해제되어 저장된 값을 다시 입력해야 해요. 그래서 자격 증명이 발급 대상이 아닌 곳으로 전달되지 않아요. 경로만 바꾸면 유지돼요.
Sonny 자체 헤더가 우선하므로 사용자 지정 헤더가 sonny-signature, content-type, 전송 식별 헤더를 대체할 수 없어요. 가능하면 서명을 검증하세요. 서명은 모든 페이로드를 인증하지만, 고정 토큰은 호출자만 식별해요.
{
"name": "Gateway",
"url": "https://api.example.com/hooks/sonny",
"events": ["conversation.created"],
"headers": [{ "name": "Authorization", "value": "Bearer …" }]
}보안과 안정성
- 서명된 원본 본문
- HMAC-SHA256은 Unix 타임스탬프, 점, 수정되지 않은 UTF-8 요청 본문을 함께 서명해요.
- 재전송 공격 방지
- HMAC가 유효하더라도 과거나 미래로 5분 넘게 차이 나는 타임스탬프는 거부하세요.
- 안정적인 재시도
- 2xx가 아닌 응답은 1m, 5m, 30m, 2h, 6h 후에 재시도해요. 6번째 시도가 마지막이에요.
요청 규격
sonny-signature- t=<unix-seconds>,v1=<sha256-hex>
sonny-event- 빠른 라우팅을 위한 이벤트 유형.
sonny-delivery-id- 멱등성과 지원을 위한 고정 ID.
user-agent- Sonny-Webhooks/1.0
{
"id": "cm_event_id",
"type": "message.created",
"apiVersion": "2026-07-15",
"createdAt": "2026-07-15T12:00:00.000Z",
"data": {
"conversationId": "cm_conversation_id",
"messageId": "cm_message_id"
}
}서명 검증하기
먼저 원본 본문을 읽으세요. JSON을 파싱한 뒤 다시 직렬화하면 공백이 바뀌어 올바른 서명도 실패해요.
import { createHmac, timingSafeEqual } from "node:crypto";
const rawBody = await request.text(); // do not parse/re-serialize first
const signature = request.headers.get("sonny-signature");
const secret = process.env.SONNY_WEBHOOK_SECRET;
if (!signature || !secret) throw new Error("Missing webhook signature or secret");
const { t, v1 } = Object.fromEntries(signature.split(",").map(p => p.split("=")));
if (!/^\d+$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1)) {
throw new Error("Malformed signature");
}
if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) {
throw new Error("Stale webhook");
}
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"))) {
throw new Error("Invalid signature");
}이벤트 카탈로그
webhook.heartbeat새 메시지가 없어도 활성화된 구독자(* 포함)에게 5분마다 전송돼요. 일반적인 서명된 전송/재시도 경로를 사용하며 대화 필터는 무시해요. 하트비트가 없거나 오래되면 알림을 설정하세요. 답장을 기다리는 대화를 검사하지는 않아요.
{"intervalSeconds":300,"nextExpectedAt":"2026-09-29T16:05:00.000Z"}contact.created연락처가 생성되었어요.
{"contactId":"cm_contact_id"}contact.updated연락처가 업데이트되거나 병합되었어요.
{"contactId":"cm_contact_id"}contact.deleted연락처가 보관되었거나 다른 연락처로 병합되었어요. archived=true로 계속 읽을 수 있어요.
{"contactId":"cm_contact_id"}contact.erased연락처가 모든 대화, 메시지, 첨부 파일과 함께 영구 삭제되었어요(예: GDPR 삭제 요청). 더 이상 읽을 수 없으니 보관 중인 사본도 삭제하세요.
{"contactId":"cm_contact_id"}conversation.created대화가 생성되었어요.
{"conversationId":"cm_conversation_id"}conversation.updated대화가 변경되었어요.
{"conversationId":"cm_conversation_id"}conversation.closed대화가 종료되었어요.
{"conversationId":"cm_conversation_id"}conversation.deleted대화가 휴지통으로 이동해 공개 API에서 제외되었어요.
{"conversationId":"cm_conversation_id"}message.created메시지나 내부 메모가 생성되었어요. 가능한 경우 컨텍스트와 짧은 메시지 미리보기가 포함되며, 내부 메모 내용은 절대 포함되지 않아요.
{"conversationId":"cm_conversation_id","messageId":"cm_message_id","context":{"sourceId":"cm_source_id","sourceName":"Shopstar Go","storeName":"Shiney Store","inboxId":"cm_inbox_id","inboxName":"Go support","agentGroupId":"cm_group_id","agentGroupName":"Support","identityVerified":true,"status":"open","lastRepliedAt":"2026-09-25T09:00:00.000Z"},"message":{"id":"cm_message_id","type":"incoming","senderType":"human","senderId":null,"senderName":"Shiney","textPreview":"Could you check my bill?","textTruncated":false}}message.updated보낸 답장이 대화 기록에서 수정되었어요. 이미 전송된 이메일은 바뀌지 않아요.
{"conversationId":"cm_conversation_id","messageId":"cm_message_id"}tag.created태그가 생성되었어요.
{"tagId":"cm_tag_id"}tag.updated태그가 업데이트되었어요.
{"tagId":"cm_tag_id"}tag.deleted태그가 삭제되었어요.
{"tagId":"cm_tag_id"}member.invited워크스페이스 멤버가 초대되었어요.
{"invitationId":"cm_invitation_id"}member.updated멤버의 역할이나 상태가 변경되었어요.
{"memberId":"cm_membership_id"}member.removed멤버가 제거되었어요.
{"memberId":"cm_membership_id"}invitation.accepted워크스페이스 초대가 수락되었어요.
{"invitationId":"cm_invitation_id","memberId":"cm_membership_id"}invitation.cancelled대기 중인 워크스페이스 초대가 취소되었어요.
{"invitationId":"cm_invitation_id"}conversation.activity한 대화의 메시지를 하나의 전송으로 묶은 이벤트예요. 가능한 경우 메시지마다 스냅샷이 포함돼요. 묶기 기간이 설정된 엔드포인트에 message.created 대신 전송되며, 직접 구독할 수는 없어요.
{"conversationId":"cm_conversation_id","messageIds":["cm_message_id","cm_other_message_id"]}webhook.test관리자가 요청한 테스트 이벤트예요.
{"message":"This is a test webhook from Sonny."}
전송 동작
- 10초 안에 2xx 상태를 반환하면 전송이 성공한 것으로 처리돼요.
- 핸들러를 멱등적으로 유지하세요. 이벤트
id나sonny-delivery-id로 중복을 무시하세요. - 리디렉션은 따라가지 않아요. 대신 Sonny에서 엔드포인트 URL을 업데이트하세요.
- 응답 본문은 크기가 제한되며 진단용으로 처음 4KiB만 보관돼요.
- 테스트를 선택하면
webhook.test이벤트를 보내요. 전송 기록을 선택하면 최근 50개 이벤트를 확인할 수 있어요. 전송이 최종 실패하면 재시도를 선택해 다시 보내세요.
조용해진 피드 감지하기
개발자 설정이나 update_webhook으로 엔드포인트 이벤트에 webhook.heartbeat를 추가하세요. 활성화된 구독자(* 포함)는 새 메시지가 없어도 5분마다 서명된 하트비트를 받아요. 하트비트는 채널, 팀, 메시지 필터를 무시하고 대화 데이터를 담지 않아요. 메시지와 같은 전송 경로와 재시도를 사용해요. 오래된 재시도가 새 하트비트처럼 보이지 않도록 createdAt과 nextExpectedAt을 확인하고, 알림을 보내기 전에 폴링과 네트워크 지연을 감안하세요. 전송 적체가 있으면 하트비트가 늦어지거나 누락될 수 있어요.
webhooks:read로 list_webhook_deliveries를 써서 시도와 실패를 확인하세요. get_status는 웹훅 전송이 아니라 데이터베이스 연결을 확인해요. 피드가 조용할 때도 오래된 미답변 스레드를 찾을 수 있도록 status=open, awaitingReply=true, sort=waitingSince, direction=asc로 list_conversations를 따로 예약 실행하세요.
개발자 설정에서 간소화된 페이로드를 켜거나 엔드포인트에 compact=true를 설정하면, 라우팅 ID, 보낸 사람, 시간, 200자 메시지 미리보기는 유지하고 맥락 라벨은 생략해요. 묶인 전송에도 적용되며, 내부 메모 텍스트는 계속 제외돼요. 기본 페이로드는 메시지 타임스탬프가 추가된 것 외에는 그대로예요. 전송 기록과 연락처 직접 조회를 위해 기존 API 키의 범위를 편집해 webhooks:read와 contacts:read를 부여하세요. 두 범위 모두 모든 채널 키와 모든 채널 멤버 접근 권한이 필요하며, 범위가 제한된 키는 권한을 추가하는 것만으로 넓힐 수 없어요.