웹훅

앱의 이벤트(발행·수정·삭제·리액션…)를 당신 백엔드가 HTTP로 받는다. 푸시 알림, 검색 색인, 감사 로그, 외부 시스템 동기화가 여기 붙는다.

웹훅 주소는 콘솔의 웹훅 화면에서 설정한다. 앱 개요 옆 "웹훅" 탭에서 받을 주소(와 필요하면 before_publish)를 넣으면, 서명 시크릿을 그 화면이 한 번 보여 준다 — 콘솔은 이 값을 저장하지 않으니 그 자리에서 엔드포인트의 검증 코드에 옮겨 적는다.

받는 코드

ts
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) // 처리는 비동기로
})
python
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)

지킬 것 넷

  1. 바디는 raw 그대로 검증한다. 서명은 받은 바이트에 대한 것이라, JSON으로 파싱했다가 다시 직렬화한 문자열로는 맞지 않는다. 프레임워크의 JSON 파서가 먼저 바디를 먹지 않게 이 라우트만 raw로 받는다(Express는 express.raw, SvelteKit은 await request.text()).
  2. 배달은 at-least-once다. 같은 배달이 두 번 올 수 있다. 모든 배달에는 delivery id가 있고 재시도해도 바뀌지 않는다. 처리한 것을 기억해 두고 두 번째는 200만 답한다.
  3. 10초 안에 2xx로 답한다. 넘기거나 2xx가 아니면 실패로 치고 백오프가 시작된다(1초 → 5초 → 25초 → 2분 → 10분). 실패하면 그 앱의 배달은 그 이벤트에서 멈추고 뒤의 것을 먼저 보내지 않는다. 무거운 일은 큐에 넣고 먼저 답한다.
  4. 순서는 방 안에서만 보장된다. 한 메시지에 대한 이벤트(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는 유지하므로, 중복 제거는 그 값으로 한다.