웹훅
앱의 이벤트(발행·수정·삭제·리액션…)를 당신 백엔드가 HTTP로 받는다. 푸시 알림, 검색 색인, 감사 로그, 외부 시스템 동기화가 여기 붙는다.
웹훅 주소는 콘솔의 웹훅 화면에서 설정한다. 앱 개요 옆 "웹훅" 탭에서 받을 주소(와 필요하면
before_publish)를 넣으면, 서명 시크릿을 그 화면이 한 번 보여 준다 — 콘솔은 이 값을 저장하지 않으니 그 자리에서 엔드포인트의 검증 코드에 옮겨 적는다.
받는 코드
import express from 'express'
import { createChatServer } from '@kispi/chat/server'
const app = express()
const seen = new Set<string>() // 실제로는 Redis나 DB처럼 프로세스 밖에 둔다
const chat = createChatServer({
secretKey: process.env.CHAT_SECRET_KEY!,
webhookSecret: process.env.CHAT_WEBHOOK_SECRET // 콘솔의 웹훅 화면이 한 번 보여준 서명 시크릿
})
app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
let payload
try {
payload = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
} catch {
return res.sendStatus(400) // 서명이 맞지 않는다. 아무것도 처리하지 않는다
}
const { event, delivery, data } = payload
if (seen.has(delivery)) return res.sendStatus(200) // at-least-once다
seen.add(delivery)
res.sendStatus(200) // 10초 안에 먼저 답하고
void handle(event, data) // 처리는 비동기로
})import hmac, hashlib, json, time
# raw_body는 받은 바이트 그대로다. 파싱한 뒤 다시 직렬화하면 서명이 맞지 않는다.
# 서명이 맞으면 파싱한 바디를, 아니면 None(→ 400)을 돌려준다.
def verify(headers, raw_body, secret):
event = headers.get("X-Chat-Event", "")
delivery = headers.get("X-Chat-Delivery", "") # before_publish에는 이 헤더가 없다
timestamp, macs = None, []
for item in headers.get("X-Chat-Signature", "").split(","):
name, _, value = item.strip().partition("=")
if name == "t":
timestamp = value
elif name == "v1":
macs.append(value) # 여러 개일 수 있고 하나라도 맞으면 통과다
if timestamp is None or not macs:
return None
if abs(time.time() - int(timestamp)) > 300: # 양방향 5분. 미래 시각도 거절이다
return None
signed = f"{timestamp}\n{event}\n{delivery}\n".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, mac) for mac in macs):
return None
return json.loads(raw_body)지킬 것 넷
- 바디는 raw 그대로 검증한다. 서명은 받은 바이트에 대한 것이라, JSON으로 파싱했다가
다시 직렬화한 문자열로는 맞지 않는다. 프레임워크의 JSON 파서가 먼저 바디를 먹지 않게 이
라우트만 raw로 받는다(Express는
express.raw, SvelteKit은await request.text()). - 배달은 at-least-once다. 같은 배달이 두 번 올 수 있다. 모든 배달에는
deliveryid가 있고 재시도해도 바뀌지 않는다. 처리한 것을 기억해 두고 두 번째는 200만 답한다. - 10초 안에 2xx로 답한다. 넘기거나 2xx가 아니면 실패로 치고 백오프가 시작된다(1초 → 5초 → 25초 → 2분 → 10분). 실패하면 그 앱의 배달은 그 이벤트에서 멈추고 뒤의 것을 먼저 보내지 않는다. 무거운 일은 큐에 넣고 먼저 답한다.
- 순서는 방 안에서만 보장된다. 한 메시지에 대한 이벤트(
message.created→reaction.added→message.deleted)는 뒤집히지 않지만, 다른 방의 이벤트끼리는 뒤집힐 수 있다.
놓친 것 따라잡기
엔드포인트가 한동안 죽어 있었으면 chat.events.list({ after, limit })로 지난 이벤트를 읽어
따라잡는다. 각 항목은 { delivery, event, createdAt, data }이고 data는 웹훅이 POST하는 바디와
같다. 마지막으로 처리한 delivery를 after로 주고, 빈 cursor가 오면 그게 전부였다는 뜻이다.
여기서도 delivery로 중복을 거른다 — 같은 이벤트가 웹훅과 이 목록 양쪽에서 올 수 있다.
지난 이벤트는 7일까지만 남으니 따라잡기를 그보다 미루지 않는다.
before_publish — 발행 전에 끼어들기
켜 두면 발행과 편집이 당신 엔드포인트를 동기로 지나간다. 거절하거나 본문을 바꿔 돌려줄 수 있는 유일한 웹훅이다. 금칙어 치환, 이미지 모더레이션, 스팸 차단이 여기 산다.
→ { "event": "before_publish", "op": "create" | "update", "messageId"?: "…",
"room": "…", "sender": {…}, "kind": "text", "body": {…}, "meta"?: {…} }
← { "action": "allow" | "deny", "body"?: {…}, "appMeta"?: {…},
"code"?: "…", "message"?: "…" }- 검증하는 방법은 같다. 다만 질문이라
delivery가 없고 중복 제거 대상이 아니다. deny면 보낸 사람의send가moderation_denied로 실패하고, 당신이 준code가 클라이언트의err.appCode에,message가 에러 문구에 실린다.body를 돌려주면 그것으로 바꿔 저장하고 방송한다. 바뀐 본문도 크기 검사를 다시 받는다.action이allow/deny가 아니면 실패로 친다. 오타가 모더레이션 우회가 되지 않게.- 엔드포인트가 타임아웃되거나 에러면 기본은 **
allow**다.deny로 바꿀 수도 있지만, 그러면 엔드포인트가 죽었을 때 그 앱의 발행이 전부 멈춘다. - 서버 API(
sk_)로 보내는 메시지는 심사하지 않는다.
빨라야 한다. 이 호출이 끝날 때까지 보낸 사람의 메시지가 기다리고, 제한 시간은 길어야 5초다.
다른 언어로 검증하기
요청에 헤더 셋이 온다.
| 헤더 | |
|---|---|
X-Chat-Event |
이벤트 이름. before_publish면 그 값이 온다 |
X-Chat-Delivery |
배달 id. before_publish에는 이 헤더가 없다 |
X-Chat-Signature |
t=<unix 초>,v1=<hex>. v1은 여러 개일 수 있고 하나라도 맞으면 통과다 |
서명 입력은 네 조각을 줄바꿈(\n)으로 이은 것이다.
<t>
<X-Chat-Event>
<X-Chat-Delivery, 없으면 빈 문자열>
<받은 바디 바이트 그대로>v1은 hex(HMAC-SHA256(웹훅 시크릿, 서명 입력))이다. 시크릿은 sk_가 아니라 콘솔의 웹훅
화면이 따로 보여준 웹훅 시크릿이다. 지금 - t의 절대값이 5분을 넘으면 거절한다(양방향 —
미래 시각도 거절이다). 재시도는 t만 갱신하고 X-Chat-Delivery는 유지하므로, 중복 제거는 그 값으로 한다.